chore: أرشفة تطبيقات ولوحات تريبز التي سيحلّ محلها كود سيرو
استكمال قرار 2026-07-27 (كوميت fff76c9 أرشف الباك إند):
apps/rider → apps/rider-archive-cubit
apps/driver → apps/driver-archive-cubit
dashboards/admin-web → dashboards/admin-web-archive
dashboards/service-web → dashboards/service-web-archive
إعادة تسمية بلا تعديل محتوى. rider/driver كانا إعادة بناء Cubit مبنية على
عقد NestJS المؤرشف. admin-web و service-web نموذجان بملف HTML واحد (164 و
128 سطراً) — يحلّ محلهما siro_admin و siro_service المكتملان.
يُحمل من المؤرشف ولا يُعاد اكتشافه: عقد map-saas المثبَّت بالتجربة في
apps/rider-archive-cubit/lib/core/api/ (x-api-key ترويسة، reverse مصفوفة،
route في points) ودرس مسار FCM.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
fff76c949f
commit
9909d9b4c1
@@ -1,88 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'core/l10n/app_localizations.dart';
|
||||
import 'core/di.dart';
|
||||
import 'core/router.dart';
|
||||
import 'core/design/theme.dart';
|
||||
import 'features/auth/cubit/auth_cubit.dart';
|
||||
import 'features/auth/cubit/auth_state.dart';
|
||||
import 'features/auth/data/auth_repository.dart';
|
||||
import 'features/settings/cubit/settings_cubit.dart';
|
||||
import 'features/trip/bloc/trip_bloc.dart';
|
||||
import 'features/trip/bloc/trip_event.dart';
|
||||
import 'features/trip/data/trip_socket_service.dart';
|
||||
import 'features/trip/data/fcm_service.dart';
|
||||
|
||||
class TripzApp extends StatelessWidget {
|
||||
const TripzApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(create: (_) => SettingsCubit()),
|
||||
BlocProvider(
|
||||
create: (_) => AuthCubit(getIt<AuthRepository>())..checkAuth(),
|
||||
),
|
||||
BlocProvider<TripBloc>(
|
||||
create: (_) => TripBloc(
|
||||
tripRepo: getIt(),
|
||||
socket: getIt<TripSocketService>(),
|
||||
)..add(const TripCheckActive()),
|
||||
),
|
||||
],
|
||||
child: _AppListener(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final settings = context.watch<SettingsCubit>();
|
||||
final auth = context.read<AuthCubit>();
|
||||
final router = buildRouter(auth);
|
||||
|
||||
return MaterialApp.router(
|
||||
title: 'Tripz',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: buildTheme(
|
||||
brightness: Brightness.light,
|
||||
locale: settings.state.locale,
|
||||
),
|
||||
darkTheme: buildTheme(
|
||||
brightness: Brightness.dark,
|
||||
locale: settings.state.locale,
|
||||
),
|
||||
themeMode: settings.state.themeMode,
|
||||
locale: settings.state.locale,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
routerConfig: router,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// يستمع لتغيير حالة المصادقة ويُهيّئ FCM بعد تسجيل الدخول.
|
||||
class _AppListener extends StatefulWidget {
|
||||
final Widget child;
|
||||
const _AppListener({required this.child});
|
||||
|
||||
@override
|
||||
State<_AppListener> createState() => _AppListenerState();
|
||||
}
|
||||
|
||||
class _AppListenerState extends State<_AppListener> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final auth = context.read<AuthCubit>();
|
||||
auth.stream.listen((state) {
|
||||
if (state.status == AuthStatus.authenticated) {
|
||||
getIt<FcmService>().init();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.child;
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import '../config.dart';
|
||||
|
||||
/// واجهة مباشرة مع انطلق (map-saas) — لا مرور عبر الباك إند.
|
||||
///
|
||||
/// كل نداء يمر عبر هذه النافذة: البحث، التفكيك العكسي، المسار.
|
||||
/// الباك إند يبقى مسؤولاً عن المصادقة والرحلات والمدفوعات فقط.
|
||||
class AntlaqApi {
|
||||
final String apiKey;
|
||||
final Dio _dio;
|
||||
|
||||
AntlaqApi({String? apiKey})
|
||||
: apiKey = apiKey ?? AppConfig.mapApiKey,
|
||||
_dio = Dio(BaseOptions(
|
||||
baseUrl: 'https://map-saas.intaleqapp.com',
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
// المفتاح يمر كترويسة `x-api-key` — map-saas يرفض أي مفتاح في
|
||||
// query (تحقّق صارم: `property api_key should not exist`).
|
||||
// المفتاح مقيّد ببصمة التطبيق (bundle id) على map-saas نفسه.
|
||||
headers: {'x-api-key': apiKey ?? AppConfig.mapApiKey},
|
||||
));
|
||||
|
||||
/// بحث أماكن — GET /api/geocoding/search.
|
||||
Future<List<dynamic>> search(String query) async {
|
||||
final r = await _dio.get('/api/geocoding/search', queryParameters: {
|
||||
'q': query,
|
||||
});
|
||||
return (r.data['results'] ?? r.data) as List;
|
||||
}
|
||||
|
||||
/// تفكيك عنوان عكسي — GET /api/geocoding/reverse.
|
||||
/// يرجّع map-saas **مصفوفة** مرتّبة بالأقرب؛ نأخذ العنصر الأول.
|
||||
Future<Map<String, dynamic>> reverse(double lat, double lng) async {
|
||||
final r = await _dio.get('/api/geocoding/reverse', queryParameters: {
|
||||
'lat': lat,
|
||||
'lng': lng,
|
||||
});
|
||||
final data = r.data;
|
||||
if (data is List) {
|
||||
if (data.isEmpty) return {'latitude': lat, 'longitude': lng};
|
||||
return Map<String, dynamic>.from(data.first as Map);
|
||||
}
|
||||
return Map<String, dynamic>.from(data as Map);
|
||||
}
|
||||
|
||||
/// حساب مسار بين نقطتين — GET /api/maps/route.
|
||||
Future<Map<String, dynamic>> route({
|
||||
required double fromLat,
|
||||
required double fromLng,
|
||||
required double toLat,
|
||||
required double toLng,
|
||||
}) async {
|
||||
final r = await _dio.get('/api/maps/route', queryParameters: {
|
||||
'fromLat': fromLat,
|
||||
'fromLng': fromLng,
|
||||
'toLat': toLat,
|
||||
'toLng': toLng,
|
||||
'alternatives': true,
|
||||
'steps': true,
|
||||
'locale': 'ar',
|
||||
});
|
||||
return r.data as Map<String, dynamic>;
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import '../config.dart';
|
||||
import '../storage/token_store.dart';
|
||||
import 'logging_interceptor.dart';
|
||||
|
||||
/// عميل HTTP لكل نداءات الـAPI (docs/23 §4).
|
||||
///
|
||||
/// **نقطة الشبكة الوحيدة** في التطبيق: لا `http.get` ولا `Dio` آخر في أي
|
||||
/// مكان. السبب أن كل ما يفرضه السيرفر (المستأجر · التوكن · بصمة الجهاز ·
|
||||
/// التوقيع) يُحقن هنا مرة واحدة؛ نداءٌ يلتفّ حول هذا الملف هو نداء بلا
|
||||
/// مصادقة يفشل عند تفعيل الحراسات على السيرفر.
|
||||
class ApiClient {
|
||||
final Dio dio;
|
||||
final TokenStore _store;
|
||||
|
||||
/// بصمة الجهاز (docs/17 — D2). تُحسب مرة وتُخزَّن: قراءتها من النظام في كل
|
||||
/// طلب استدعاءُ قناة أصيلة بلا داعٍ.
|
||||
static String? _deviceId;
|
||||
|
||||
ApiClient(this._store)
|
||||
: dio = Dio(BaseOptions(
|
||||
baseUrl: AppConfig.baseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
)) {
|
||||
// Resolve device ID once in background — don't block requests
|
||||
_resolveDeviceId();
|
||||
|
||||
dio.interceptors.add(AppLoggingInterceptor());
|
||||
dio.interceptors.add(InterceptorsWrapper(
|
||||
onRequest: (options, handler) async {
|
||||
options.headers['x-tenant-id'] = AppConfig.tenantSlug;
|
||||
// راكب أم سائق — هويتان منفصلتان لنفس الرقم (قرار المالك 2026-07-20 — ب).
|
||||
options.headers['x-app-role'] = AppConfig.appRole;
|
||||
|
||||
// Device ID is cached after first call — non-blocking
|
||||
final device = _deviceId;
|
||||
if (device != null) options.headers['x-device-id'] = device;
|
||||
|
||||
final token = await _store.access();
|
||||
if (token != null && token.isNotEmpty) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
handler.next(options);
|
||||
},
|
||||
onError: (e, handler) async {
|
||||
// تجديد التوكن عند 401 — **مرة واحدة**. بلا علامة `_retried` يعيد
|
||||
// الطلبُ الفاشل تجديداً يفشل فيعيد… حلقة لانهائية تستنزف البطارية
|
||||
// وتغرق السيرفر عند انتهاء الجلسة فعلاً.
|
||||
final isAuthError = e.response?.statusCode == 401;
|
||||
final alreadyRetried = e.requestOptions.extra['_retried'] == true;
|
||||
final isRefreshCall = e.requestOptions.path.contains('/auth/refresh');
|
||||
|
||||
if (!isAuthError || alreadyRetried || isRefreshCall) {
|
||||
return handler.next(e);
|
||||
}
|
||||
|
||||
final refreshed = await _tryRefresh();
|
||||
if (!refreshed) {
|
||||
await _store.clear(); // الجلسة انتهت فعلاً — لا نُبقي توكناً ميتاً
|
||||
return handler.next(e);
|
||||
}
|
||||
|
||||
try {
|
||||
e.requestOptions.extra['_retried'] = true;
|
||||
final retry = await dio.fetch(e.requestOptions);
|
||||
return handler.resolve(retry);
|
||||
} on DioException catch (err) {
|
||||
return handler.next(err);
|
||||
}
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
Future<bool> _tryRefresh() async {
|
||||
final rt = await _store.refresh();
|
||||
if (rt == null || rt.isEmpty) return false;
|
||||
try {
|
||||
// Dio نظيف: المرور بالعميل نفسه يُدخل التوكن المنتهي في الترويسة.
|
||||
final plain = Dio(BaseOptions(baseUrl: AppConfig.baseUrl));
|
||||
final r = await plain.post('/auth/refresh', data: {'refresh_token': rt});
|
||||
await _store.save(
|
||||
r.data['access_token'] as String,
|
||||
(r.data['refresh_token'] ?? rt) as String,
|
||||
);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String?> _resolveDeviceId() async {
|
||||
if (_deviceId != null) return _deviceId;
|
||||
try {
|
||||
final info = DeviceInfoPlugin();
|
||||
if (Platform.isAndroid) {
|
||||
_deviceId = (await info.androidInfo).id;
|
||||
} else if (Platform.isIOS) {
|
||||
_deviceId = (await info.iosInfo).identifierForVendor;
|
||||
}
|
||||
} catch (_) {
|
||||
// تعذّر قراءة الجهاز: نُرسل بلا الترويسة بدل إسقاط الطلب. السيرفر
|
||||
// يرفض عند تفعيل الربط، وهذا أوضح من فشل صامت هنا.
|
||||
_deviceId = null;
|
||||
}
|
||||
return _deviceId;
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
/// استثناءات الشبكة الموحّدة — كل خطأ يتحول إلى صنف هنا.
|
||||
/// docs/23 §4
|
||||
class ApiException implements Exception {
|
||||
ApiException(this.message, {this.statusCode, this.type});
|
||||
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
final ApiExceptionType? type;
|
||||
|
||||
@override
|
||||
String toString() => 'ApiException($statusCode): $message';
|
||||
}
|
||||
|
||||
enum ApiExceptionType {
|
||||
network,
|
||||
timeout,
|
||||
unauthorized,
|
||||
forbidden,
|
||||
notFound,
|
||||
server,
|
||||
unknown,
|
||||
}
|
||||
|
||||
/// تحليل DioException إلى ApiException
|
||||
ApiException fromDioError(Object error) {
|
||||
final dio = error;
|
||||
final type = switch (dio.runtimeType.toString()) {
|
||||
'DioException' => _mapDioType(dio),
|
||||
_ => ApiExceptionType.unknown,
|
||||
};
|
||||
|
||||
final statusCode = _extractStatusCode(dio);
|
||||
final message = _extractMessage(dio, statusCode);
|
||||
|
||||
return ApiException(message, statusCode: statusCode, type: type);
|
||||
}
|
||||
|
||||
int? _extractStatusCode(Object error) {
|
||||
try {
|
||||
return (error as dynamic).response?.statusCode as int?;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String _extractMessage(Object error, int? statusCode) {
|
||||
try {
|
||||
final data = (error as dynamic).response?.data;
|
||||
if (data is Map) {
|
||||
return (data['message'] ?? data['error'] ?? 'خطأ غير معروف').toString();
|
||||
}
|
||||
} catch (_) {}
|
||||
return switch (statusCode) {
|
||||
401 => 'انتهت الجلسة',
|
||||
403 => 'غير مصرح',
|
||||
404 => 'غير موجود',
|
||||
500 => 'خطأ في السيرفر',
|
||||
_ => 'خطأ غير معروف',
|
||||
};
|
||||
}
|
||||
|
||||
ApiExceptionType _mapDioType(Object error) {
|
||||
try {
|
||||
final dioType = (error as dynamic).type;
|
||||
return switch (dioType.toString()) {
|
||||
'DioExceptionType.connectionTimeout' => ApiExceptionType.timeout,
|
||||
'DioExceptionType.sendTimeout' => ApiExceptionType.timeout,
|
||||
'DioExceptionType.receiveTimeout' => ApiExceptionType.timeout,
|
||||
'DioExceptionType.connectionError' => ApiExceptionType.network,
|
||||
'DioExceptionType.badResponse' => ApiExceptionType.server,
|
||||
'DioExceptionType.cancel' => ApiExceptionType.unknown,
|
||||
_ => ApiExceptionType.unknown,
|
||||
};
|
||||
} catch (_) {
|
||||
return ApiExceptionType.unknown;
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// مساطر طباعة كل طلب HTTP — يظهر فقط في وضع التطوير.
|
||||
class AppLoggingInterceptor extends Interceptor {
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
if (kDebugMode) {
|
||||
final method = options.method;
|
||||
final uri = options.uri;
|
||||
final payload = options.data;
|
||||
|
||||
debugPrint('┌─────── REQUEST ───────');
|
||||
debugPrint('│ $method $uri');
|
||||
if (payload != null) {
|
||||
debugPrint('└────── Payload ───────');
|
||||
try {
|
||||
final json = jsonDecode(payload.toString());
|
||||
debugPrint('${const JsonEncoder.withIndent(' ').convert(json)}');
|
||||
} catch (_) {
|
||||
debugPrint('$payload');
|
||||
}
|
||||
}
|
||||
}
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
@override
|
||||
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||
if (kDebugMode) {
|
||||
final status = response.statusCode;
|
||||
final uri = response.requestOptions.uri;
|
||||
final body = response.data;
|
||||
|
||||
debugPrint('┌─────── RESPONSE [$status] ───────');
|
||||
debugPrint('│ $uri');
|
||||
if (body != null) {
|
||||
try {
|
||||
final json = jsonDecode(body.toString());
|
||||
debugPrint('${const JsonEncoder.withIndent(' ').convert(json)}');
|
||||
} catch (_) {
|
||||
debugPrint('$body');
|
||||
}
|
||||
}
|
||||
}
|
||||
handler.next(response);
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
if (kDebugMode) {
|
||||
final status = err.response?.statusCode;
|
||||
final uri = err.requestOptions.uri;
|
||||
final message = err.message;
|
||||
|
||||
debugPrint('┌─────── ERROR [$status] ───────');
|
||||
debugPrint('│ $uri');
|
||||
debugPrint('│ $message');
|
||||
if (err.response?.data != null) {
|
||||
debugPrint('│ ${err.response?.data}');
|
||||
}
|
||||
}
|
||||
handler.next(err);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
/// مرجع معلومات التطبيق ورقم الإصدار الموحد.
|
||||
abstract final class AppInfo {
|
||||
static const String name = 'Tripz';
|
||||
static const String version = '1.0.0';
|
||||
static const int buildNumber = 1;
|
||||
|
||||
/// الصيغة المنسقة لرقم الإصدار (مثل v1.0.0).
|
||||
static String get formattedVersion => 'v$version';
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
/// مُولَّد من المانيفست عبر سكربت N3 — لا يُحرَّر يدوياً.
|
||||
/// يحتوي على أعلام الميزات `const` + إعداد المستأجر الثابت.
|
||||
/// docs/22 §1.5
|
||||
class BuildConfig {
|
||||
static const String tenantSlug = 'siro';
|
||||
static const String baseUrl = 'https://tripz-api.intaleqapp.com/api';
|
||||
static const String appName = 'Tripz';
|
||||
static const String appRole = 'rider';
|
||||
|
||||
// ألوان المستأجر (البذرة)
|
||||
static const int primaryColorValue = 0xFF0E7C66;
|
||||
static const int secondaryColorValue = 0xFF0E7C66;
|
||||
static const String defaultCountry = 'JO';
|
||||
|
||||
// أعلام الميزات — const: tree-shaking يحذف الكود المطفأ في release
|
||||
// docs/22 §1.5, docs/23 §5
|
||||
static const bool rideClassesSet = true;
|
||||
static const bool tariffEngineFull = true;
|
||||
static const bool liveTrackingPro = true;
|
||||
static const bool coupons = true;
|
||||
static const bool walletPayment = true;
|
||||
static const bool chat = true;
|
||||
static const bool aiDocumentProcessing = false;
|
||||
static const bool heatmap = false;
|
||||
static const bool predictiveDemand = false;
|
||||
static const bool geofence = false;
|
||||
static const bool targetedPromo = false;
|
||||
static const bool whatsappCampaigns = false;
|
||||
static const bool marketingEngine = false;
|
||||
static const bool dynamicPricing = false;
|
||||
static const bool transit = false;
|
||||
static const bool paymentGateways = false;
|
||||
static const bool smsSettlement = false;
|
||||
static const bool negotiator = false;
|
||||
static const bool driverTiers = false;
|
||||
static const bool driverAssurance = false;
|
||||
}
|
||||
|
||||
/// اختصار وصول للاستعمال في شروط Features.xxx
|
||||
abstract final class Features {
|
||||
static const bool rideClassesSet = BuildConfig.rideClassesSet;
|
||||
static const bool tariffEngineFull = BuildConfig.tariffEngineFull;
|
||||
static const bool liveTrackingPro = BuildConfig.liveTrackingPro;
|
||||
static const bool coupons = BuildConfig.coupons;
|
||||
static const bool walletPayment = BuildConfig.walletPayment;
|
||||
static const bool chat = BuildConfig.chat;
|
||||
static const bool aiDocumentProcessing = BuildConfig.aiDocumentProcessing;
|
||||
static const bool heatmap = BuildConfig.heatmap;
|
||||
static const bool predictiveDemand = BuildConfig.predictiveDemand;
|
||||
static const bool geofence = BuildConfig.geofence;
|
||||
static const bool targetedPromo = BuildConfig.targetedPromo;
|
||||
static const bool whatsappCampaigns = BuildConfig.whatsappCampaigns;
|
||||
static const bool marketingEngine = BuildConfig.marketingEngine;
|
||||
static const bool dynamicPricing = BuildConfig.dynamicPricing;
|
||||
static const bool transit = BuildConfig.transit;
|
||||
static const bool paymentGateways = BuildConfig.paymentGateways;
|
||||
static const bool smsSettlement = BuildConfig.smsSettlement;
|
||||
static const bool negotiator = BuildConfig.negotiator;
|
||||
static const bool driverTiers = BuildConfig.driverTiers;
|
||||
static const bool driverAssurance = BuildConfig.driverAssurance;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
/// إعداد وقت التشغيل — يُمرَّر عبر --dart-define عند البناء.
|
||||
///
|
||||
/// ما يخصّ **المستأجر** (اسمه، ألوانه، أعلام ميزاته) لا يوضع هنا: مكانه
|
||||
/// `build_config.dart` المُولَّد من المانيفست (docs/22 §1.5). هذا الملف
|
||||
/// للإعداد التقني المشترك بين كل المستأجرين.
|
||||
class AppConfig {
|
||||
/// الـAPI عبر TLS حصراً (docs/20). المنفذ الخام مغلق على مستوى الشبكة،
|
||||
/// فأي عودة إلى http تعني تطبيقاً لا يتصل أصلاً.
|
||||
static const String baseUrl = String.fromEnvironment(
|
||||
'BASE_URL',
|
||||
defaultValue: 'https://tripz-api.intaleqapp.com/api',
|
||||
);
|
||||
|
||||
/// slug المستأجر. الافتراض للتطوير؛ البناء المولَّد يمرّره صراحةً.
|
||||
static const String tenantSlug = String.fromEnvironment(
|
||||
'TENANT',
|
||||
defaultValue: 'siro',
|
||||
);
|
||||
|
||||
static const String appName = 'Tripz';
|
||||
|
||||
/// دور هذا التطبيق — يميّز تطبيق الراكب عن السائق عند التسجيل.
|
||||
static const String appRole = 'rider';
|
||||
|
||||
/// مفتاح الخريطة — يُضمَّن في التطبيق ويُقيَّد ببصمة التطبيق (bundle id
|
||||
/// للأندرويد/iOS) على map-saas نفسه، فلا يعمل خارج هذا التطبيق (قرار المالك
|
||||
/// 2026-07-20: خرائط مباشرة إلى map-saas بلا مرور عبر الباك إند). يمكن
|
||||
/// تجاوزه ببيئة البناء `--dart-define=MAP_API_KEY=...` لكل مستأجر.
|
||||
static const String mapApiKey = String.fromEnvironment(
|
||||
'MAP_API_KEY',
|
||||
defaultValue: 'in_9478b32836d19cff73db3063',
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../build_config.dart';
|
||||
import 'tripz_colors.dart';
|
||||
import 'typography.dart';
|
||||
|
||||
/// بناء الثيم — المدخلان الوحيدان: السطوع واللغة.
|
||||
/// docs/26 §2, §4
|
||||
ThemeData buildTheme({required Brightness brightness, required Locale locale}) {
|
||||
final bool isDark = brightness == Brightness.dark;
|
||||
final seed = Color(BuildConfig.primaryColorValue); // أخضر تركوازي — بذرة المستأجر
|
||||
|
||||
final colorScheme = ColorScheme.fromSeed(
|
||||
seedColor: seed,
|
||||
brightness: brightness,
|
||||
);
|
||||
|
||||
final textTheme = buildTextTheme(locale);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: brightness,
|
||||
colorScheme: colorScheme,
|
||||
textTheme: textTheme,
|
||||
extensions: [
|
||||
isDark ? TripzColors.dark : TripzColors.light,
|
||||
],
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: colorScheme.surface,
|
||||
foregroundColor: colorScheme.onSurface,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
centerTitle: false,
|
||||
titleTextStyle: textTheme.titleLarge?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: colorScheme.outline),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: colorScheme.outline),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: colorScheme.primary, width: 2),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: colorScheme.error),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
labelStyle: textTheme.bodyLarge,
|
||||
hintStyle: textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
textStyle: textTheme.labelLarge,
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
textStyle: textTheme.labelLarge,
|
||||
),
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
margin: EdgeInsets.zero,
|
||||
),
|
||||
bottomSheetTheme: const BottomSheetThemeData(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
showDragHandle: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// الرموز الثابتة — كل قيمة هنا `const` ولا تُخترَع في الشاشات.
|
||||
/// docs/26 §3
|
||||
abstract final class T {
|
||||
// ── المسافات (مضاعفات 4) ──
|
||||
static const double s4 = 4;
|
||||
static const double s8 = 8;
|
||||
static const double s12 = 12;
|
||||
static const double s16 = 16;
|
||||
static const double s24 = 24;
|
||||
static const double s32 = 32;
|
||||
static const double s48 = 48;
|
||||
static const double s64 = 64;
|
||||
|
||||
/// حشوة الصفحة الافتراضية
|
||||
static const double pagePadding = s16;
|
||||
|
||||
// ── الزوايا ──
|
||||
static const double r8 = 8; // حقول / شارات
|
||||
static const double r12 = 12; // أزرار / بطاقات
|
||||
static const double r16 = 16; // محتوى مدمّق
|
||||
static const double r24 = 24; // bottom sheets
|
||||
static const double rCircle = 999; // avatars
|
||||
|
||||
// ── الحركة (أزمنة) ──
|
||||
static const Duration fast = Duration(milliseconds: 120); // لمسة
|
||||
static const Duration medium = Duration(milliseconds: 240); // انتقال ضمن الشاشة
|
||||
static const Duration slow = Duration(milliseconds: 400); // sheet / صفحة
|
||||
|
||||
// ── المسافات الإضافية ──
|
||||
static const double s20 = 20;
|
||||
|
||||
// ── الظلال ──
|
||||
static const BoxShadow shadowSm = BoxShadow(
|
||||
color: Color(0x0F000000),
|
||||
blurRadius: 4,
|
||||
offset: Offset(0, 1),
|
||||
);
|
||||
static const BoxShadow shadowLg = BoxShadow(
|
||||
color: Color(0x1A000000),
|
||||
blurRadius: 12,
|
||||
offset: Offset(0, 4),
|
||||
);
|
||||
|
||||
// ── الأيقونات ──
|
||||
static const double iconSm = 20;
|
||||
static const double iconMd = 24;
|
||||
static const double iconLg = 32;
|
||||
|
||||
// ── الارتفاعات ──
|
||||
static const double buttonHeight = 52;
|
||||
static const double inputHeight = 52;
|
||||
static const double touchTarget = 48;
|
||||
|
||||
// ── عناصر الخريطة ──
|
||||
static const double markerSize = 40;
|
||||
static const double polylineWidth = 6;
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// الأدوار الدلالية غير الموجودة في ColorScheme الأساسي.
|
||||
/// الشاشة تستدعيها عبر `context.tripzColors` — لا تقرّر درجة اللون بنفسها.
|
||||
/// docs/26 §2
|
||||
@immutable
|
||||
class TripzColors extends ThemeExtension<TripzColors> {
|
||||
const TripzColors({
|
||||
required this.success,
|
||||
required this.warning,
|
||||
required this.danger,
|
||||
required this.info,
|
||||
required this.surfaceRaised,
|
||||
required this.mapOverlayDark,
|
||||
required this.mapOverlayLight,
|
||||
});
|
||||
|
||||
final Color success;
|
||||
final Color warning;
|
||||
final Color danger;
|
||||
final Color info;
|
||||
final Color surfaceRaised;
|
||||
final Color mapOverlayDark;
|
||||
final Color mapOverlayLight;
|
||||
|
||||
@override
|
||||
TripzColors copyWith({
|
||||
Color? success,
|
||||
Color? warning,
|
||||
Color? danger,
|
||||
Color? info,
|
||||
Color? surfaceRaised,
|
||||
Color? mapOverlayDark,
|
||||
Color? mapOverlayLight,
|
||||
}) {
|
||||
return TripzColors(
|
||||
success: success ?? this.success,
|
||||
warning: warning ?? this.warning,
|
||||
danger: danger ?? this.danger,
|
||||
info: info ?? this.info,
|
||||
surfaceRaised: surfaceRaised ?? this.surfaceRaised,
|
||||
mapOverlayDark: mapOverlayDark ?? this.mapOverlayDark,
|
||||
mapOverlayLight: mapOverlayLight ?? this.mapOverlayLight,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TripzColors lerp(TripzColors? other, double t) {
|
||||
if (other is! TripzColors) return this;
|
||||
return TripzColors(
|
||||
success: Color.lerp(success, other.success, t)!,
|
||||
warning: Color.lerp(warning, other.warning, t)!,
|
||||
danger: Color.lerp(danger, other.danger, t)!,
|
||||
info: Color.lerp(info, other.info, t)!,
|
||||
surfaceRaised: Color.lerp(surfaceRaised, other.surfaceRaised, t)!,
|
||||
mapOverlayDark: Color.lerp(mapOverlayDark, other.mapOverlayDark, t)!,
|
||||
mapOverlayLight: Color.lerp(mapOverlayLight, other.mapOverlayLight, t)!,
|
||||
);
|
||||
}
|
||||
|
||||
// ── الوضع النهاري ──
|
||||
static const light = TripzColors(
|
||||
success: Color(0xFF2E7D32),
|
||||
warning: Color(0xFFF9A825),
|
||||
danger: Color(0xFFD32F2F),
|
||||
info: Color(0xFF1565C0),
|
||||
surfaceRaised: Color(0xFFF5F5F5),
|
||||
mapOverlayDark: Color(0xCC1A1A2E),
|
||||
mapOverlayLight: Color(0xCCFFFFFF),
|
||||
);
|
||||
|
||||
// ── الوضع الليلي ──
|
||||
static const dark = TripzColors(
|
||||
success: Color(0xFF66BB6A),
|
||||
warning: Color(0xFFFFD54F),
|
||||
danger: Color(0xFFEF5350),
|
||||
info: Color(0xFF42A5F5),
|
||||
surfaceRaised: Color(0xFF2C2C3A),
|
||||
mapOverlayDark: Color(0xCC0D0D1A),
|
||||
mapOverlayLight: Color(0xCC1A1A2E),
|
||||
);
|
||||
}
|
||||
|
||||
/// امتداد مريح للوصول السريع من السياق
|
||||
extension TripzColorsExtension on BuildContext {
|
||||
TripzColors get tripzColors =>
|
||||
Theme.of(this).extension<TripzColors>() ?? TripzColors.light;
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// الخطوط المضمّنة محلياً — Inter + IBM Plex Sans Arabic.
|
||||
/// لا حزمة google_fonts — الاتصال لا يُراهَن عليه.
|
||||
/// docs/26 §1
|
||||
///
|
||||
/// الأوزان المضمّنة: Regular(400) · Medium(500) · SemiBold(600) · Bold(700)
|
||||
/// لكل خط (٨ ملفات في `assets/fonts/`).
|
||||
TextTheme buildTextTheme(Locale locale) {
|
||||
final bool isArabic = locale.languageCode == 'ar';
|
||||
|
||||
// الخط الرئيسي: عربي ⇒ Plex Arabic، إنجليزي ⇒ Inter
|
||||
final String primary = isArabic ? 'IBMPlexSansArabic' : 'Inter';
|
||||
// الاحتياطي: العكس
|
||||
final String fallback = isArabic ? 'Inter' : 'IBMPlexSansArabic';
|
||||
|
||||
return TextTheme(
|
||||
displayLarge: TextStyle(fontFamily: primary, fontFamilyFallback: [fallback], fontWeight: FontWeight.w700, fontSize: 57, height: 1.12),
|
||||
displayMedium: TextStyle(fontFamily: primary, fontFamilyFallback: [fallback], fontWeight: FontWeight.w700, fontSize: 45, height: 1.16),
|
||||
displaySmall: TextStyle(fontFamily: primary, fontFamilyFallback: [fallback], fontWeight: FontWeight.w700, fontSize: 36, height: 1.22),
|
||||
headlineLarge: TextStyle(fontFamily: primary, fontFamilyFallback: [fallback], fontWeight: FontWeight.w700, fontSize: 32, height: 1.25),
|
||||
headlineMedium: TextStyle(fontFamily: primary, fontFamilyFallback: [fallback], fontWeight: FontWeight.w600, fontSize: 28, height: 1.29),
|
||||
headlineSmall: TextStyle(fontFamily: primary, fontFamilyFallback: [fallback], fontWeight: FontWeight.w600, fontSize: 24, height: 1.33),
|
||||
titleLarge: TextStyle(fontFamily: primary, fontFamilyFallback: [fallback], fontWeight: FontWeight.w600, fontSize: 22, height: 1.27),
|
||||
titleMedium: TextStyle(fontFamily: primary, fontFamilyFallback: [fallback], fontWeight: FontWeight.w500, fontSize: 16, height: 1.50, letterSpacing: 0.15),
|
||||
titleSmall: TextStyle(fontFamily: primary, fontFamilyFallback: [fallback], fontWeight: FontWeight.w500, fontSize: 14, height: 1.43, letterSpacing: 0.1),
|
||||
bodyLarge: TextStyle(fontFamily: primary, fontFamilyFallback: [fallback], fontWeight: FontWeight.w400, fontSize: 16, height: 1.50, letterSpacing: 0.5),
|
||||
bodyMedium: TextStyle(fontFamily: primary, fontFamilyFallback: [fallback], fontWeight: FontWeight.w400, fontSize: 14, height: 1.43, letterSpacing: 0.25),
|
||||
bodySmall: TextStyle(fontFamily: primary, fontFamilyFallback: [fallback], fontWeight: FontWeight.w400, fontSize: 12, height: 1.33, letterSpacing: 0.4),
|
||||
labelLarge: TextStyle(fontFamily: primary, fontFamilyFallback: [fallback], fontWeight: FontWeight.w500, fontSize: 14, height: 1.43, letterSpacing: 0.1),
|
||||
labelMedium: TextStyle(fontFamily: primary, fontFamilyFallback: [fallback], fontWeight: FontWeight.w500, fontSize: 12, height: 1.33, letterSpacing: 0.5),
|
||||
labelSmall: TextStyle(fontFamily: primary, fontFamilyFallback: [fallback], fontWeight: FontWeight.w500, fontSize: 11, height: 1.45, letterSpacing: 0.5),
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'api/api_client.dart';
|
||||
import 'api/antlaq_api.dart';
|
||||
import 'storage/token_store.dart';
|
||||
import 'storage/saved_places_store.dart';
|
||||
import '../features/auth/data/auth_repository.dart';
|
||||
import '../features/home/data/maps_repository.dart';
|
||||
import '../features/home/data/tariff_repository.dart';
|
||||
import '../features/home/data/ride_types_repository.dart';
|
||||
import '../features/trip/data/trip_repository.dart';
|
||||
import '../features/trip/data/trip_socket_service.dart';
|
||||
import '../features/trip/data/trip_event_merger.dart';
|
||||
import '../features/trip/data/fcm_service.dart';
|
||||
|
||||
final GetIt getIt = GetIt.instance;
|
||||
|
||||
/// حقن التبعيات (بديل bindings في GetX).
|
||||
Future<void> setupDi() async {
|
||||
getIt.registerLazySingleton<TokenStore>(() => TokenStore());
|
||||
getIt.registerLazySingleton<ApiClient>(() => ApiClient(getIt<TokenStore>()));
|
||||
getIt.registerLazySingleton<AuthRepository>(
|
||||
() => AuthRepository(getIt<ApiClient>(), getIt<TokenStore>()),
|
||||
);
|
||||
|
||||
// ميزات Phase 1: الخرائط + الأسعار
|
||||
getIt.registerLazySingleton<AntlaqApi>(() => AntlaqApi());
|
||||
getIt.registerLazySingleton<MapsRepository>(
|
||||
() => MapsRepository(getIt<AntlaqApi>(), getIt<ApiClient>()),
|
||||
);
|
||||
getIt.registerLazySingleton<TariffRepository>(
|
||||
() => TariffRepository(getIt<ApiClient>()),
|
||||
);
|
||||
getIt.registerLazySingleton<RideTypesRepository>(
|
||||
() => RideTypesRepository(getIt<ApiClient>()),
|
||||
);
|
||||
|
||||
// ميزات Phase 2: الرحلات + WebSocket
|
||||
getIt.registerLazySingleton<TripRepository>(
|
||||
() => TripRepository(getIt<ApiClient>()),
|
||||
);
|
||||
getIt.registerLazySingleton<TripEventMerger>(
|
||||
() => TripEventMerger(),
|
||||
);
|
||||
getIt.registerLazySingleton<TripSocketService>(
|
||||
() => TripSocketService(getIt<TokenStore>(), getIt<TripEventMerger>()),
|
||||
);
|
||||
getIt.registerLazySingleton<FcmService>(
|
||||
() => FcmService(getIt<TripEventMerger>()),
|
||||
);
|
||||
|
||||
getIt.registerLazySingleton<SavedPlacesStore>(() => SavedPlacesStore());
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
{
|
||||
"@@locale": "ar",
|
||||
"appName": "Tripz",
|
||||
"appTagline": "اسحب، وصل، وصل",
|
||||
"phoneTitle": "أدخل رقم هاتفك للمتابعة",
|
||||
"phoneLabel": "رقم الهاتف",
|
||||
"phoneHint": "07XXXXXXXX",
|
||||
"sendCode": "إرسال الرمز",
|
||||
"otpTitle": "أدخل رمز التحقق",
|
||||
"otpSentTo": "أُرسل إلى {phone}",
|
||||
"@otpSentTo": {
|
||||
"placeholders": {
|
||||
"phone": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"otpLabel": "الرمز",
|
||||
"confirm": "تأكيد",
|
||||
"welcome": "مرحباً {name}",
|
||||
"@welcome": {
|
||||
"placeholders": {
|
||||
"name": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"welcomeBack": "مرحباً بعودتك",
|
||||
"whereTo": "إلى أين؟",
|
||||
"setLocationOnMap": "تحديد الموقع على الخريطة",
|
||||
"myTrips": "رحلاتي",
|
||||
"wallet": "المحفظة",
|
||||
"coupons": "الكوبونات والدعوات",
|
||||
"settings": "الإعدادات",
|
||||
"support": "الدعم",
|
||||
"logout": "تسجيل الخروج",
|
||||
"language": "اللغة",
|
||||
"arabic": "العربية",
|
||||
"english": "English",
|
||||
"theme": "المظهر",
|
||||
"lightMode": "الوضع النهاري",
|
||||
"darkMode": "الوضع الليلي",
|
||||
"systemMode": "تلقائي",
|
||||
"errorGeneric": "حدث خطأ ما",
|
||||
"retry": "أعد المحاولة",
|
||||
"emptyGeneric": "لا توجد عناصر بعد",
|
||||
"loading": "جارٍ التحميل...",
|
||||
"cancel": "إلغاء",
|
||||
"save": "حفظ",
|
||||
"close": "إغلاق",
|
||||
"next": "التالي",
|
||||
"back": "رجوع",
|
||||
"search": "بحث",
|
||||
"noResults": "لا توجد نتائج",
|
||||
"connectionError": "تحقق من اتصالك بالإنترنت",
|
||||
"authError": "تعذّر إرسال الرمز",
|
||||
"invalidOtp": "رمز غير صحيح",
|
||||
"phoneEmpty": "أدخل رقم الهاتف",
|
||||
"selectDestination": "اختر وجهتك",
|
||||
"pickupLocation": "نقطة الانطلاق",
|
||||
"dropoffLocation": "الوجهة",
|
||||
"chooseOnMap": "اختر من الخريطة",
|
||||
"favorites": "المفضلة",
|
||||
"homePlace": "البيت",
|
||||
"workPlace": "العمل",
|
||||
"searchPlaces": "ابحث عن مكان...",
|
||||
"recent": "الأخيرة",
|
||||
"addStop": "إضافة توقف",
|
||||
"removeStop": "إزالة التوقف",
|
||||
"rideClasses": "فئة الرحلة",
|
||||
"cash": "نقدي",
|
||||
"walletPayment": "المحفظة",
|
||||
"couponCode": "كود الخصم",
|
||||
"scheduleRide": "جدولة رحلة",
|
||||
"requestRide": "اطلب رحلة",
|
||||
"searchingDriver": "جارٍ البحث عن سائق...",
|
||||
"cancelSearch": "إلغاء البحث",
|
||||
"driverAccepted": "قبل سائقك الرحلة",
|
||||
"driverArriving": "السائق في طريقه إليك",
|
||||
"driverArrived": "وصل سائقك",
|
||||
"rideInProgress": "الرحلة جارية",
|
||||
"rideCompleted": "انتهت الرحلة",
|
||||
"rateDriver": "قيّم سائقك",
|
||||
"tripFare": "أجرة الرحلة",
|
||||
"waitingFee": "رسوم الانتظار",
|
||||
"discount": "الخصم",
|
||||
"paymentMethod": "طريقة الدفع",
|
||||
"total": "المجموع",
|
||||
"submitRating": "إرسال التقييم",
|
||||
"skipRating": "تخطي",
|
||||
"freeWaitMinutes": "دقائق انتظار مجانية",
|
||||
"minutes": "دقائق",
|
||||
"callDriver": "اتصل بالسائق",
|
||||
"chatWithDriver": "دردشة مع السائق",
|
||||
"shareTrip": "مشاركة الرحلة",
|
||||
"emergencySOS": "طارئ SOS",
|
||||
"plateNumber": "رقم اللوحة",
|
||||
"vehicleInfo": "معلومات المركبة",
|
||||
"driverRating": "تقييم السائق",
|
||||
"eta": "الوصول المتوقع",
|
||||
"km": "كم",
|
||||
"min": "د",
|
||||
"searchTimeout": "لم نجد سائقاً متاحاً حالياً. حاول مرة أخرى",
|
||||
"priceEstimate": "تقدير السعر",
|
||||
"perKm": "/كم",
|
||||
"capacity": "{seats} مقاعد",
|
||||
"@capacity": {
|
||||
"placeholders": {
|
||||
"seats": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"confirmPickup": "تأكيد نقطة الانطلاق",
|
||||
"pickupSet": "تم تحديد نقطة الانطلاق",
|
||||
"destinationSet": "تم تحديد الوجهة",
|
||||
"routeSummary": "{distance} · {duration}",
|
||||
"@routeSummary": {
|
||||
"placeholders": {
|
||||
"distance": {"type": "String"},
|
||||
"duration": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"noPickupSet": "حدد نقطة انطلاقك",
|
||||
"mapPinInstructions": "حرّك الخريطة لإظهار الدبوس",
|
||||
"tripNoDrivers": "لم نجد سائقين بالقرب",
|
||||
"tripCancelled": "تم إلغاء الرحلة",
|
||||
"tripCancelledByDriver": "ألغى السائق الرحلة",
|
||||
"cancelFee": "رسوم الإلغاء: {fee} {currency}",
|
||||
"@cancelFee": {
|
||||
"placeholders": {
|
||||
"fee": {"type": "String"},
|
||||
"currency": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"waitFreeMinutes": "{minutes} دقائق انتظار مجانية",
|
||||
"@waitFreeMinutes": {
|
||||
"placeholders": {
|
||||
"minutes": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"waitingCharge": "رسوم الانتظار: {amount} {currency}",
|
||||
"@waitingCharge": {
|
||||
"placeholders": {
|
||||
"amount": {"type": "String"},
|
||||
"currency": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"driverEnRoute": "السائق في طريقه إليك",
|
||||
"arrivingIn": "الوصول خلال {eta}",
|
||||
"@arrivingIn": {
|
||||
"placeholders": {
|
||||
"eta": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"tripStarted": "بدأت الرحلة",
|
||||
"tripEnd": "انتهت الرحلة",
|
||||
"fareBreakdown": "تفاصيل الأجرة",
|
||||
"baseFare": "الأجرة الأساسية",
|
||||
"distanceFare": "المسافة",
|
||||
"timeFare": "الوقت",
|
||||
"surgeMultiplier": "الذروة",
|
||||
"couponApplied": "تم تطبيق الكوبون",
|
||||
"youPaid": "دفعت",
|
||||
"payCash": "ادفع نقدي",
|
||||
"rateYourTrip": "قيّم رحلتك",
|
||||
"ratingComment": "أضف تعليقاً (اختياري)",
|
||||
"thankYou": "شكراً على ملاحظاتك",
|
||||
"tripDuration": "مدة الرحلة: {duration}",
|
||||
"@tripDuration": {
|
||||
"placeholders": {
|
||||
"duration": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"tripDistance": "مسافة الرحلة: {distance}",
|
||||
"@tripDistance": {
|
||||
"placeholders": {
|
||||
"distance": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"edit": "تعديل",
|
||||
"resendIn": "إعادة إرسال خلال {seconds}ث",
|
||||
"@resendIn": {
|
||||
"placeholders": {
|
||||
"seconds": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"resendCode": "إعادة إرسال الرمز",
|
||||
"nameTitle": "ما اسمك؟",
|
||||
"nameSubtitle": "ساعدنا في تخصيص تجربتك",
|
||||
"nameHint": "اسمك",
|
||||
"firstNameHint": "الاسم الأول",
|
||||
"lastNameHint": "اسم العائلة",
|
||||
"nameButton": "متابعة",
|
||||
"reconnect": "جارٍ إعادة الاتصال...",
|
||||
"tripExpired": "انتهت صلاحية الرحلة",
|
||||
"loginTitle": "مرحباً بك في Tripz",
|
||||
"phoneLoginSubtitle": "أدخل رقم هاتفك لتسجيل الدخول",
|
||||
"sendOtpButton": "إرسال رمز التحقق",
|
||||
"nameEntryTitle": "ما اسمك؟",
|
||||
"homeTitle": "الرئيسية",
|
||||
"destinationTitle": "الوجهة",
|
||||
"routeTitle": "المسار",
|
||||
"whereToHint": "إلى أين تريد الذهاب؟",
|
||||
"currentLocation": "الموقع الحالي",
|
||||
"savedPlaces": "الأماكن المحفوظة",
|
||||
"home": "المنزل",
|
||||
"work": "العمل",
|
||||
"pickFromMap": "اختيار من الخريطة",
|
||||
"searchHint": "ابحث عن مكان...",
|
||||
"noResultsFound": "لا توجد نتائج",
|
||||
"connecting": "جارٍ الاتصال...",
|
||||
"tripSummary": "ملخص الرحلة",
|
||||
"totalDistance": "المسافة الإجمالية",
|
||||
"estimatedTime": "الوقت المقدر",
|
||||
"totalFare": "الأجرة الإجمالية",
|
||||
"chooseRide": "اختر نوع الرحلة",
|
||||
"promoCode": "كود الترويج",
|
||||
"errorTitle": "حدث خطأ",
|
||||
"errorSubtitle": "يرجى المحاولة مرة أخرى",
|
||||
"emptyTitle": "لا توجد بيانات",
|
||||
"emptySubtitle": "لم يتم العثور على محتوى حالياً",
|
||||
"signIn": "تسجيل الدخول",
|
||||
"confirmPhoneNumber": "تأكيد رقم الهاتف",
|
||||
"driverRatingValue": "التقييم",
|
||||
"missingDataError": "بيانات مفقودة",
|
||||
"onboardingTitle1": "اطلب بلمسة واحدة",
|
||||
"onboardingSubtitle1": "أدخل وجهتك واحصل على سائق في ثوانٍ",
|
||||
"onboardingTitle2": "تتبع رحلتك مباشرة",
|
||||
"onboardingSubtitle2": "شاهد سائقك وهو يقترب في الوقت الفعلي",
|
||||
"onboardingTitle3": "ادفع كما يناسبك",
|
||||
"onboardingSubtitle3": "اختر الدفع نقدًا أو بالبطاقة أو عبر المحفظة",
|
||||
"getStarted": "ابدأ الآن",
|
||||
"skip": "تخطي"
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
{
|
||||
"@@locale": "en",
|
||||
"appName": "Tripz",
|
||||
"appTagline": "Tap, ride, arrive",
|
||||
"phoneTitle": "Enter your phone number to continue",
|
||||
"phoneLabel": "Phone number",
|
||||
"phoneHint": "07XXXXXXXX",
|
||||
"sendCode": "Send code",
|
||||
"otpTitle": "Enter verification code",
|
||||
"otpSentTo": "Sent to {phone}",
|
||||
"@otpSentTo": {
|
||||
"placeholders": {
|
||||
"phone": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"otpLabel": "Code",
|
||||
"confirm": "Confirm",
|
||||
"welcome": "Welcome {name}",
|
||||
"@welcome": {
|
||||
"placeholders": {
|
||||
"name": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"welcomeBack": "Welcome back",
|
||||
"whereTo": "Where to?",
|
||||
"setLocationOnMap": "Set location on map",
|
||||
"myTrips": "My trips",
|
||||
"wallet": "Wallet",
|
||||
"coupons": "Coupons & invites",
|
||||
"settings": "Settings",
|
||||
"support": "Support",
|
||||
"logout": "Logout",
|
||||
"language": "Language",
|
||||
"arabic": "العربية",
|
||||
"english": "English",
|
||||
"theme": "Theme",
|
||||
"lightMode": "Light",
|
||||
"darkMode": "Dark",
|
||||
"systemMode": "System",
|
||||
"errorGeneric": "Something went wrong",
|
||||
"retry": "Try again",
|
||||
"emptyGeneric": "Nothing here yet",
|
||||
"loading": "Loading...",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"close": "Close",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"search": "Search",
|
||||
"noResults": "No results found",
|
||||
"connectionError": "Check your internet connection",
|
||||
"authError": "Failed to send code",
|
||||
"invalidOtp": "Invalid code",
|
||||
"phoneEmpty": "Enter your phone number",
|
||||
"selectDestination": "Choose your destination",
|
||||
"pickupLocation": "Pickup location",
|
||||
"dropoffLocation": "Destination",
|
||||
"chooseOnMap": "Choose on map",
|
||||
"favorites": "Favorites",
|
||||
"homePlace": "Home",
|
||||
"workPlace": "Work",
|
||||
"searchPlaces": "Search for a place...",
|
||||
"recent": "Recent",
|
||||
"addStop": "Add a stop",
|
||||
"removeStop": "Remove stop",
|
||||
"rideClasses": "Ride class",
|
||||
"cash": "Cash",
|
||||
"walletPayment": "Wallet",
|
||||
"couponCode": "Coupon code",
|
||||
"scheduleRide": "Schedule a ride",
|
||||
"requestRide": "Request a ride",
|
||||
"searchingDriver": "Looking for a driver...",
|
||||
"cancelSearch": "Cancel search",
|
||||
"driverAccepted": "Your driver accepted",
|
||||
"driverArriving": "Driver is on the way",
|
||||
"driverArrived": "Your driver has arrived",
|
||||
"rideInProgress": "Ride in progress",
|
||||
"rideCompleted": "Ride completed",
|
||||
"rateDriver": "Rate your driver",
|
||||
"tripFare": "Trip fare",
|
||||
"waitingFee": "Waiting fee",
|
||||
"discount": "Discount",
|
||||
"paymentMethod": "Payment method",
|
||||
"total": "Total",
|
||||
"submitRating": "Submit rating",
|
||||
"skipRating": "Skip",
|
||||
"freeWaitMinutes": "Free wait minutes",
|
||||
"minutes": "minutes",
|
||||
"callDriver": "Call driver",
|
||||
"chatWithDriver": "Chat with driver",
|
||||
"shareTrip": "Share trip",
|
||||
"emergencySOS": "Emergency SOS",
|
||||
"plateNumber": "Plate number",
|
||||
"vehicleInfo": "Vehicle info",
|
||||
"driverRating": "Driver rating",
|
||||
"eta": "Estimated arrival",
|
||||
"km": "km",
|
||||
"min": "min",
|
||||
"searchTimeout": "No drivers available right now. Please try again",
|
||||
"priceEstimate": "Price estimate",
|
||||
"perKm": "/km",
|
||||
"capacity": "{seats} seats",
|
||||
"@capacity": {
|
||||
"placeholders": {
|
||||
"seats": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"confirmPickup": "Confirm pickup",
|
||||
"pickupSet": "Pickup set",
|
||||
"destinationSet": "Destination set",
|
||||
"routeSummary": "{distance} · {duration}",
|
||||
"@routeSummary": {
|
||||
"placeholders": {
|
||||
"distance": {"type": "String"},
|
||||
"duration": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"noPickupSet": "Set your pickup location",
|
||||
"mapPinInstructions": "Move the map to place the pin",
|
||||
"tripNoDrivers": "No drivers found nearby",
|
||||
"tripCancelled": "Trip cancelled",
|
||||
"tripCancelledByDriver": "Driver cancelled the trip",
|
||||
"cancelFee": "Cancellation fee: {fee} {currency}",
|
||||
"@cancelFee": {
|
||||
"placeholders": {
|
||||
"fee": {"type": "String"},
|
||||
"currency": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"waitFreeMinutes": "{minutes} min free wait",
|
||||
"@waitFreeMinutes": {
|
||||
"placeholders": {
|
||||
"minutes": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"waitingCharge": "Waiting charge: {amount} {currency}",
|
||||
"@waitingCharge": {
|
||||
"placeholders": {
|
||||
"amount": {"type": "String"},
|
||||
"currency": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"driverEnRoute": "Driver is heading to you",
|
||||
"arrivingIn": "Arriving in {eta}",
|
||||
"@arrivingIn": {
|
||||
"placeholders": {
|
||||
"eta": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"tripStarted": "Ride started",
|
||||
"tripEnd": "Trip ended",
|
||||
"fareBreakdown": "Fare breakdown",
|
||||
"baseFare": "Base fare",
|
||||
"distanceFare": "Distance",
|
||||
"timeFare": "Time",
|
||||
"surgeMultiplier": "Surge",
|
||||
"couponApplied": "Coupon applied",
|
||||
"youPaid": "You paid",
|
||||
"payCash": "Pay cash",
|
||||
"rateYourTrip": "Rate your trip",
|
||||
"ratingComment": "Add a comment (optional)",
|
||||
"thankYou": "Thank you for your feedback",
|
||||
"tripDuration": "Trip duration: {duration}",
|
||||
"@tripDuration": {
|
||||
"placeholders": {
|
||||
"duration": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"tripDistance": "Trip distance: {distance}",
|
||||
"@tripDistance": {
|
||||
"placeholders": {
|
||||
"distance": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"edit": "Edit",
|
||||
"resendIn": "Resend in {seconds}s",
|
||||
"@resendIn": {
|
||||
"placeholders": {
|
||||
"seconds": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"resendCode": "Resend code",
|
||||
"nameTitle": "What's your name?",
|
||||
"nameSubtitle": "Help us personalize your experience",
|
||||
"nameHint": "Your name",
|
||||
"firstNameHint": "First name",
|
||||
"lastNameHint": "Last name",
|
||||
"nameButton": "Continue",
|
||||
"reconnect": "Reconnecting...",
|
||||
"tripExpired": "Trip expired",
|
||||
"loginTitle": "Welcome to Tripz",
|
||||
"phoneLoginSubtitle": "Enter your phone number to log in",
|
||||
"sendOtpButton": "Send OTP",
|
||||
"nameEntryTitle": "What's your name?",
|
||||
"homeTitle": "Home",
|
||||
"destinationTitle": "Destination",
|
||||
"routeTitle": "Route",
|
||||
"whereToHint": "Where are you going?",
|
||||
"currentLocation": "Current location",
|
||||
"savedPlaces": "Saved places",
|
||||
"home": "Home",
|
||||
"work": "Work",
|
||||
"pickFromMap": "Pick from map",
|
||||
"searchHint": "Search for a place...",
|
||||
"noResultsFound": "No results found",
|
||||
"connecting": "Connecting...",
|
||||
"tripSummary": "Trip summary",
|
||||
"totalDistance": "Total distance",
|
||||
"estimatedTime": "Estimated time",
|
||||
"totalFare": "Total fare",
|
||||
"chooseRide": "Choose a ride",
|
||||
"promoCode": "Promo code",
|
||||
"errorTitle": "Something went wrong",
|
||||
"errorSubtitle": "Please try again",
|
||||
"emptyTitle": "No data",
|
||||
"emptySubtitle": "No content found yet",
|
||||
"signIn": "Sign in",
|
||||
"confirmPhoneNumber": "Confirm phone number",
|
||||
"driverRatingValue": "Rating",
|
||||
"missingDataError": "Missing data",
|
||||
"onboardingTitle1": "Order with one tap",
|
||||
"onboardingSubtitle1": "Enter your destination and get a driver in seconds",
|
||||
"onboardingTitle2": "Track your trip live",
|
||||
"onboardingSubtitle2": "Watch your driver approach in real-time",
|
||||
"onboardingTitle3": "Pay your way",
|
||||
"onboardingSubtitle3": "Choose to pay cash, card, or wallet",
|
||||
"getStarted": "Get started",
|
||||
"skip": "Skip"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,537 +0,0 @@
|
||||
// ignore: unused_import
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
import 'app_localizations.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
|
||||
/// The translations for Arabic (`ar`).
|
||||
class AppLocalizationsAr extends AppLocalizations {
|
||||
AppLocalizationsAr([String locale = 'ar']) : super(locale);
|
||||
|
||||
@override
|
||||
String get appName => 'Tripz';
|
||||
|
||||
@override
|
||||
String get appTagline => 'اسحب، وصل، وصل';
|
||||
|
||||
@override
|
||||
String get phoneTitle => 'أدخل رقم هاتفك للمتابعة';
|
||||
|
||||
@override
|
||||
String get phoneLabel => 'رقم الهاتف';
|
||||
|
||||
@override
|
||||
String get phoneHint => '07XXXXXXXX';
|
||||
|
||||
@override
|
||||
String get sendCode => 'إرسال الرمز';
|
||||
|
||||
@override
|
||||
String get otpTitle => 'أدخل رمز التحقق';
|
||||
|
||||
@override
|
||||
String otpSentTo(String phone) {
|
||||
return 'أُرسل إلى $phone';
|
||||
}
|
||||
|
||||
@override
|
||||
String get otpLabel => 'الرمز';
|
||||
|
||||
@override
|
||||
String get confirm => 'تأكيد';
|
||||
|
||||
@override
|
||||
String welcome(String name) {
|
||||
return 'مرحباً $name';
|
||||
}
|
||||
|
||||
@override
|
||||
String get welcomeBack => 'مرحباً بعودتك';
|
||||
|
||||
@override
|
||||
String get whereTo => 'إلى أين؟';
|
||||
|
||||
@override
|
||||
String get setLocationOnMap => 'تحديد الموقع على الخريطة';
|
||||
|
||||
@override
|
||||
String get myTrips => 'رحلاتي';
|
||||
|
||||
@override
|
||||
String get wallet => 'المحفظة';
|
||||
|
||||
@override
|
||||
String get coupons => 'الكوبونات والدعوات';
|
||||
|
||||
@override
|
||||
String get settings => 'الإعدادات';
|
||||
|
||||
@override
|
||||
String get support => 'الدعم';
|
||||
|
||||
@override
|
||||
String get logout => 'تسجيل الخروج';
|
||||
|
||||
@override
|
||||
String get language => 'اللغة';
|
||||
|
||||
@override
|
||||
String get arabic => 'العربية';
|
||||
|
||||
@override
|
||||
String get english => 'English';
|
||||
|
||||
@override
|
||||
String get theme => 'المظهر';
|
||||
|
||||
@override
|
||||
String get lightMode => 'الوضع النهاري';
|
||||
|
||||
@override
|
||||
String get darkMode => 'الوضع الليلي';
|
||||
|
||||
@override
|
||||
String get systemMode => 'تلقائي';
|
||||
|
||||
@override
|
||||
String get errorGeneric => 'حدث خطأ ما';
|
||||
|
||||
@override
|
||||
String get retry => 'أعد المحاولة';
|
||||
|
||||
@override
|
||||
String get emptyGeneric => 'لا توجد عناصر بعد';
|
||||
|
||||
@override
|
||||
String get loading => 'جارٍ التحميل...';
|
||||
|
||||
@override
|
||||
String get cancel => 'إلغاء';
|
||||
|
||||
@override
|
||||
String get save => 'حفظ';
|
||||
|
||||
@override
|
||||
String get close => 'إغلاق';
|
||||
|
||||
@override
|
||||
String get next => 'التالي';
|
||||
|
||||
@override
|
||||
String get back => 'رجوع';
|
||||
|
||||
@override
|
||||
String get search => 'بحث';
|
||||
|
||||
@override
|
||||
String get noResults => 'لا توجد نتائج';
|
||||
|
||||
@override
|
||||
String get connectionError => 'تحقق من اتصالك بالإنترنت';
|
||||
|
||||
@override
|
||||
String get authError => 'تعذّر إرسال الرمز';
|
||||
|
||||
@override
|
||||
String get invalidOtp => 'رمز غير صحيح';
|
||||
|
||||
@override
|
||||
String get phoneEmpty => 'أدخل رقم الهاتف';
|
||||
|
||||
@override
|
||||
String get selectDestination => 'اختر وجهتك';
|
||||
|
||||
@override
|
||||
String get pickupLocation => 'نقطة الانطلاق';
|
||||
|
||||
@override
|
||||
String get dropoffLocation => 'الوجهة';
|
||||
|
||||
@override
|
||||
String get chooseOnMap => 'اختر من الخريطة';
|
||||
|
||||
@override
|
||||
String get favorites => 'المفضلة';
|
||||
|
||||
@override
|
||||
String get homePlace => 'البيت';
|
||||
|
||||
@override
|
||||
String get workPlace => 'العمل';
|
||||
|
||||
@override
|
||||
String get searchPlaces => 'ابحث عن مكان...';
|
||||
|
||||
@override
|
||||
String get recent => 'الأخيرة';
|
||||
|
||||
@override
|
||||
String get addStop => 'إضافة توقف';
|
||||
|
||||
@override
|
||||
String get removeStop => 'إزالة التوقف';
|
||||
|
||||
@override
|
||||
String get rideClasses => 'فئة الرحلة';
|
||||
|
||||
@override
|
||||
String get cash => 'نقدي';
|
||||
|
||||
@override
|
||||
String get walletPayment => 'المحفظة';
|
||||
|
||||
@override
|
||||
String get couponCode => 'كود الخصم';
|
||||
|
||||
@override
|
||||
String get scheduleRide => 'جدولة رحلة';
|
||||
|
||||
@override
|
||||
String get requestRide => 'اطلب رحلة';
|
||||
|
||||
@override
|
||||
String get searchingDriver => 'جارٍ البحث عن سائق...';
|
||||
|
||||
@override
|
||||
String get cancelSearch => 'إلغاء البحث';
|
||||
|
||||
@override
|
||||
String get driverAccepted => 'قبل سائقك الرحلة';
|
||||
|
||||
@override
|
||||
String get driverArriving => 'السائق في طريقه إليك';
|
||||
|
||||
@override
|
||||
String get driverArrived => 'وصل سائقك';
|
||||
|
||||
@override
|
||||
String get rideInProgress => 'الرحلة جارية';
|
||||
|
||||
@override
|
||||
String get rideCompleted => 'انتهت الرحلة';
|
||||
|
||||
@override
|
||||
String get rateDriver => 'قيّم سائقك';
|
||||
|
||||
@override
|
||||
String get tripFare => 'أجرة الرحلة';
|
||||
|
||||
@override
|
||||
String get waitingFee => 'رسوم الانتظار';
|
||||
|
||||
@override
|
||||
String get discount => 'الخصم';
|
||||
|
||||
@override
|
||||
String get paymentMethod => 'طريقة الدفع';
|
||||
|
||||
@override
|
||||
String get total => 'المجموع';
|
||||
|
||||
@override
|
||||
String get submitRating => 'إرسال التقييم';
|
||||
|
||||
@override
|
||||
String get skipRating => 'تخطي';
|
||||
|
||||
@override
|
||||
String get freeWaitMinutes => 'دقائق انتظار مجانية';
|
||||
|
||||
@override
|
||||
String get minutes => 'دقائق';
|
||||
|
||||
@override
|
||||
String get callDriver => 'اتصل بالسائق';
|
||||
|
||||
@override
|
||||
String get chatWithDriver => 'دردشة مع السائق';
|
||||
|
||||
@override
|
||||
String get shareTrip => 'مشاركة الرحلة';
|
||||
|
||||
@override
|
||||
String get emergencySOS => 'طارئ SOS';
|
||||
|
||||
@override
|
||||
String get plateNumber => 'رقم اللوحة';
|
||||
|
||||
@override
|
||||
String get vehicleInfo => 'معلومات المركبة';
|
||||
|
||||
@override
|
||||
String get driverRating => 'تقييم السائق';
|
||||
|
||||
@override
|
||||
String get eta => 'الوصول المتوقع';
|
||||
|
||||
@override
|
||||
String get km => 'كم';
|
||||
|
||||
@override
|
||||
String get min => 'د';
|
||||
|
||||
@override
|
||||
String get searchTimeout => 'لم نجد سائقاً متاحاً حالياً. حاول مرة أخرى';
|
||||
|
||||
@override
|
||||
String get priceEstimate => 'تقدير السعر';
|
||||
|
||||
@override
|
||||
String get perKm => '/كم';
|
||||
|
||||
@override
|
||||
String capacity(int seats) {
|
||||
return '$seats مقاعد';
|
||||
}
|
||||
|
||||
@override
|
||||
String get confirmPickup => 'تأكيد نقطة الانطلاق';
|
||||
|
||||
@override
|
||||
String get pickupSet => 'تم تحديد نقطة الانطلاق';
|
||||
|
||||
@override
|
||||
String get destinationSet => 'تم تحديد الوجهة';
|
||||
|
||||
@override
|
||||
String routeSummary(String distance, String duration) {
|
||||
return '$distance · $duration';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noPickupSet => 'حدد نقطة انطلاقك';
|
||||
|
||||
@override
|
||||
String get mapPinInstructions => 'حرّك الخريطة لإظهار الدبوس';
|
||||
|
||||
@override
|
||||
String get tripNoDrivers => 'لم نجد سائقين بالقرب';
|
||||
|
||||
@override
|
||||
String get tripCancelled => 'تم إلغاء الرحلة';
|
||||
|
||||
@override
|
||||
String get tripCancelledByDriver => 'ألغى السائق الرحلة';
|
||||
|
||||
@override
|
||||
String cancelFee(String fee, String currency) {
|
||||
return 'رسوم الإلغاء: $fee $currency';
|
||||
}
|
||||
|
||||
@override
|
||||
String waitFreeMinutes(int minutes) {
|
||||
return '$minutes دقائق انتظار مجانية';
|
||||
}
|
||||
|
||||
@override
|
||||
String waitingCharge(String amount, String currency) {
|
||||
return 'رسوم الانتظار: $amount $currency';
|
||||
}
|
||||
|
||||
@override
|
||||
String get driverEnRoute => 'السائق في طريقه إليك';
|
||||
|
||||
@override
|
||||
String arrivingIn(String eta) {
|
||||
return 'الوصول خلال $eta';
|
||||
}
|
||||
|
||||
@override
|
||||
String get tripStarted => 'بدأت الرحلة';
|
||||
|
||||
@override
|
||||
String get tripEnd => 'انتهت الرحلة';
|
||||
|
||||
@override
|
||||
String get fareBreakdown => 'تفاصيل الأجرة';
|
||||
|
||||
@override
|
||||
String get baseFare => 'الأجرة الأساسية';
|
||||
|
||||
@override
|
||||
String get distanceFare => 'المسافة';
|
||||
|
||||
@override
|
||||
String get timeFare => 'الوقت';
|
||||
|
||||
@override
|
||||
String get surgeMultiplier => 'الذروة';
|
||||
|
||||
@override
|
||||
String get couponApplied => 'تم تطبيق الكوبون';
|
||||
|
||||
@override
|
||||
String get youPaid => 'دفعت';
|
||||
|
||||
@override
|
||||
String get payCash => 'ادفع نقدي';
|
||||
|
||||
@override
|
||||
String get rateYourTrip => 'قيّم رحلتك';
|
||||
|
||||
@override
|
||||
String get ratingComment => 'أضف تعليقاً (اختياري)';
|
||||
|
||||
@override
|
||||
String get thankYou => 'شكراً على ملاحظاتك';
|
||||
|
||||
@override
|
||||
String tripDuration(String duration) {
|
||||
return 'مدة الرحلة: $duration';
|
||||
}
|
||||
|
||||
@override
|
||||
String tripDistance(String distance) {
|
||||
return 'مسافة الرحلة: $distance';
|
||||
}
|
||||
|
||||
@override
|
||||
String get edit => 'تعديل';
|
||||
|
||||
@override
|
||||
String resendIn(int seconds) {
|
||||
return 'إعادة إرسال خلال $secondsث';
|
||||
}
|
||||
|
||||
@override
|
||||
String get resendCode => 'إعادة إرسال الرمز';
|
||||
|
||||
@override
|
||||
String get nameTitle => 'ما اسمك؟';
|
||||
|
||||
@override
|
||||
String get nameSubtitle => 'ساعدنا في تخصيص تجربتك';
|
||||
|
||||
@override
|
||||
String get nameHint => 'اسمك';
|
||||
|
||||
@override
|
||||
String get firstNameHint => 'الاسم الأول';
|
||||
|
||||
@override
|
||||
String get lastNameHint => 'اسم العائلة';
|
||||
|
||||
@override
|
||||
String get nameButton => 'متابعة';
|
||||
|
||||
@override
|
||||
String get reconnect => 'جارٍ إعادة الاتصال...';
|
||||
|
||||
@override
|
||||
String get tripExpired => 'انتهت صلاحية الرحلة';
|
||||
|
||||
@override
|
||||
String get loginTitle => 'مرحباً بك في Tripz';
|
||||
|
||||
@override
|
||||
String get phoneLoginSubtitle => 'أدخل رقم هاتفك لتسجيل الدخول';
|
||||
|
||||
@override
|
||||
String get sendOtpButton => 'إرسال رمز التحقق';
|
||||
|
||||
@override
|
||||
String get nameEntryTitle => 'ما اسمك؟';
|
||||
|
||||
@override
|
||||
String get homeTitle => 'الرئيسية';
|
||||
|
||||
@override
|
||||
String get destinationTitle => 'الوجهة';
|
||||
|
||||
@override
|
||||
String get routeTitle => 'المسار';
|
||||
|
||||
@override
|
||||
String get whereToHint => 'إلى أين تريد الذهاب؟';
|
||||
|
||||
@override
|
||||
String get currentLocation => 'الموقع الحالي';
|
||||
|
||||
@override
|
||||
String get savedPlaces => 'الأماكن المحفوظة';
|
||||
|
||||
@override
|
||||
String get home => 'المنزل';
|
||||
|
||||
@override
|
||||
String get work => 'العمل';
|
||||
|
||||
@override
|
||||
String get pickFromMap => 'اختيار من الخريطة';
|
||||
|
||||
@override
|
||||
String get searchHint => 'ابحث عن مكان...';
|
||||
|
||||
@override
|
||||
String get noResultsFound => 'لا توجد نتائج';
|
||||
|
||||
@override
|
||||
String get connecting => 'جارٍ الاتصال...';
|
||||
|
||||
@override
|
||||
String get tripSummary => 'ملخص الرحلة';
|
||||
|
||||
@override
|
||||
String get totalDistance => 'المسافة الإجمالية';
|
||||
|
||||
@override
|
||||
String get estimatedTime => 'الوقت المقدر';
|
||||
|
||||
@override
|
||||
String get totalFare => 'الأجرة الإجمالية';
|
||||
|
||||
@override
|
||||
String get chooseRide => 'اختر نوع الرحلة';
|
||||
|
||||
@override
|
||||
String get promoCode => 'كود الترويج';
|
||||
|
||||
@override
|
||||
String get errorTitle => 'حدث خطأ';
|
||||
|
||||
@override
|
||||
String get errorSubtitle => 'يرجى المحاولة مرة أخرى';
|
||||
|
||||
@override
|
||||
String get emptyTitle => 'لا توجد بيانات';
|
||||
|
||||
@override
|
||||
String get emptySubtitle => 'لم يتم العثور على محتوى حالياً';
|
||||
|
||||
@override
|
||||
String get signIn => 'تسجيل الدخول';
|
||||
|
||||
@override
|
||||
String get confirmPhoneNumber => 'تأكيد رقم الهاتف';
|
||||
|
||||
@override
|
||||
String get driverRatingValue => 'التقييم';
|
||||
|
||||
@override
|
||||
String get missingDataError => 'بيانات مفقودة';
|
||||
|
||||
@override
|
||||
String get onboardingTitle1 => 'اطلب بلمسة واحدة';
|
||||
|
||||
@override
|
||||
String get onboardingSubtitle1 => 'أدخل وجهتك واحصل على سائق في ثوانٍ';
|
||||
|
||||
@override
|
||||
String get onboardingTitle2 => 'تتبع رحلتك مباشرة';
|
||||
|
||||
@override
|
||||
String get onboardingSubtitle2 => 'شاهد سائقك وهو يقترب في الوقت الفعلي';
|
||||
|
||||
@override
|
||||
String get onboardingTitle3 => 'ادفع كما يناسبك';
|
||||
|
||||
@override
|
||||
String get onboardingSubtitle3 =>
|
||||
'اختر الدفع نقدًا أو بالبطاقة أو عبر المحفظة';
|
||||
|
||||
@override
|
||||
String get getStarted => 'ابدأ الآن';
|
||||
|
||||
@override
|
||||
String get skip => 'تخطي';
|
||||
}
|
||||
@@ -1,538 +0,0 @@
|
||||
// ignore: unused_import
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
import 'app_localizations.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
|
||||
/// The translations for English (`en`).
|
||||
class AppLocalizationsEn extends AppLocalizations {
|
||||
AppLocalizationsEn([String locale = 'en']) : super(locale);
|
||||
|
||||
@override
|
||||
String get appName => 'Tripz';
|
||||
|
||||
@override
|
||||
String get appTagline => 'Tap, ride, arrive';
|
||||
|
||||
@override
|
||||
String get phoneTitle => 'Enter your phone number to continue';
|
||||
|
||||
@override
|
||||
String get phoneLabel => 'Phone number';
|
||||
|
||||
@override
|
||||
String get phoneHint => '07XXXXXXXX';
|
||||
|
||||
@override
|
||||
String get sendCode => 'Send code';
|
||||
|
||||
@override
|
||||
String get otpTitle => 'Enter verification code';
|
||||
|
||||
@override
|
||||
String otpSentTo(String phone) {
|
||||
return 'Sent to $phone';
|
||||
}
|
||||
|
||||
@override
|
||||
String get otpLabel => 'Code';
|
||||
|
||||
@override
|
||||
String get confirm => 'Confirm';
|
||||
|
||||
@override
|
||||
String welcome(String name) {
|
||||
return 'Welcome $name';
|
||||
}
|
||||
|
||||
@override
|
||||
String get welcomeBack => 'Welcome back';
|
||||
|
||||
@override
|
||||
String get whereTo => 'Where to?';
|
||||
|
||||
@override
|
||||
String get setLocationOnMap => 'Set location on map';
|
||||
|
||||
@override
|
||||
String get myTrips => 'My trips';
|
||||
|
||||
@override
|
||||
String get wallet => 'Wallet';
|
||||
|
||||
@override
|
||||
String get coupons => 'Coupons & invites';
|
||||
|
||||
@override
|
||||
String get settings => 'Settings';
|
||||
|
||||
@override
|
||||
String get support => 'Support';
|
||||
|
||||
@override
|
||||
String get logout => 'Logout';
|
||||
|
||||
@override
|
||||
String get language => 'Language';
|
||||
|
||||
@override
|
||||
String get arabic => 'العربية';
|
||||
|
||||
@override
|
||||
String get english => 'English';
|
||||
|
||||
@override
|
||||
String get theme => 'Theme';
|
||||
|
||||
@override
|
||||
String get lightMode => 'Light';
|
||||
|
||||
@override
|
||||
String get darkMode => 'Dark';
|
||||
|
||||
@override
|
||||
String get systemMode => 'System';
|
||||
|
||||
@override
|
||||
String get errorGeneric => 'Something went wrong';
|
||||
|
||||
@override
|
||||
String get retry => 'Try again';
|
||||
|
||||
@override
|
||||
String get emptyGeneric => 'Nothing here yet';
|
||||
|
||||
@override
|
||||
String get loading => 'Loading...';
|
||||
|
||||
@override
|
||||
String get cancel => 'Cancel';
|
||||
|
||||
@override
|
||||
String get save => 'Save';
|
||||
|
||||
@override
|
||||
String get close => 'Close';
|
||||
|
||||
@override
|
||||
String get next => 'Next';
|
||||
|
||||
@override
|
||||
String get back => 'Back';
|
||||
|
||||
@override
|
||||
String get search => 'Search';
|
||||
|
||||
@override
|
||||
String get noResults => 'No results found';
|
||||
|
||||
@override
|
||||
String get connectionError => 'Check your internet connection';
|
||||
|
||||
@override
|
||||
String get authError => 'Failed to send code';
|
||||
|
||||
@override
|
||||
String get invalidOtp => 'Invalid code';
|
||||
|
||||
@override
|
||||
String get phoneEmpty => 'Enter your phone number';
|
||||
|
||||
@override
|
||||
String get selectDestination => 'Choose your destination';
|
||||
|
||||
@override
|
||||
String get pickupLocation => 'Pickup location';
|
||||
|
||||
@override
|
||||
String get dropoffLocation => 'Destination';
|
||||
|
||||
@override
|
||||
String get chooseOnMap => 'Choose on map';
|
||||
|
||||
@override
|
||||
String get favorites => 'Favorites';
|
||||
|
||||
@override
|
||||
String get homePlace => 'Home';
|
||||
|
||||
@override
|
||||
String get workPlace => 'Work';
|
||||
|
||||
@override
|
||||
String get searchPlaces => 'Search for a place...';
|
||||
|
||||
@override
|
||||
String get recent => 'Recent';
|
||||
|
||||
@override
|
||||
String get addStop => 'Add a stop';
|
||||
|
||||
@override
|
||||
String get removeStop => 'Remove stop';
|
||||
|
||||
@override
|
||||
String get rideClasses => 'Ride class';
|
||||
|
||||
@override
|
||||
String get cash => 'Cash';
|
||||
|
||||
@override
|
||||
String get walletPayment => 'Wallet';
|
||||
|
||||
@override
|
||||
String get couponCode => 'Coupon code';
|
||||
|
||||
@override
|
||||
String get scheduleRide => 'Schedule a ride';
|
||||
|
||||
@override
|
||||
String get requestRide => 'Request a ride';
|
||||
|
||||
@override
|
||||
String get searchingDriver => 'Looking for a driver...';
|
||||
|
||||
@override
|
||||
String get cancelSearch => 'Cancel search';
|
||||
|
||||
@override
|
||||
String get driverAccepted => 'Your driver accepted';
|
||||
|
||||
@override
|
||||
String get driverArriving => 'Driver is on the way';
|
||||
|
||||
@override
|
||||
String get driverArrived => 'Your driver has arrived';
|
||||
|
||||
@override
|
||||
String get rideInProgress => 'Ride in progress';
|
||||
|
||||
@override
|
||||
String get rideCompleted => 'Ride completed';
|
||||
|
||||
@override
|
||||
String get rateDriver => 'Rate your driver';
|
||||
|
||||
@override
|
||||
String get tripFare => 'Trip fare';
|
||||
|
||||
@override
|
||||
String get waitingFee => 'Waiting fee';
|
||||
|
||||
@override
|
||||
String get discount => 'Discount';
|
||||
|
||||
@override
|
||||
String get paymentMethod => 'Payment method';
|
||||
|
||||
@override
|
||||
String get total => 'Total';
|
||||
|
||||
@override
|
||||
String get submitRating => 'Submit rating';
|
||||
|
||||
@override
|
||||
String get skipRating => 'Skip';
|
||||
|
||||
@override
|
||||
String get freeWaitMinutes => 'Free wait minutes';
|
||||
|
||||
@override
|
||||
String get minutes => 'minutes';
|
||||
|
||||
@override
|
||||
String get callDriver => 'Call driver';
|
||||
|
||||
@override
|
||||
String get chatWithDriver => 'Chat with driver';
|
||||
|
||||
@override
|
||||
String get shareTrip => 'Share trip';
|
||||
|
||||
@override
|
||||
String get emergencySOS => 'Emergency SOS';
|
||||
|
||||
@override
|
||||
String get plateNumber => 'Plate number';
|
||||
|
||||
@override
|
||||
String get vehicleInfo => 'Vehicle info';
|
||||
|
||||
@override
|
||||
String get driverRating => 'Driver rating';
|
||||
|
||||
@override
|
||||
String get eta => 'Estimated arrival';
|
||||
|
||||
@override
|
||||
String get km => 'km';
|
||||
|
||||
@override
|
||||
String get min => 'min';
|
||||
|
||||
@override
|
||||
String get searchTimeout =>
|
||||
'No drivers available right now. Please try again';
|
||||
|
||||
@override
|
||||
String get priceEstimate => 'Price estimate';
|
||||
|
||||
@override
|
||||
String get perKm => '/km';
|
||||
|
||||
@override
|
||||
String capacity(int seats) {
|
||||
return '$seats seats';
|
||||
}
|
||||
|
||||
@override
|
||||
String get confirmPickup => 'Confirm pickup';
|
||||
|
||||
@override
|
||||
String get pickupSet => 'Pickup set';
|
||||
|
||||
@override
|
||||
String get destinationSet => 'Destination set';
|
||||
|
||||
@override
|
||||
String routeSummary(String distance, String duration) {
|
||||
return '$distance · $duration';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noPickupSet => 'Set your pickup location';
|
||||
|
||||
@override
|
||||
String get mapPinInstructions => 'Move the map to place the pin';
|
||||
|
||||
@override
|
||||
String get tripNoDrivers => 'No drivers found nearby';
|
||||
|
||||
@override
|
||||
String get tripCancelled => 'Trip cancelled';
|
||||
|
||||
@override
|
||||
String get tripCancelledByDriver => 'Driver cancelled the trip';
|
||||
|
||||
@override
|
||||
String cancelFee(String fee, String currency) {
|
||||
return 'Cancellation fee: $fee $currency';
|
||||
}
|
||||
|
||||
@override
|
||||
String waitFreeMinutes(int minutes) {
|
||||
return '$minutes min free wait';
|
||||
}
|
||||
|
||||
@override
|
||||
String waitingCharge(String amount, String currency) {
|
||||
return 'Waiting charge: $amount $currency';
|
||||
}
|
||||
|
||||
@override
|
||||
String get driverEnRoute => 'Driver is heading to you';
|
||||
|
||||
@override
|
||||
String arrivingIn(String eta) {
|
||||
return 'Arriving in $eta';
|
||||
}
|
||||
|
||||
@override
|
||||
String get tripStarted => 'Ride started';
|
||||
|
||||
@override
|
||||
String get tripEnd => 'Trip ended';
|
||||
|
||||
@override
|
||||
String get fareBreakdown => 'Fare breakdown';
|
||||
|
||||
@override
|
||||
String get baseFare => 'Base fare';
|
||||
|
||||
@override
|
||||
String get distanceFare => 'Distance';
|
||||
|
||||
@override
|
||||
String get timeFare => 'Time';
|
||||
|
||||
@override
|
||||
String get surgeMultiplier => 'Surge';
|
||||
|
||||
@override
|
||||
String get couponApplied => 'Coupon applied';
|
||||
|
||||
@override
|
||||
String get youPaid => 'You paid';
|
||||
|
||||
@override
|
||||
String get payCash => 'Pay cash';
|
||||
|
||||
@override
|
||||
String get rateYourTrip => 'Rate your trip';
|
||||
|
||||
@override
|
||||
String get ratingComment => 'Add a comment (optional)';
|
||||
|
||||
@override
|
||||
String get thankYou => 'Thank you for your feedback';
|
||||
|
||||
@override
|
||||
String tripDuration(String duration) {
|
||||
return 'Trip duration: $duration';
|
||||
}
|
||||
|
||||
@override
|
||||
String tripDistance(String distance) {
|
||||
return 'Trip distance: $distance';
|
||||
}
|
||||
|
||||
@override
|
||||
String get edit => 'Edit';
|
||||
|
||||
@override
|
||||
String resendIn(int seconds) {
|
||||
return 'Resend in ${seconds}s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get resendCode => 'Resend code';
|
||||
|
||||
@override
|
||||
String get nameTitle => 'What\'s your name?';
|
||||
|
||||
@override
|
||||
String get nameSubtitle => 'Help us personalize your experience';
|
||||
|
||||
@override
|
||||
String get nameHint => 'Your name';
|
||||
|
||||
@override
|
||||
String get firstNameHint => 'First name';
|
||||
|
||||
@override
|
||||
String get lastNameHint => 'Last name';
|
||||
|
||||
@override
|
||||
String get nameButton => 'Continue';
|
||||
|
||||
@override
|
||||
String get reconnect => 'Reconnecting...';
|
||||
|
||||
@override
|
||||
String get tripExpired => 'Trip expired';
|
||||
|
||||
@override
|
||||
String get loginTitle => 'Welcome to Tripz';
|
||||
|
||||
@override
|
||||
String get phoneLoginSubtitle => 'Enter your phone number to log in';
|
||||
|
||||
@override
|
||||
String get sendOtpButton => 'Send OTP';
|
||||
|
||||
@override
|
||||
String get nameEntryTitle => 'What\'s your name?';
|
||||
|
||||
@override
|
||||
String get homeTitle => 'Home';
|
||||
|
||||
@override
|
||||
String get destinationTitle => 'Destination';
|
||||
|
||||
@override
|
||||
String get routeTitle => 'Route';
|
||||
|
||||
@override
|
||||
String get whereToHint => 'Where are you going?';
|
||||
|
||||
@override
|
||||
String get currentLocation => 'Current location';
|
||||
|
||||
@override
|
||||
String get savedPlaces => 'Saved places';
|
||||
|
||||
@override
|
||||
String get home => 'Home';
|
||||
|
||||
@override
|
||||
String get work => 'Work';
|
||||
|
||||
@override
|
||||
String get pickFromMap => 'Pick from map';
|
||||
|
||||
@override
|
||||
String get searchHint => 'Search for a place...';
|
||||
|
||||
@override
|
||||
String get noResultsFound => 'No results found';
|
||||
|
||||
@override
|
||||
String get connecting => 'Connecting...';
|
||||
|
||||
@override
|
||||
String get tripSummary => 'Trip summary';
|
||||
|
||||
@override
|
||||
String get totalDistance => 'Total distance';
|
||||
|
||||
@override
|
||||
String get estimatedTime => 'Estimated time';
|
||||
|
||||
@override
|
||||
String get totalFare => 'Total fare';
|
||||
|
||||
@override
|
||||
String get chooseRide => 'Choose a ride';
|
||||
|
||||
@override
|
||||
String get promoCode => 'Promo code';
|
||||
|
||||
@override
|
||||
String get errorTitle => 'Something went wrong';
|
||||
|
||||
@override
|
||||
String get errorSubtitle => 'Please try again';
|
||||
|
||||
@override
|
||||
String get emptyTitle => 'No data';
|
||||
|
||||
@override
|
||||
String get emptySubtitle => 'No content found yet';
|
||||
|
||||
@override
|
||||
String get signIn => 'Sign in';
|
||||
|
||||
@override
|
||||
String get confirmPhoneNumber => 'Confirm phone number';
|
||||
|
||||
@override
|
||||
String get driverRatingValue => 'Rating';
|
||||
|
||||
@override
|
||||
String get missingDataError => 'Missing data';
|
||||
|
||||
@override
|
||||
String get onboardingTitle1 => 'Order with one tap';
|
||||
|
||||
@override
|
||||
String get onboardingSubtitle1 =>
|
||||
'Enter your destination and get a driver in seconds';
|
||||
|
||||
@override
|
||||
String get onboardingTitle2 => 'Track your trip live';
|
||||
|
||||
@override
|
||||
String get onboardingSubtitle2 => 'Watch your driver approach in real-time';
|
||||
|
||||
@override
|
||||
String get onboardingTitle3 => 'Pay your way';
|
||||
|
||||
@override
|
||||
String get onboardingSubtitle3 => 'Choose to pay cash, card, or wallet';
|
||||
|
||||
@override
|
||||
String get getStarted => 'Get started';
|
||||
|
||||
@override
|
||||
String get skip => 'Skip';
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intaleq_maps/intaleq_maps.dart';
|
||||
import '../features/auth/cubit/auth_cubit.dart';
|
||||
import '../features/auth/cubit/auth_state.dart';
|
||||
import '../features/splash/view/splash_screen.dart';
|
||||
import '../features/onboarding/view/onboarding_screen.dart';
|
||||
import '../features/auth/view/phone_screen.dart';
|
||||
import '../features/auth/view/otp_screen.dart';
|
||||
import '../features/auth/view/name_screen.dart';
|
||||
import '../features/home/view/home_screen.dart';
|
||||
import '../features/home/view/destination_screen.dart';
|
||||
import '../features/home/view/route_preview_screen.dart';
|
||||
import '../features/trip/view/trip_tracking_screen.dart';
|
||||
import '../features/trip/view/trip_invoice_screen.dart';
|
||||
import '../features/trip/view/rating_screen.dart';
|
||||
import '../features/home/cubit/home_cubit.dart';
|
||||
import '../features/home/cubit/destination_cubit.dart';
|
||||
import '../features/home/cubit/quote_cubit.dart';
|
||||
import '../features/home/cubit/saved_places_cubit.dart';
|
||||
import '../features/home/data/models/place_model.dart';
|
||||
import '../features/home/data/maps_repository.dart';
|
||||
import '../features/home/data/tariff_repository.dart';
|
||||
import '../core/storage/saved_places_store.dart';
|
||||
import '../core/l10n/app_localizations.dart';
|
||||
import 'di.dart';
|
||||
|
||||
GoRouter buildRouter(AuthCubit auth) {
|
||||
return GoRouter(
|
||||
initialLocation: '/splash',
|
||||
refreshListenable: _StreamRefresh(auth.stream),
|
||||
redirect: (context, state) {
|
||||
final status = auth.state.status;
|
||||
final loc = state.matchedLocation;
|
||||
final onSplash = loc == '/splash';
|
||||
final onOnboarding = loc == '/onboarding';
|
||||
final onAuthPages = loc == '/phone' || loc == '/otp' || loc == '/name';
|
||||
final onNamePage = loc == '/name';
|
||||
|
||||
if (status == AuthStatus.authenticated) {
|
||||
final hasName = auth.state.user?.name != null &&
|
||||
auth.state.user!.name!.trim().isNotEmpty;
|
||||
if (!hasName && !onNamePage) return '/name';
|
||||
if (hasName && onNamePage) return '/home';
|
||||
if (onAuthPages || onSplash || onOnboarding) return hasName ? '/home' : '/name';
|
||||
return null;
|
||||
}
|
||||
|
||||
if (onSplash || onOnboarding) return null;
|
||||
|
||||
if (status == AuthStatus.codeSent && loc == '/phone') return '/otp';
|
||||
if (status != AuthStatus.authenticated && !onAuthPages) return '/phone';
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
GoRoute(path: '/splash', builder: (_, __) => const SplashScreen()),
|
||||
GoRoute(path: '/onboarding', builder: (_, __) => const OnboardingScreen()),
|
||||
GoRoute(path: '/phone', builder: (_, __) => const PhoneScreen()),
|
||||
GoRoute(path: '/otp', builder: (_, __) => const OtpScreen()),
|
||||
GoRoute(path: '/name', builder: (_, __) => const NameScreen()),
|
||||
GoRoute(
|
||||
path: '/home',
|
||||
builder: (_, __) => MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(create: (_) => HomeCubit(getIt<MapsRepository>())),
|
||||
BlocProvider(create: (_) => SavedPlacesCubit(getIt<SavedPlacesStore>())),
|
||||
],
|
||||
child: const HomeScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/destination',
|
||||
builder: (_, state) {
|
||||
final extra = state.extra as Map<String, dynamic>?;
|
||||
final currentLocation = extra?['currentLocation'] as LatLng?;
|
||||
return BlocProvider(
|
||||
create: (_) => DestinationCubit(getIt<MapsRepository>()),
|
||||
child: DestinationScreen(currentLocation: currentLocation),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/route-preview',
|
||||
builder: (_, state) {
|
||||
final extra = state.extra as Map<String, dynamic>?;
|
||||
final pickup = extra?['pickup'] as PlaceModel?;
|
||||
final destination = extra?['destination'] as PlaceModel?;
|
||||
if (pickup == null || destination == null) {
|
||||
return Builder(
|
||||
builder: (ctx) => Scaffold(
|
||||
body: Center(
|
||||
child: Text(AppLocalizations.of(ctx)?.missingDataError ?? ''),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return BlocProvider(
|
||||
create: (_) => QuoteCubit(getIt<TariffRepository>()),
|
||||
child: RoutePreviewScreen(pickup: pickup, destination: destination),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(path: '/trip-tracking', builder: (_, __) => const TripTrackingScreen()),
|
||||
GoRoute(path: '/trip-invoice', builder: (_, __) => const TripInvoiceScreen()),
|
||||
GoRoute(path: '/trip-rating', builder: (_, __) => const RatingScreen()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _StreamRefresh extends ChangeNotifier {
|
||||
late final StreamSubscription<dynamic> _sub;
|
||||
_StreamRefresh(Stream<dynamic> stream) {
|
||||
notifyListeners();
|
||||
_sub = stream.asBroadcastStream().listen((_) => notifyListeners());
|
||||
}
|
||||
@override
|
||||
void dispose() {
|
||||
_sub.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
enum FavoriteType { home, work }
|
||||
|
||||
class SavedPlace {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? address;
|
||||
final double lat;
|
||||
final double lng;
|
||||
int useCount;
|
||||
DateTime lastUsedAt;
|
||||
FavoriteType? favoriteType;
|
||||
|
||||
SavedPlace({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.address,
|
||||
required this.lat,
|
||||
required this.lng,
|
||||
this.useCount = 0,
|
||||
DateTime? lastUsedAt,
|
||||
this.favoriteType,
|
||||
}) : lastUsedAt = lastUsedAt ?? DateTime.now();
|
||||
|
||||
/// Rounding to 4 decimal places (~11m precision) for deduplication.
|
||||
static double _roundCoord(double value) =>
|
||||
(value * 10000).roundToDouble() / 10000;
|
||||
|
||||
String get dedupeKey =>
|
||||
'${_roundCoord(lat)},${_roundCoord(lng)}';
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'address': address,
|
||||
'lat': lat,
|
||||
'lng': lng,
|
||||
'useCount': useCount,
|
||||
'lastUsedAt': lastUsedAt.toIso8601String(),
|
||||
'favoriteType': favoriteType?.name,
|
||||
};
|
||||
|
||||
factory SavedPlace.fromJson(Map<String, dynamic> json) => SavedPlace(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
address: json['address'] as String?,
|
||||
lat: (json['lat'] as num).toDouble(),
|
||||
lng: (json['lng'] as num).toDouble(),
|
||||
useCount: json['useCount'] as int? ?? 0,
|
||||
lastUsedAt: DateTime.parse(json['lastUsedAt'] as String),
|
||||
favoriteType: json['favoriteType'] != null
|
||||
? FavoriteType.values.firstWhere(
|
||||
(e) => e.name == json['favoriteType'],
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
class SavedPlacesStore {
|
||||
static const _key = 'saved_places';
|
||||
static const _maxItems = 50;
|
||||
|
||||
Future<List<SavedPlace>> getAll() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_key);
|
||||
if (raw == null) return [];
|
||||
final list = (jsonDecode(raw) as List)
|
||||
.map((e) => SavedPlace.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
return _sort(list);
|
||||
}
|
||||
|
||||
Future<void> add(SavedPlace place) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final list = await getAll();
|
||||
|
||||
final existingIndex = list.indexWhere(
|
||||
(p) => p.dedupeKey == place.dedupeKey && p.favoriteType == null,
|
||||
);
|
||||
|
||||
if (existingIndex != -1) {
|
||||
list[existingIndex].useCount++;
|
||||
list[existingIndex].lastUsedAt = DateTime.now();
|
||||
} else {
|
||||
place.useCount = 1;
|
||||
list.add(place);
|
||||
}
|
||||
|
||||
await _enforceCap(list);
|
||||
await _save(prefs, list);
|
||||
}
|
||||
|
||||
Future<void> remove(String placeId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final list = await getAll();
|
||||
list.removeWhere((p) => p.id == placeId);
|
||||
await _save(prefs, list);
|
||||
}
|
||||
|
||||
Future<void> setFavorite(String placeId, FavoriteType type) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final list = await getAll();
|
||||
|
||||
// Clear any existing favorite of this type across all items.
|
||||
for (final p in list) {
|
||||
if (p.favoriteType == type) {
|
||||
p.favoriteType = null;
|
||||
}
|
||||
}
|
||||
|
||||
final idx = list.indexWhere((p) => p.id == placeId);
|
||||
if (idx != -1) {
|
||||
list[idx].favoriteType = type;
|
||||
}
|
||||
|
||||
await _save(prefs, list);
|
||||
}
|
||||
|
||||
Future<void> clearFavorite(FavoriteType type) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final list = await getAll();
|
||||
for (final p in list) {
|
||||
if (p.favoriteType == type) {
|
||||
p.favoriteType = null;
|
||||
}
|
||||
}
|
||||
await _save(prefs, list);
|
||||
}
|
||||
|
||||
/// Removes the lowest-scoring non-favorite item when the list exceeds the cap.
|
||||
/// Score = useCount (higher is better), ties broken by lastUsedAt (newer is better).
|
||||
Future<void> _enforceCap(List<SavedPlace> list) async {
|
||||
if (list.length <= _maxItems) return;
|
||||
|
||||
final nonFavorites = list.where((p) => p.favoriteType == null).toList()
|
||||
..sort((a, b) {
|
||||
if (a.useCount != b.useCount) {
|
||||
return a.useCount.compareTo(b.useCount); // ascending — lowest first
|
||||
}
|
||||
return a.lastUsedAt.compareTo(b.lastUsedAt); // oldest first
|
||||
});
|
||||
|
||||
final toRemove = list.length - _maxItems;
|
||||
final idsToRemove =
|
||||
nonFavorites.take(toRemove).map((p) => p.id).toSet();
|
||||
list.removeWhere((p) => idsToRemove.contains(p.id));
|
||||
}
|
||||
|
||||
List<SavedPlace> _sort(List<SavedPlace> list) {
|
||||
final favoriteOrder = {FavoriteType.home: 0, FavoriteType.work: 1};
|
||||
list.sort((a, b) {
|
||||
if (a.favoriteType != null && b.favoriteType == null) return -1;
|
||||
if (a.favoriteType == null && b.favoriteType != null) return 1;
|
||||
if (a.favoriteType != null && b.favoriteType != null) {
|
||||
return favoriteOrder[a.favoriteType]!
|
||||
.compareTo(favoriteOrder[b.favoriteType]!);
|
||||
}
|
||||
if (a.useCount != b.useCount) {
|
||||
return b.useCount.compareTo(a.useCount); // descending
|
||||
}
|
||||
return b.lastUsedAt.compareTo(a.lastUsedAt); // newer first
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
Future<void> _save(SharedPreferences prefs, List<SavedPlace> list) async {
|
||||
final encoded = jsonEncode(list.map((p) => p.toJson()).toList());
|
||||
await prefs.setString(_key, encoded);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
/// تخزين آمن للتوكنات (access + refresh).
|
||||
class TokenStore {
|
||||
final FlutterSecureStorage _s = const FlutterSecureStorage();
|
||||
|
||||
Future<void> save(String access, String refresh) async {
|
||||
await _s.write(key: 'access_token', value: access);
|
||||
await _s.write(key: 'refresh_token', value: refresh);
|
||||
}
|
||||
|
||||
Future<void> saveUser(String userJson) async {
|
||||
await _s.write(key: 'cached_user', value: userJson);
|
||||
}
|
||||
|
||||
Future<String?> readUser() => _s.read(key: 'cached_user');
|
||||
Future<String?> access() => _s.read(key: 'access_token');
|
||||
Future<String?> refresh() => _s.read(key: 'refresh_token');
|
||||
Future<bool> hasToken() async {
|
||||
final token = await access();
|
||||
return token != null && token.isNotEmpty;
|
||||
}
|
||||
Future<void> clear() => _s.deleteAll();
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,168 +0,0 @@
|
||||
import 'helpers.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'countries.dart';
|
||||
|
||||
class PickerDialogStyle {
|
||||
final Color? backgroundColor;
|
||||
|
||||
final TextStyle? countryCodeStyle;
|
||||
|
||||
final TextStyle? countryNameStyle;
|
||||
|
||||
final Widget? listTileDivider;
|
||||
|
||||
final EdgeInsets? listTilePadding;
|
||||
|
||||
final EdgeInsets? padding;
|
||||
|
||||
final Color? searchFieldCursorColor;
|
||||
|
||||
final InputDecoration? searchFieldInputDecoration;
|
||||
|
||||
final EdgeInsets? searchFieldPadding;
|
||||
|
||||
final double? width;
|
||||
|
||||
PickerDialogStyle({
|
||||
this.backgroundColor,
|
||||
this.countryCodeStyle,
|
||||
this.countryNameStyle,
|
||||
this.listTileDivider,
|
||||
this.listTilePadding,
|
||||
this.padding,
|
||||
this.searchFieldCursorColor,
|
||||
this.searchFieldInputDecoration,
|
||||
this.searchFieldPadding,
|
||||
this.width,
|
||||
});
|
||||
}
|
||||
|
||||
class CountryPickerDialog extends StatefulWidget {
|
||||
final List<Country> countryList;
|
||||
final Country selectedCountry;
|
||||
final ValueChanged<Country> onCountryChanged;
|
||||
final String searchText;
|
||||
final List<Country> filteredCountries;
|
||||
final PickerDialogStyle? style;
|
||||
final String languageCode;
|
||||
|
||||
const CountryPickerDialog({
|
||||
Key? key,
|
||||
required this.searchText,
|
||||
required this.languageCode,
|
||||
required this.countryList,
|
||||
required this.onCountryChanged,
|
||||
required this.selectedCountry,
|
||||
required this.filteredCountries,
|
||||
this.style,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<CountryPickerDialog> createState() => _CountryPickerDialogState();
|
||||
}
|
||||
|
||||
class _CountryPickerDialogState extends State<CountryPickerDialog> {
|
||||
late List<Country> _filteredCountries;
|
||||
late Country _selectedCountry;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_selectedCountry = widget.selectedCountry;
|
||||
_filteredCountries = widget.filteredCountries.toList()
|
||||
..sort(
|
||||
(a, b) => a
|
||||
.localizedName(widget.languageCode)
|
||||
.compareTo(b.localizedName(widget.languageCode)),
|
||||
);
|
||||
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mediaWidth = MediaQuery.of(context).size.width;
|
||||
final width = widget.style?.width ?? mediaWidth;
|
||||
const defaultHorizontalPadding = 40.0;
|
||||
const defaultVerticalPadding = 24.0;
|
||||
return Dialog(
|
||||
insetPadding: EdgeInsets.symmetric(
|
||||
vertical: defaultVerticalPadding,
|
||||
horizontal: mediaWidth > (width + defaultHorizontalPadding * 2)
|
||||
? (mediaWidth - width) / 2
|
||||
: defaultHorizontalPadding),
|
||||
backgroundColor: widget.style?.backgroundColor,
|
||||
child: Container(
|
||||
padding: widget.style?.padding ?? const EdgeInsets.all(10),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding:
|
||||
widget.style?.searchFieldPadding ?? const EdgeInsets.all(0),
|
||||
child: TextField(
|
||||
cursorColor: widget.style?.searchFieldCursorColor,
|
||||
decoration: widget.style?.searchFieldInputDecoration ??
|
||||
InputDecoration(
|
||||
suffixIcon: const Icon(Icons.search),
|
||||
labelText: widget.searchText,
|
||||
),
|
||||
onChanged: (value) {
|
||||
_filteredCountries = widget.countryList.stringSearch(value)
|
||||
..sort(
|
||||
(a, b) => a
|
||||
.localizedName(widget.languageCode)
|
||||
.compareTo(b.localizedName(widget.languageCode)),
|
||||
);
|
||||
if (mounted) setState(() {});
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: _filteredCountries.length,
|
||||
itemBuilder: (ctx, index) => Column(
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
leading: kIsWeb
|
||||
? Image.asset(
|
||||
'assets/flags/${_filteredCountries[index].code.toLowerCase()}.png',
|
||||
package: 'intl_phone_field',
|
||||
width: 32,
|
||||
)
|
||||
: Text(
|
||||
_filteredCountries[index].flag,
|
||||
style: const TextStyle(fontSize: 18),
|
||||
),
|
||||
contentPadding: widget.style?.listTilePadding,
|
||||
title: Text(
|
||||
_filteredCountries[index]
|
||||
.localizedName(widget.languageCode),
|
||||
style: widget.style?.countryNameStyle ??
|
||||
const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
trailing: Text(
|
||||
'+${_filteredCountries[index].dialCode}',
|
||||
style: widget.style?.countryCodeStyle ??
|
||||
const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
onTap: () {
|
||||
_selectedCountry = _filteredCountries[index];
|
||||
widget.onCountryChanged(_selectedCountry);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
widget.style?.listTileDivider ??
|
||||
const Divider(thickness: 1),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import 'countries.dart';
|
||||
|
||||
bool isNumeric(String s) =>
|
||||
s.isNotEmpty && int.tryParse(s.replaceAll("+", "")) != null;
|
||||
|
||||
String removeDiacritics(String str) {
|
||||
var withDia =
|
||||
'ÀÁÂÃÄÅàáâãäåÒÓÔÕÕÖØòóôõöøÈÉÊËèéêëðÇçÐÌÍÎÏìíîïÙÚÛÜùúûüÑñŠšŸÿýŽž';
|
||||
var withoutDia =
|
||||
'AAAAAAaaaaaaOOOOOOOooooooEEEEeeeeeCcDIIIIiiiiUUUUuuuuNnSsYyyZz';
|
||||
|
||||
for (int i = 0; i < withDia.length; i++) {
|
||||
str = str.replaceAll(withDia[i], withoutDia[i]);
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
extension CountryExtensions on List<Country> {
|
||||
List<Country> stringSearch(String search) {
|
||||
search = removeDiacritics(search.toLowerCase());
|
||||
return where(
|
||||
(country) => isNumeric(search) || search.startsWith("+")
|
||||
? country.dialCode.contains(search)
|
||||
: removeDiacritics(country.name.replaceAll("+", "").toLowerCase())
|
||||
.contains(search) ||
|
||||
country.nameTranslations.values.any((element) =>
|
||||
removeDiacritics(element.toLowerCase()).contains(search)),
|
||||
).toList();
|
||||
}
|
||||
}
|
||||
@@ -1,521 +0,0 @@
|
||||
library intl_phone_field;
|
||||
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import './countries.dart';
|
||||
import './phone_number.dart';
|
||||
import 'country_picker_dialog.dart';
|
||||
import 'helpers.dart';
|
||||
|
||||
class IntlPhoneField extends StatefulWidget {
|
||||
/// The TextFormField key.
|
||||
final GlobalKey<FormFieldState>? formFieldKey;
|
||||
|
||||
/// Whether to hide the text being edited (e.g., for passwords).
|
||||
final bool obscureText;
|
||||
|
||||
/// How the text should be aligned horizontally.
|
||||
final TextAlign textAlign;
|
||||
|
||||
/// How the text should be aligned vertically.
|
||||
final TextAlignVertical? textAlignVertical;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// {@macro flutter.widgets.editableText.readOnly}
|
||||
final bool readOnly;
|
||||
final FormFieldSetter<PhoneNumber>? onSaved;
|
||||
|
||||
/// {@macro flutter.widgets.editableText.onChanged}
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [inputFormatters], which are called before [onChanged]
|
||||
/// runs and can validate and change ("format") the input value.
|
||||
/// * [onEditingComplete], [onSubmitted], [onSelectionChanged]:
|
||||
/// which are more specialized input change notifications.
|
||||
final ValueChanged<PhoneNumber>? onChanged;
|
||||
|
||||
final ValueChanged<Country>? onCountryChanged;
|
||||
|
||||
/// An optional method that validates an input. Returns an error string to display if the input is invalid, or null otherwise.
|
||||
///
|
||||
/// A [PhoneNumber] is passed to the validator as argument.
|
||||
/// The validator can handle asynchronous validation when declared as a [Future].
|
||||
/// Or run synchronously when declared as a [Function].
|
||||
///
|
||||
/// By default, the validator checks whether the input number length is between selected country's phone numbers min and max length.
|
||||
/// If `disableLengthCheck` is not set to `true`, your validator returned value will be overwritten by the default validator.
|
||||
/// But, if `disableLengthCheck` is set to `true`, your validator will have to check phone number length itself.
|
||||
final FutureOr<String?> Function(PhoneNumber?)? validator;
|
||||
|
||||
/// {@macro flutter.widgets.editableText.keyboardType}
|
||||
final TextInputType keyboardType;
|
||||
|
||||
/// Controls the text being edited.
|
||||
///
|
||||
/// If null, this widget will create its own [TextEditingController].
|
||||
final TextEditingController? controller;
|
||||
|
||||
/// Defines the keyboard focus for this widget.
|
||||
///
|
||||
/// The [focusNode] is a long-lived object that's typically managed by a
|
||||
/// [StatefulWidget] parent. See [FocusNode] for more information.
|
||||
///
|
||||
/// To give the keyboard focus to this widget, provide a [focusNode] and then
|
||||
/// use the current [FocusScope] to request the focus:
|
||||
///
|
||||
/// ```dart
|
||||
/// FocusScope.of(context).requestFocus(myFocusNode);
|
||||
/// ```
|
||||
///
|
||||
/// This happens automatically when the widget is tapped.
|
||||
///
|
||||
/// To be notified when the widget gains or loses the focus, add a listener
|
||||
/// to the [focusNode]:
|
||||
///
|
||||
/// ```dart
|
||||
/// focusNode.addListener(() { Log.print(myFocusNode.hasFocus); });
|
||||
/// ```
|
||||
///
|
||||
/// If null, this widget will create its own [FocusNode].
|
||||
///
|
||||
/// ## Keyboard
|
||||
///
|
||||
/// Requesting the focus will typically cause the keyboard to be shown
|
||||
/// if it's not showing already.
|
||||
///
|
||||
/// On Android, the user can hide the keyboard - without changing the focus -
|
||||
/// with the system back button. They can restore the keyboard's visibility
|
||||
/// by tapping on a text field. The user might hide the keyboard and
|
||||
/// switch to a physical keyboard, or they might just need to get it
|
||||
/// out of the way for a moment, to expose something it's
|
||||
/// obscuring. In this case requesting the focus again will not
|
||||
/// cause the focus to change, and will not make the keyboard visible.
|
||||
///
|
||||
/// This widget builds an [EditableText] and will ensure that the keyboard is
|
||||
/// showing when it is tapped by calling [EditableTextState.requestKeyboard()].
|
||||
final FocusNode? focusNode;
|
||||
|
||||
/// {@macro flutter.widgets.editableText.onSubmitted}
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [EditableText.onSubmitted] for an example of how to handle moving to
|
||||
/// the next/previous field when using [TextInputAction.next] and
|
||||
/// [TextInputAction.previous] for [textInputAction].
|
||||
final void Function(String)? onSubmitted;
|
||||
|
||||
/// If false the widget is "disabled": it ignores taps, the [TextFormField]'s
|
||||
/// [decoration] is rendered in grey,
|
||||
/// [decoration]'s [InputDecoration.counterText] is set to `""`,
|
||||
/// and the drop down icon is hidden no matter [showDropdownIcon] value.
|
||||
///
|
||||
/// If non-null this property overrides the [decoration]'s
|
||||
/// [Decoration.enabled] property.
|
||||
final bool enabled;
|
||||
|
||||
/// The appearance of the keyboard.
|
||||
///
|
||||
/// This setting is only honored on iOS devices.
|
||||
///
|
||||
/// If unset, defaults to the brightness of [ThemeData.brightness].
|
||||
final Brightness? keyboardAppearance;
|
||||
|
||||
/// Initial Value for the field.
|
||||
/// This property can be used to pre-fill the field.
|
||||
final String? initialValue;
|
||||
|
||||
final String languageCode;
|
||||
|
||||
/// 2 letter ISO Code or country dial code.
|
||||
///
|
||||
/// ```dart
|
||||
/// initialCountryCode: 'IN', // India
|
||||
/// initialCountryCode: '+225', // Côte d'Ivoire
|
||||
/// ```
|
||||
final String? initialCountryCode;
|
||||
|
||||
/// List of Country to display see countries.dart for format
|
||||
final List<Country>? countries;
|
||||
|
||||
/// The decoration to show around the text field.
|
||||
///
|
||||
/// By default, draws a horizontal line under the text field but can be
|
||||
/// configured to show an icon, label, hint text, and error text.
|
||||
///
|
||||
/// Specify null to remove the decoration entirely (including the
|
||||
/// extra padding introduced by the decoration to save space for the labels).
|
||||
final InputDecoration decoration;
|
||||
|
||||
/// The style to use for the text being edited.
|
||||
///
|
||||
/// This text style is also used as the base style for the [decoration].
|
||||
///
|
||||
/// If null, defaults to the `subtitle1` text style from the current [Theme].
|
||||
final TextStyle? style;
|
||||
|
||||
/// Disable view Min/Max Length check
|
||||
final bool disableLengthCheck;
|
||||
|
||||
/// Won't work if [enabled] is set to `false`.
|
||||
final bool showDropdownIcon;
|
||||
|
||||
final BoxDecoration dropdownDecoration;
|
||||
|
||||
/// The style use for the country dial code.
|
||||
final TextStyle? dropdownTextStyle;
|
||||
|
||||
/// {@macro flutter.widgets.editableText.inputFormatters}
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
|
||||
/// The text that describes the search input field.
|
||||
///
|
||||
/// When the input field is empty and unfocused, the label is displayed on top of the input field (i.e., at the same location on the screen where text may be entered in the input field).
|
||||
/// When the input field receives focus (or if the field is non-empty), the label moves above (i.e., vertically adjacent to) the input field.
|
||||
final String searchText;
|
||||
|
||||
/// Position of an icon [leading, trailing]
|
||||
final IconPosition dropdownIconPosition;
|
||||
|
||||
/// Icon of the drop down button.
|
||||
///
|
||||
/// Default is [Icon(Icons.arrow_drop_down)]
|
||||
final Icon dropdownIcon;
|
||||
|
||||
/// Whether this text field should focus itself if nothing else is already focused.
|
||||
final bool autofocus;
|
||||
|
||||
/// Autovalidate mode for text form field.
|
||||
///
|
||||
/// If [AutovalidateMode.onUserInteraction], this FormField will only auto-validate after its content changes.
|
||||
/// If [AutovalidateMode.always], it will auto-validate even without user interaction.
|
||||
/// If [AutovalidateMode.disabled], auto-validation will be disabled.
|
||||
///
|
||||
/// Defaults to [AutovalidateMode.onUserInteraction].
|
||||
final AutovalidateMode? autovalidateMode;
|
||||
|
||||
/// Whether to show or hide country flag.
|
||||
///
|
||||
/// Default value is `true`.
|
||||
final bool showCountryFlag;
|
||||
|
||||
/// Message to be displayed on autoValidate error
|
||||
///
|
||||
/// Default value is `Invalid Mobile Number`.
|
||||
final String? invalidNumberMessage;
|
||||
|
||||
/// The color of the cursor.
|
||||
final Color? cursorColor;
|
||||
|
||||
/// How tall the cursor will be.
|
||||
final double? cursorHeight;
|
||||
|
||||
/// How rounded the corners of the cursor should be.
|
||||
final Radius? cursorRadius;
|
||||
|
||||
/// How thick the cursor will be.
|
||||
final double cursorWidth;
|
||||
|
||||
/// Whether to show cursor.
|
||||
final bool? showCursor;
|
||||
|
||||
/// The padding of the Flags Button.
|
||||
///
|
||||
/// The amount of insets that are applied to the Flags Button.
|
||||
///
|
||||
/// If unset, defaults to [EdgeInsets.zero].
|
||||
final EdgeInsetsGeometry flagsButtonPadding;
|
||||
|
||||
/// The type of action button to use for the keyboard.
|
||||
final TextInputAction? textInputAction;
|
||||
|
||||
/// Optional set of styles to allow for customizing the country search
|
||||
/// & pick dialog
|
||||
final PickerDialogStyle? pickerDialogStyle;
|
||||
|
||||
/// The margin of the country selector button.
|
||||
///
|
||||
/// The amount of space to surround the country selector button.
|
||||
///
|
||||
/// If unset, defaults to [EdgeInsets.zero].
|
||||
final EdgeInsets flagsButtonMargin;
|
||||
|
||||
/// Enable the autofill hint for phone number.
|
||||
final bool disableAutoFillHints;
|
||||
|
||||
/// If null, default magnification configuration will be used.
|
||||
final TextMagnifierConfiguration? magnifierConfiguration;
|
||||
|
||||
const IntlPhoneField({
|
||||
Key? key,
|
||||
this.formFieldKey,
|
||||
this.initialCountryCode,
|
||||
this.languageCode = 'en',
|
||||
this.disableAutoFillHints = false,
|
||||
this.obscureText = false,
|
||||
this.textAlign = TextAlign.left,
|
||||
this.textAlignVertical,
|
||||
this.onTap,
|
||||
this.readOnly = false,
|
||||
this.initialValue,
|
||||
this.keyboardType = TextInputType.phone,
|
||||
this.controller,
|
||||
this.focusNode,
|
||||
this.decoration = const InputDecoration(),
|
||||
this.style,
|
||||
this.dropdownTextStyle,
|
||||
this.onSubmitted,
|
||||
this.validator,
|
||||
this.onChanged,
|
||||
this.countries,
|
||||
this.onCountryChanged,
|
||||
this.onSaved,
|
||||
this.showDropdownIcon = true,
|
||||
this.dropdownDecoration = const BoxDecoration(),
|
||||
this.inputFormatters,
|
||||
this.enabled = true,
|
||||
this.keyboardAppearance,
|
||||
@Deprecated('Use searchFieldInputDecoration of PickerDialogStyle instead')
|
||||
this.searchText = 'Search country',
|
||||
this.dropdownIconPosition = IconPosition.leading,
|
||||
this.dropdownIcon = const Icon(Icons.arrow_drop_down),
|
||||
this.autofocus = false,
|
||||
this.textInputAction,
|
||||
this.autovalidateMode = AutovalidateMode.onUserInteraction,
|
||||
this.showCountryFlag = true,
|
||||
this.cursorColor,
|
||||
this.disableLengthCheck = false,
|
||||
this.flagsButtonPadding = EdgeInsets.zero,
|
||||
this.invalidNumberMessage = 'Invalid Mobile Number',
|
||||
this.cursorHeight,
|
||||
this.cursorRadius = Radius.zero,
|
||||
this.cursorWidth = 2.0,
|
||||
this.showCursor = true,
|
||||
this.pickerDialogStyle,
|
||||
this.flagsButtonMargin = EdgeInsets.zero,
|
||||
this.magnifierConfiguration,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<IntlPhoneField> createState() => _IntlPhoneFieldState();
|
||||
}
|
||||
|
||||
class _IntlPhoneFieldState extends State<IntlPhoneField> {
|
||||
late List<Country> _countryList;
|
||||
late Country _selectedCountry;
|
||||
late List<Country> filteredCountries;
|
||||
late String number;
|
||||
|
||||
String? validatorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_countryList = widget.countries ?? countries;
|
||||
filteredCountries = _countryList;
|
||||
number = widget.initialValue ?? '';
|
||||
if (widget.initialCountryCode == null && number.startsWith('+')) {
|
||||
number = number.substring(1);
|
||||
// parse initial value
|
||||
_selectedCountry = countries.firstWhere(
|
||||
(country) => number.startsWith(country.fullCountryCode),
|
||||
orElse: () => _countryList.first);
|
||||
|
||||
// remove country code from the initial number value
|
||||
number = number.replaceFirst(
|
||||
RegExp("^${_selectedCountry.fullCountryCode}"), "");
|
||||
} else {
|
||||
_selectedCountry = _countryList.firstWhere(
|
||||
(item) => item.code == (widget.initialCountryCode ?? 'US'),
|
||||
orElse: () => _countryList.first);
|
||||
|
||||
// remove country code from the initial number value
|
||||
if (number.startsWith('+')) {
|
||||
number = number.replaceFirst(
|
||||
RegExp("^\\+${_selectedCountry.fullCountryCode}"), "");
|
||||
} else {
|
||||
number = number.replaceFirst(
|
||||
RegExp("^${_selectedCountry.fullCountryCode}"), "");
|
||||
}
|
||||
}
|
||||
|
||||
if (widget.autovalidateMode == AutovalidateMode.always) {
|
||||
final initialPhoneNumber = PhoneNumber(
|
||||
countryISOCode: _selectedCountry.code,
|
||||
countryCode: '+${_selectedCountry.dialCode}',
|
||||
number: widget.initialValue ?? '',
|
||||
);
|
||||
|
||||
final value = widget.validator?.call(initialPhoneNumber);
|
||||
|
||||
if (value is String) {
|
||||
validatorMessage = value;
|
||||
} else {
|
||||
(value as Future).then((msg) {
|
||||
validatorMessage = msg;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _changeCountry() async {
|
||||
filteredCountries = _countryList;
|
||||
await showDialog(
|
||||
context: context,
|
||||
useRootNavigator: false,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (ctx, setState) => CountryPickerDialog(
|
||||
languageCode: widget.languageCode.toLowerCase(),
|
||||
style: widget.pickerDialogStyle,
|
||||
filteredCountries: filteredCountries,
|
||||
searchText: widget.searchText,
|
||||
countryList: _countryList,
|
||||
selectedCountry: _selectedCountry,
|
||||
onCountryChanged: (Country country) {
|
||||
_selectedCountry = country;
|
||||
widget.onCountryChanged?.call(country);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
key: widget.formFieldKey,
|
||||
initialValue: (widget.controller == null) ? number : null,
|
||||
autofillHints: widget.disableAutoFillHints
|
||||
? null
|
||||
: [AutofillHints.telephoneNumberNational],
|
||||
readOnly: widget.readOnly,
|
||||
obscureText: widget.obscureText,
|
||||
textAlign: widget.textAlign,
|
||||
textAlignVertical: widget.textAlignVertical,
|
||||
cursorColor: widget.cursorColor,
|
||||
onTap: widget.onTap,
|
||||
controller: widget.controller,
|
||||
focusNode: widget.focusNode,
|
||||
cursorHeight: widget.cursorHeight,
|
||||
cursorRadius: widget.cursorRadius,
|
||||
cursorWidth: widget.cursorWidth,
|
||||
showCursor: widget.showCursor,
|
||||
onFieldSubmitted: widget.onSubmitted,
|
||||
magnifierConfiguration: widget.magnifierConfiguration,
|
||||
decoration: widget.decoration.copyWith(
|
||||
prefixIcon: _buildFlagsButton(),
|
||||
counterText: !widget.enabled ? '' : null,
|
||||
),
|
||||
style: widget.style,
|
||||
onSaved: (value) {
|
||||
widget.onSaved?.call(
|
||||
PhoneNumber(
|
||||
countryISOCode: _selectedCountry.code,
|
||||
countryCode:
|
||||
'+${_selectedCountry.dialCode}${_selectedCountry.regionCode}',
|
||||
number: value!,
|
||||
),
|
||||
);
|
||||
},
|
||||
onChanged: (value) async {
|
||||
final phoneNumber = PhoneNumber(
|
||||
countryISOCode: _selectedCountry.code,
|
||||
countryCode: '+${_selectedCountry.fullCountryCode}',
|
||||
number: value,
|
||||
);
|
||||
|
||||
if (widget.autovalidateMode != AutovalidateMode.disabled) {
|
||||
validatorMessage = await widget.validator?.call(phoneNumber);
|
||||
}
|
||||
|
||||
widget.onChanged?.call(phoneNumber);
|
||||
},
|
||||
validator: (value) {
|
||||
if (value == null || !isNumeric(value)) return validatorMessage;
|
||||
if (!widget.disableLengthCheck) {
|
||||
return value.length >= _selectedCountry.minLength &&
|
||||
value.length <= _selectedCountry.maxLength
|
||||
? null
|
||||
: widget.invalidNumberMessage;
|
||||
}
|
||||
|
||||
return validatorMessage;
|
||||
},
|
||||
maxLength: widget.disableLengthCheck ? null : _selectedCountry.maxLength,
|
||||
keyboardType: widget.keyboardType,
|
||||
inputFormatters: widget.inputFormatters,
|
||||
enabled: widget.enabled,
|
||||
keyboardAppearance: widget.keyboardAppearance,
|
||||
autofocus: widget.autofocus,
|
||||
textInputAction: widget.textInputAction,
|
||||
autovalidateMode: widget.autovalidateMode,
|
||||
);
|
||||
}
|
||||
|
||||
Container _buildFlagsButton() {
|
||||
return Container(
|
||||
margin: widget.flagsButtonMargin,
|
||||
child: DecoratedBox(
|
||||
decoration: widget.dropdownDecoration,
|
||||
child: InkWell(
|
||||
borderRadius: widget.dropdownDecoration.borderRadius as BorderRadius?,
|
||||
onTap: widget.enabled ? _changeCountry : null,
|
||||
child: Padding(
|
||||
padding: widget.flagsButtonPadding,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
const SizedBox(
|
||||
width: 4,
|
||||
),
|
||||
if (widget.enabled &&
|
||||
widget.showDropdownIcon &&
|
||||
widget.dropdownIconPosition == IconPosition.leading) ...[
|
||||
widget.dropdownIcon,
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
if (widget.showCountryFlag) ...[
|
||||
kIsWeb
|
||||
? Image.asset(
|
||||
'assets/flags/${_selectedCountry.code.toLowerCase()}.png',
|
||||
package: 'intl_phone_field',
|
||||
width: 32,
|
||||
)
|
||||
: Text(
|
||||
_selectedCountry.flag,
|
||||
style: const TextStyle(fontSize: 18),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
FittedBox(
|
||||
child: Text(
|
||||
'+${_selectedCountry.dialCode}',
|
||||
style: widget.dropdownTextStyle,
|
||||
),
|
||||
),
|
||||
if (widget.enabled &&
|
||||
widget.showDropdownIcon &&
|
||||
widget.dropdownIconPosition == IconPosition.trailing) ...[
|
||||
const SizedBox(width: 4),
|
||||
widget.dropdownIcon,
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum IconPosition {
|
||||
leading,
|
||||
trailing,
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import 'countries.dart';
|
||||
|
||||
class NumberTooLongException implements Exception {}
|
||||
|
||||
class NumberTooShortException implements Exception {}
|
||||
|
||||
class InvalidCharactersException implements Exception {}
|
||||
|
||||
class PhoneNumber {
|
||||
String countryISOCode;
|
||||
String countryCode;
|
||||
String number;
|
||||
|
||||
PhoneNumber({
|
||||
required this.countryISOCode,
|
||||
required this.countryCode,
|
||||
required this.number,
|
||||
});
|
||||
|
||||
factory PhoneNumber.fromCompleteNumber({required String completeNumber}) {
|
||||
if (completeNumber == "") {
|
||||
return PhoneNumber(countryISOCode: "", countryCode: "", number: "");
|
||||
}
|
||||
|
||||
try {
|
||||
Country country = getCountry(completeNumber);
|
||||
String number;
|
||||
if (completeNumber.startsWith('+')) {
|
||||
number = completeNumber.substring(1 + country.dialCode.length + country.regionCode.length);
|
||||
} else {
|
||||
number = completeNumber.substring(country.dialCode.length + country.regionCode.length);
|
||||
}
|
||||
return PhoneNumber(
|
||||
countryISOCode: country.code, countryCode: country.dialCode + country.regionCode, number: number);
|
||||
} on InvalidCharactersException {
|
||||
rethrow;
|
||||
// ignore: unused_catch_clause
|
||||
} on Exception catch (e) {
|
||||
return PhoneNumber(countryISOCode: "", countryCode: "", number: "");
|
||||
}
|
||||
}
|
||||
|
||||
bool isValidNumber() {
|
||||
Country country = getCountry(completeNumber);
|
||||
if (number.length < country.minLength) {
|
||||
throw NumberTooShortException();
|
||||
}
|
||||
|
||||
if (number.length > country.maxLength) {
|
||||
throw NumberTooLongException();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
String get completeNumber {
|
||||
return countryCode + number;
|
||||
}
|
||||
|
||||
static Country getCountry(String phoneNumber) {
|
||||
if (phoneNumber == "") {
|
||||
throw NumberTooShortException();
|
||||
}
|
||||
|
||||
final validPhoneNumber = RegExp(r'^[+0-9]*[0-9]*$');
|
||||
|
||||
if (!validPhoneNumber.hasMatch(phoneNumber)) {
|
||||
throw InvalidCharactersException();
|
||||
}
|
||||
|
||||
if (phoneNumber.startsWith('+')) {
|
||||
return countries
|
||||
.firstWhere((country) => phoneNumber.substring(1).startsWith(country.dialCode + country.regionCode));
|
||||
}
|
||||
return countries.firstWhere((country) => phoneNumber.startsWith(country.dialCode + country.regionCode));
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'PhoneNumber(countryISOCode: $countryISOCode, countryCode: $countryCode, number: $number)';
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../design/tokens.dart';
|
||||
|
||||
/// هيكل التحميل — تأثير نبض على عناصر مستوحاة من الشاشة الفعلية.
|
||||
/// docs/26 §7
|
||||
class Skeleton extends StatelessWidget {
|
||||
const Skeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final baseColor = cs.surfaceContainerHighest;
|
||||
final highlightColor = cs.surfaceContainerHigh;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(T.pagePadding),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_PulseBox(width: double.infinity, height: 200, base: baseColor, highlight: highlightColor),
|
||||
const SizedBox(height: T.s16),
|
||||
_PulseBox(width: 200, height: 20, base: baseColor, highlight: highlightColor),
|
||||
const SizedBox(height: T.s12),
|
||||
_PulseBox(width: 140, height: 16, base: baseColor, highlight: highlightColor),
|
||||
const SizedBox(height: T.s24),
|
||||
Row(
|
||||
children: [
|
||||
_PulseBox(width: 48, height: 48, base: baseColor, highlight: highlightColor, radius: T.rCircle),
|
||||
const SizedBox(width: T.s12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_PulseBox(width: double.infinity, height: 16, base: baseColor, highlight: highlightColor),
|
||||
const SizedBox(height: T.s8),
|
||||
_PulseBox(width: 120, height: 14, base: baseColor, highlight: highlightColor),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PulseBox extends StatefulWidget {
|
||||
const _PulseBox({
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.base,
|
||||
required this.highlight,
|
||||
this.radius = 8,
|
||||
});
|
||||
|
||||
final double width;
|
||||
final double height;
|
||||
final Color base;
|
||||
final Color highlight;
|
||||
final double radius;
|
||||
|
||||
@override
|
||||
State<_PulseBox> createState() => _PulseBoxState();
|
||||
}
|
||||
|
||||
class _PulseBoxState extends State<_PulseBox>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _ctrl;
|
||||
late final Animation<double> _anim;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 1200))
|
||||
..repeat();
|
||||
_anim = Tween<double>(begin: 0, end: 1).animate(
|
||||
CurvedAnimation(parent: _ctrl, curve: Curves.easeInOut),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _anim,
|
||||
builder: (_, __) {
|
||||
return Container(
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
decoration: BoxDecoration(
|
||||
color: Color.lerp(widget.base, widget.highlight, _anim.value),
|
||||
borderRadius: BorderRadius.circular(widget.radius),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../design/tokens.dart';
|
||||
|
||||
/// شريط حالة الرحلة الحي.
|
||||
/// docs/26 §7
|
||||
class StatusBanner extends StatelessWidget {
|
||||
const StatusBanner({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.color,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final Color? color;
|
||||
final IconData? icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final effectiveColor = color ?? cs.primary;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: T.s16,
|
||||
vertical: T.s12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: effectiveColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(T.r8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, color: effectiveColor, size: T.iconSm),
|
||||
const SizedBox(width: T.s8),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: effectiveColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../design/tokens.dart';
|
||||
|
||||
/// زر موحّد: primary (معبّأ) · secondary (محدّد) · text — مع حالة تحميل داخلية.
|
||||
/// docs/26 §7
|
||||
enum TripzButtonStyle { primary, secondary, text }
|
||||
|
||||
class TripzButton extends StatelessWidget {
|
||||
const TripzButton({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.onPressed,
|
||||
this.style = TripzButtonStyle.primary,
|
||||
this.loading = false,
|
||||
this.icon,
|
||||
this.fullWidth = true,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
final TripzButtonStyle style;
|
||||
final bool loading;
|
||||
final IconData? icon;
|
||||
final bool fullWidth;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final cs = theme.colorScheme;
|
||||
final labelStyle = theme.textTheme.labelLarge;
|
||||
|
||||
final effectiveOnPressed = loading ? null : onPressed;
|
||||
|
||||
switch (style) {
|
||||
case TripzButtonStyle.primary:
|
||||
return SizedBox(
|
||||
width: fullWidth ? double.infinity : null,
|
||||
height: T.buttonHeight,
|
||||
child: FilledButton(
|
||||
onPressed: effectiveOnPressed,
|
||||
child: _buildChild(cs.primary, labelStyle),
|
||||
),
|
||||
);
|
||||
case TripzButtonStyle.secondary:
|
||||
return SizedBox(
|
||||
width: fullWidth ? double.infinity : null,
|
||||
height: T.buttonHeight,
|
||||
child: OutlinedButton(
|
||||
onPressed: effectiveOnPressed,
|
||||
child: _buildChild(cs.primary, labelStyle),
|
||||
),
|
||||
);
|
||||
case TripzButtonStyle.text:
|
||||
return TextButton(
|
||||
onPressed: effectiveOnPressed,
|
||||
child: _buildChild(cs.primary, labelStyle),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildChild(Color color, TextStyle? style) {
|
||||
if (loading) {
|
||||
return const SizedBox(
|
||||
height: 22,
|
||||
width: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
);
|
||||
}
|
||||
|
||||
if (icon != null) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 20),
|
||||
const SizedBox(width: T.s8),
|
||||
Text(label, style: style),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Text(label, style: style);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../design/tokens.dart';
|
||||
|
||||
/// بطاقة موحّدة: رحلة / سائق / محفظة — زوايا 12، حشوة 16.
|
||||
/// docs/26 §7
|
||||
class TripzCard extends StatelessWidget {
|
||||
const TripzCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding,
|
||||
this.onTap,
|
||||
this.color,
|
||||
this.borderColor,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final VoidCallback? onTap;
|
||||
final Color? color;
|
||||
final Color? borderColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final card = Card(
|
||||
color: color,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(T.r12),
|
||||
side: borderColor != null
|
||||
? BorderSide(color: borderColor!)
|
||||
: BorderSide.none,
|
||||
),
|
||||
child: Padding(
|
||||
padding: padding ?? const EdgeInsets.all(T.pagePadding),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
|
||||
if (onTap != null) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(T.r12),
|
||||
child: card,
|
||||
);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
}
|
||||
@@ -1,390 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../design/tokens.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
/// السكافولد الموحّد — كل شاشة في التطبيق تُبنى داخله.
|
||||
/// يحوّل `status` من Cubit/Bloc إلى: skeleton / error / empty / محتوى.
|
||||
/// docs/26 §6
|
||||
class TripzScaffold extends StatelessWidget {
|
||||
const TripzScaffold({
|
||||
super.key,
|
||||
this.title,
|
||||
this.status,
|
||||
this.onRetry,
|
||||
this.empty,
|
||||
this.bottomAction,
|
||||
this.actions,
|
||||
this.showBackButton = false,
|
||||
this.onBack,
|
||||
this.safeAreaTop = true,
|
||||
this.safeAreaBottom = true,
|
||||
this.padding = true,
|
||||
this.backgroundColor,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final String? title;
|
||||
final Enum? status;
|
||||
final VoidCallback? onRetry;
|
||||
final Widget? empty;
|
||||
final Widget? bottomAction;
|
||||
final List<Widget>? actions;
|
||||
final bool showBackButton;
|
||||
final VoidCallback? onBack;
|
||||
final bool safeAreaTop;
|
||||
final bool safeAreaBottom;
|
||||
final bool padding;
|
||||
final Color? backgroundColor;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final cs = theme.colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: backgroundColor ?? cs.surface,
|
||||
appBar: title != null
|
||||
? AppBar(
|
||||
title: Text(title!),
|
||||
leading: showBackButton
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 20),
|
||||
onPressed: onBack ?? () => Navigator.maybePop(context),
|
||||
)
|
||||
: null,
|
||||
actions: actions,
|
||||
)
|
||||
: null,
|
||||
body: SafeArea(
|
||||
top: safeAreaTop,
|
||||
bottom: safeAreaBottom,
|
||||
child: _buildBody(context),
|
||||
),
|
||||
bottomNavigationBar: bottomAction != null
|
||||
? Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
T.pagePadding,
|
||||
0,
|
||||
T.pagePadding,
|
||||
MediaQuery.of(context).viewInsets.bottom > 0
|
||||
? MediaQuery.of(context).viewInsets.bottom + 8
|
||||
: T.pagePadding,
|
||||
),
|
||||
child: bottomAction,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(BuildContext context) {
|
||||
if (status != null) {
|
||||
if (_isError) return ErrorView(onRetry: onRetry);
|
||||
if (_isLoading) return const Skeleton();
|
||||
if (_isEmpty) return empty ?? const EmptyView();
|
||||
}
|
||||
|
||||
return padding
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(T.pagePadding),
|
||||
child: child,
|
||||
)
|
||||
: child;
|
||||
}
|
||||
|
||||
bool get _isLoading =>
|
||||
status != null && status!.name == 'loading';
|
||||
|
||||
bool get _isError =>
|
||||
status != null && status!.name == 'error';
|
||||
|
||||
bool get _isEmpty =>
|
||||
status != null && status!.name == 'empty';
|
||||
}
|
||||
|
||||
class ErrorView extends StatelessWidget {
|
||||
final VoidCallback? onRetry;
|
||||
const ErrorView({super.key, this.onRetry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final cs = theme.colorScheme;
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(T.pagePadding),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: T.s64,
|
||||
height: T.s64,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.error.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.error_outline_rounded,
|
||||
size: T.s48,
|
||||
color: cs.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: T.s24),
|
||||
Text(
|
||||
l10n?.errorTitle ?? 'حدث خطأ',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: T.s8),
|
||||
Text(
|
||||
l10n?.errorSubtitle ?? 'يرجى المحاولة مرة أخرى',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (onRetry != null) ...[
|
||||
const SizedBox(height: T.s24),
|
||||
FilledButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh_rounded, size: T.iconSm),
|
||||
label: Text(l10n?.retry ?? 'أعد المحاولة'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Skeleton extends StatefulWidget {
|
||||
const Skeleton({super.key});
|
||||
|
||||
@override
|
||||
State<Skeleton> createState() => _SkeletonState();
|
||||
}
|
||||
|
||||
class _SkeletonState extends State<Skeleton> with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1200),
|
||||
)..repeat();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final baseColor = cs.surfaceContainerHighest;
|
||||
final highlightColor = cs.surfaceContainerLow;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
return CustomPaint(
|
||||
painter: _ShimmerPainter(
|
||||
baseColor: baseColor,
|
||||
highlightColor: highlightColor,
|
||||
progress: _controller.value,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: _buildSkeletonLayout(context),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSkeletonLayout(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final baseColor = cs.surfaceContainerHighest;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(T.pagePadding),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header row skeleton
|
||||
_ShimmerBox(
|
||||
width: 160,
|
||||
height: 24,
|
||||
color: baseColor,
|
||||
radius: T.r8,
|
||||
),
|
||||
const SizedBox(height: T.s16),
|
||||
// Subtitle row skeleton
|
||||
_ShimmerBox(
|
||||
width: 100,
|
||||
height: 14,
|
||||
color: baseColor,
|
||||
radius: T.r8,
|
||||
),
|
||||
const SizedBox(height: T.s24),
|
||||
// Card-like skeleton block 1
|
||||
_ShimmerBox(
|
||||
width: double.infinity,
|
||||
height: 120,
|
||||
color: baseColor,
|
||||
radius: T.r12,
|
||||
),
|
||||
const SizedBox(height: T.s16),
|
||||
// Card-like skeleton block 2
|
||||
_ShimmerBox(
|
||||
width: double.infinity,
|
||||
height: 80,
|
||||
color: baseColor,
|
||||
radius: T.r12,
|
||||
),
|
||||
const SizedBox(height: T.s16),
|
||||
// Row of two small boxes
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _ShimmerBox(
|
||||
height: 48,
|
||||
color: baseColor,
|
||||
radius: T.r12,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: T.s12),
|
||||
Expanded(
|
||||
child: _ShimmerBox(
|
||||
height: 48,
|
||||
color: baseColor,
|
||||
radius: T.r12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ShimmerBox extends StatelessWidget {
|
||||
const _ShimmerBox({
|
||||
required this.height,
|
||||
required this.color,
|
||||
this.width = double.infinity,
|
||||
this.radius = 0,
|
||||
});
|
||||
|
||||
final double width;
|
||||
final double height;
|
||||
final Color color;
|
||||
final double radius;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ShimmerPainter extends CustomPainter {
|
||||
_ShimmerPainter({
|
||||
required this.baseColor,
|
||||
required this.highlightColor,
|
||||
required this.progress,
|
||||
});
|
||||
|
||||
final Color baseColor;
|
||||
final Color highlightColor;
|
||||
final double progress;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint();
|
||||
final dx = size.width * 1.5 * progress;
|
||||
final gradient = LinearGradient(
|
||||
begin: const Alignment(-0.3, 0),
|
||||
end: const Alignment(0.3, 0),
|
||||
colors: [
|
||||
baseColor,
|
||||
highlightColor,
|
||||
baseColor,
|
||||
],
|
||||
stops: const [0.0, 0.5, 1.0],
|
||||
);
|
||||
final rect = Rect.fromCenter(
|
||||
center: Offset(dx, size.height / 2),
|
||||
width: size.width * 0.6,
|
||||
height: size.height,
|
||||
);
|
||||
paint.shader = gradient.createShader(rect);
|
||||
canvas.drawRect(rect, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_ShimmerPainter old) => old.progress != progress;
|
||||
}
|
||||
|
||||
class EmptyView extends StatelessWidget {
|
||||
const EmptyView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final cs = theme.colorScheme;
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(T.pagePadding),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: T.s64,
|
||||
height: T.s64,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.inbox_outlined,
|
||||
size: T.iconLg,
|
||||
color: cs.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: T.s24),
|
||||
Text(
|
||||
l10n?.emptyTitle ?? 'لا توجد بيانات',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: T.s8),
|
||||
Text(
|
||||
l10n?.emptySubtitle ?? 'لم يتم العثور على محتوى حالياً',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../design/tokens.dart';
|
||||
|
||||
/// كل bottom sheet في التطبيق يمرّ من هنا: مقبض علوي، زوايا 24.
|
||||
/// docs/26 §7
|
||||
class TripzSheet extends StatelessWidget {
|
||||
const TripzSheet({
|
||||
super.key,
|
||||
this.title,
|
||||
this.child,
|
||||
this.bottomAction,
|
||||
this.isScrollControlled = true,
|
||||
});
|
||||
|
||||
final String? title;
|
||||
final Widget? child;
|
||||
final Widget? bottomAction;
|
||||
final bool isScrollControlled;
|
||||
|
||||
/// مساعد للاستدعاء بأمر واحد
|
||||
static Future<R?> show<R>(
|
||||
BuildContext context, {
|
||||
String? title,
|
||||
Widget? child,
|
||||
Widget? bottomAction,
|
||||
bool isScrollControlled = true,
|
||||
}) {
|
||||
return showModalBottomSheet<R>(
|
||||
context: context,
|
||||
isScrollControlled: isScrollControlled,
|
||||
useSafeArea: true,
|
||||
builder: (_) => TripzSheet(
|
||||
title: title,
|
||||
bottomAction: bottomAction,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final bottomPadding = MediaQuery.of(context).viewInsets.bottom;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(0, 0, 0, bottomPadding),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: T.pagePadding),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (title != null) ...[
|
||||
Text(
|
||||
title!,
|
||||
style: theme.textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: T.s16),
|
||||
],
|
||||
if (child != null) child!,
|
||||
if (bottomAction != null) ...[
|
||||
const SizedBox(height: T.s16),
|
||||
bottomAction!,
|
||||
const SizedBox(height: T.s8),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../design/tokens.dart';
|
||||
|
||||
/// حقل إدخال موحّد: هاتف · OTP · بحث — رسالة الخطأ تحته بالعربية.
|
||||
/// docs/26 §7
|
||||
class TripzTextField extends StatelessWidget {
|
||||
const TripzTextField({
|
||||
super.key,
|
||||
this.controller,
|
||||
this.label,
|
||||
this.hint,
|
||||
this.prefix,
|
||||
this.suffix,
|
||||
this.keyboardType,
|
||||
this.obscureText = false,
|
||||
this.errorText,
|
||||
this.enabled = true,
|
||||
this.onChanged,
|
||||
this.onSubmitted,
|
||||
this.autofocus = false,
|
||||
this.textAlign,
|
||||
this.maxLength,
|
||||
this.contentPadding,
|
||||
this.focusNode,
|
||||
this.textInputAction,
|
||||
});
|
||||
|
||||
final TextEditingController? controller;
|
||||
final String? label;
|
||||
final String? hint;
|
||||
final Widget? prefix;
|
||||
final Widget? suffix;
|
||||
final TextInputType? keyboardType;
|
||||
final bool obscureText;
|
||||
final String? errorText;
|
||||
final bool enabled;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final ValueChanged<String>? onSubmitted;
|
||||
final bool autofocus;
|
||||
final TextAlign? textAlign;
|
||||
final int? maxLength;
|
||||
final EdgeInsetsGeometry? contentPadding;
|
||||
final FocusNode? focusNode;
|
||||
final TextInputAction? textInputAction;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: T.inputHeight,
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
keyboardType: keyboardType,
|
||||
textInputAction: textInputAction,
|
||||
obscureText: obscureText,
|
||||
enabled: enabled,
|
||||
onChanged: onChanged,
|
||||
onSubmitted: onSubmitted,
|
||||
autofocus: autofocus,
|
||||
textAlign: textAlign ?? TextAlign.start,
|
||||
maxLength: maxLength,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
hintText: hint,
|
||||
prefixIcon: prefix,
|
||||
suffixIcon: suffix,
|
||||
counterText: '',
|
||||
contentPadding: contentPadding ??
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (errorText != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
errorText!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: cs.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
class DialPlan {
|
||||
final String callingCode;
|
||||
final int nationalLength;
|
||||
|
||||
const DialPlan({required this.callingCode, required this.nationalLength});
|
||||
}
|
||||
|
||||
/// أداة تطبيع رقم الهاتف لصيغة دولية موحدة (مطابقة للباك إند PhoneService).
|
||||
class PhoneNormalizer {
|
||||
static const Map<String, DialPlan> dialPlans = {
|
||||
'JO': DialPlan(callingCode: '962', nationalLength: 9),
|
||||
'SY': DialPlan(callingCode: '963', nationalLength: 9),
|
||||
'EG': DialPlan(callingCode: '20', nationalLength: 10),
|
||||
'SA': DialPlan(callingCode: '966', nationalLength: 9),
|
||||
'AE': DialPlan(callingCode: '971', nationalLength: 9),
|
||||
'IQ': DialPlan(callingCode: '964', nationalLength: 10),
|
||||
};
|
||||
|
||||
/// يرجع الرقم في صيغة موحّدة (مثل 962791234567 أو +962791234567 حسب الحاجة).
|
||||
static String normalize(String rawNumber, {String isoCode = 'JO', bool withPlus = true}) {
|
||||
final countryKey = isoCode.toUpperCase();
|
||||
final plan = dialPlans[countryKey] ?? const DialPlan(callingCode: '962', nationalLength: 9);
|
||||
|
||||
// إزالة كل الرموز باستثناء الأرقام
|
||||
var digits = rawNumber.replaceAll(RegExp(r'\D'), '');
|
||||
if (digits.isEmpty) return rawNumber;
|
||||
|
||||
// إزالة البادئة الدولية "00" إن وجدت
|
||||
if (digits.startsWith('00')) {
|
||||
digits = digits.substring(2);
|
||||
}
|
||||
|
||||
String resultDigits = digits;
|
||||
|
||||
// 1) الرقم مكتوب بالفعل بمفتاح الدولة كلياً
|
||||
if (digits.startsWith(plan.callingCode) &&
|
||||
digits.length == plan.callingCode.length + plan.nationalLength) {
|
||||
resultDigits = digits;
|
||||
}
|
||||
// 2) رقم محلي يبدأ بصفر (مثل 0791234567)
|
||||
else if (digits.startsWith('0') && digits.length == plan.nationalLength + 1) {
|
||||
resultDigits = plan.callingCode + digits.substring(1);
|
||||
}
|
||||
// 3) رقم محلي محذوف منه الصفر البادئ (مثل 791234567)
|
||||
else if (digits.length == plan.nationalLength) {
|
||||
resultDigits = plan.callingCode + digits;
|
||||
}
|
||||
|
||||
return withPlus ? '+$resultDigits' : resultDigits;
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../data/auth_repository.dart';
|
||||
import 'auth_state.dart';
|
||||
|
||||
class AuthCubit extends Cubit<AuthState> {
|
||||
final AuthRepository _repo;
|
||||
|
||||
AuthCubit(this._repo) : super(const AuthState());
|
||||
|
||||
Future<void> checkAuth() async {
|
||||
final hasToken = await _repo.hasSavedToken();
|
||||
if (hasToken) {
|
||||
final cachedUser = await _repo.getCachedUser();
|
||||
if (cachedUser != null) {
|
||||
emit(state.copyWith(
|
||||
status: AuthStatus.authenticated,
|
||||
user: cachedUser,
|
||||
));
|
||||
}
|
||||
}
|
||||
final user = await _repo.me();
|
||||
if (user != null) {
|
||||
emit(state.copyWith(
|
||||
status: AuthStatus.authenticated,
|
||||
user: user,
|
||||
));
|
||||
} else {
|
||||
final hasTokenAfterMe = await _repo.hasSavedToken();
|
||||
if (!hasTokenAfterMe) {
|
||||
emit(state.copyWith(
|
||||
status: AuthStatus.unauthenticated,
|
||||
user: null,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Future<void> sendOtp(String phone) async {
|
||||
if (phone.trim().isEmpty) {
|
||||
emit(state.copyWith(status: AuthStatus.error, error: 'أدخل رقم الهاتف'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
emit(state.copyWith(status: AuthStatus.authenticating, phone: phone.trim()));
|
||||
await _repo.sendOtp(phone.trim());
|
||||
emit(state.copyWith(status: AuthStatus.codeSent, phone: phone.trim()));
|
||||
} catch (_) {
|
||||
emit(state.copyWith(status: AuthStatus.error, error: 'تعذّر إرسال الرمز'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> verify(String code) async {
|
||||
try {
|
||||
emit(state.copyWith(status: AuthStatus.authenticating));
|
||||
final user = await _repo.verify(state.phone, code.trim());
|
||||
emit(state.copyWith(status: AuthStatus.authenticated, user: user));
|
||||
} catch (_) {
|
||||
emit(state.copyWith(status: AuthStatus.error, error: 'رمز غير صحيح'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await _repo.logout();
|
||||
emit(const AuthState(status: AuthStatus.unauthenticated));
|
||||
}
|
||||
|
||||
Future<void> updateName(String name) async {
|
||||
try {
|
||||
emit(state.copyWith(status: AuthStatus.authenticating));
|
||||
final user = await _repo.updateName(name);
|
||||
emit(state.copyWith(status: AuthStatus.authenticated, user: user));
|
||||
} catch (_) {
|
||||
emit(state.copyWith(status: AuthStatus.error, error: 'تعذّر حفظ الاسم'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../data/auth_repository.dart';
|
||||
|
||||
enum AuthStatus {
|
||||
unknown,
|
||||
unauthenticated,
|
||||
codeSent,
|
||||
authenticating,
|
||||
authenticated,
|
||||
error,
|
||||
}
|
||||
|
||||
class AuthState extends Equatable {
|
||||
final AuthStatus status;
|
||||
final String phone;
|
||||
final AppUser? user;
|
||||
final String? error;
|
||||
|
||||
const AuthState({
|
||||
this.status = AuthStatus.unknown,
|
||||
this.phone = '',
|
||||
this.user,
|
||||
this.error,
|
||||
});
|
||||
|
||||
AuthState copyWith({
|
||||
AuthStatus? status,
|
||||
String? phone,
|
||||
AppUser? user,
|
||||
String? error,
|
||||
}) {
|
||||
return AuthState(
|
||||
status: status ?? this.status,
|
||||
phone: phone ?? this.phone,
|
||||
user: user ?? this.user,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [status, phone, user, error];
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../../../core/api/api_client.dart';
|
||||
import '../../../core/storage/token_store.dart';
|
||||
|
||||
class AppUser {
|
||||
final String id;
|
||||
final String phone;
|
||||
final String? name;
|
||||
final String role;
|
||||
|
||||
AppUser({required this.id, required this.phone, this.name, required this.role});
|
||||
|
||||
factory AppUser.fromJson(Map<String, dynamic> j) => AppUser(
|
||||
id: j['id'] as String,
|
||||
phone: (j['phone'] ?? '') as String,
|
||||
name: j['name'] as String?,
|
||||
role: (j['role'] ?? 'rider') as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'phone': phone,
|
||||
'name': name,
|
||||
'role': role,
|
||||
};
|
||||
}
|
||||
|
||||
class AuthRepository {
|
||||
final ApiClient _api;
|
||||
final TokenStore _store;
|
||||
|
||||
AuthRepository(this._api, this._store);
|
||||
|
||||
Future<void> sendOtp(String phone) async {
|
||||
await _api.dio.post('/auth/send-otp', data: {'phone': phone});
|
||||
}
|
||||
|
||||
Future<AppUser> verify(String phone, String code) async {
|
||||
final r = await _api.dio.post(
|
||||
'/auth/verify-otp',
|
||||
data: {'phone': phone, 'code': code},
|
||||
);
|
||||
await _store.save(
|
||||
r.data['access_token'] as String,
|
||||
r.data['refresh_token'] as String,
|
||||
);
|
||||
final user = AppUser.fromJson(Map<String, dynamic>.from(r.data['user'] as Map));
|
||||
await _store.saveUser(jsonEncode(user.toJson()));
|
||||
return user;
|
||||
}
|
||||
|
||||
Future<AppUser?> getCachedUser() async {
|
||||
final raw = await _store.readUser();
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
try {
|
||||
final map = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return AppUser.fromJson(map);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> hasSavedToken() => _store.hasToken();
|
||||
|
||||
Future<AppUser?> me() async {
|
||||
try {
|
||||
final r = await _api.dio.get('/users/me');
|
||||
final user = AppUser.fromJson(Map<String, dynamic>.from(r.data as Map));
|
||||
await _store.saveUser(jsonEncode(user.toJson()));
|
||||
return user;
|
||||
} on DioException {
|
||||
return getCachedUser();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() => _store.clear();
|
||||
|
||||
Future<AppUser> updateName(String name) async {
|
||||
final r = await _api.dio.patch('/users/me', data: {'name': name});
|
||||
final user = AppUser.fromJson(Map<String, dynamic>.from(r.data as Map));
|
||||
await _store.saveUser(jsonEncode(user.toJson()));
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/l10n/app_localizations.dart';
|
||||
import '../../../core/design/tokens.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';
|
||||
|
||||
/// شاشة إدخال الاسم (أول دخول فقط) — تطابق stitch/name_entry.html:
|
||||
/// شعار صغير بالمنتصف أعلى الشاشة + عنوان وسطر فرعي + بطاقة بيضاء
|
||||
/// مرفوعة فيها حقل الاسم بأيقونة، وزر متابعة أسفل الشاشة.
|
||||
///
|
||||
/// ملاحظة: تصميم Stitch يعرض 3 حقول (الاسم الأول/الأخير/البريد)، لكن
|
||||
/// AuthCubit.updateName ينادي PATCH /users/me بحقل name واحد فقط
|
||||
/// (docs/29 §3.5 ينص صراحة على «حقل واحد»). الشاشة هنا تستعمل حقلاً
|
||||
/// واحداً للاسم الكامل بنفس لغة Stitch البصرية (بطاقة مرفوعة + أيقونة
|
||||
/// + label عائم) دون تغيير عقد الـCubit.
|
||||
class NameScreen extends StatefulWidget {
|
||||
const NameScreen({super.key});
|
||||
@override
|
||||
State<NameScreen> createState() => _NameScreenState();
|
||||
}
|
||||
|
||||
class _NameScreenState extends State<NameScreen> {
|
||||
final _firstNameController = TextEditingController();
|
||||
final _lastNameController = TextEditingController();
|
||||
bool _valid = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_firstNameController.addListener(_validate);
|
||||
_lastNameController.addListener(_validate);
|
||||
}
|
||||
|
||||
void _validate() {
|
||||
final isValid = _firstNameController.text.trim().isNotEmpty &&
|
||||
_lastNameController.text.trim().isNotEmpty;
|
||||
if (_valid != isValid) {
|
||||
setState(() => _valid = isValid);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_firstNameController.dispose();
|
||||
_lastNameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
if (!_valid) return;
|
||||
final fullName =
|
||||
'${_firstNameController.text.trim()} ${_lastNameController.text.trim()}';
|
||||
context.read<AuthCubit>().updateName(fullName);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final theme = Theme.of(context);
|
||||
final cs = theme.colorScheme;
|
||||
|
||||
return 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 isLoading = state.status == AuthStatus.authenticating;
|
||||
return TripzScaffold(
|
||||
backgroundColor: cs.surfaceContainerLow,
|
||||
padding: false,
|
||||
bottomAction: TripzButton(
|
||||
onPressed: (_valid && !isLoading) ? _submit : null,
|
||||
label: l10n.nameButton,
|
||||
loading: isLoading,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// ── رأس الشاشة: زر عودة (يبدأ الجلسة من جديد) + شعار مصغّر ──
|
||||
SizedBox(
|
||||
height: T.s48,
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const BackButtonIcon(),
|
||||
color: cs.onSurface,
|
||||
tooltip: l10n.back,
|
||||
onPressed: () => context.read<AuthCubit>().logout(),
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Image.asset(
|
||||
'assets/images/name_hero.png',
|
||||
height: T.iconLg,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) => Icon(
|
||||
Icons.directions_car_filled_rounded,
|
||||
size: T.iconLg,
|
||||
color: cs.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: T.s48),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: T.s24),
|
||||
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: T.pagePadding),
|
||||
child: Column(
|
||||
children: [
|
||||
// العنوان والسطر الفرعي
|
||||
Text(
|
||||
l10n.nameTitle,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: T.s8),
|
||||
Text(
|
||||
l10n.nameSubtitle,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: T.s24),
|
||||
|
||||
// بطاقة بيضاء مرفوعة تحوي حقول الاسم الأول واسم العائلة
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(T.s24),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerLowest,
|
||||
borderRadius: BorderRadius.circular(T.r16),
|
||||
boxShadow: const [T.shadowSm],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
TripzTextField(
|
||||
controller: _firstNameController,
|
||||
label: l10n.firstNameHint,
|
||||
autofocus: true,
|
||||
prefix: Icon(Icons.person_outline_rounded, color: cs.outline),
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
const SizedBox(height: T.s16),
|
||||
TripzTextField(
|
||||
controller: _lastNameController,
|
||||
label: l10n.lastNameHint,
|
||||
prefix: Icon(Icons.badge_outlined, color: cs.outline),
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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/design/tokens.dart';
|
||||
import '../../../core/ui/tripz_scaffold.dart';
|
||||
import '../../../core/ui/tripz_button.dart';
|
||||
|
||||
/// شاشة تحقق OTP — تطابق stitch/otp_verification.html:
|
||||
/// شريط علوي بلون primary + 4 خانات منفصلة (ترتيب LTR ثابت) + عدّاد
|
||||
/// إعادة إرسال + تأكيد تلقائي عند اكتمال الخانات.
|
||||
class OtpScreen extends StatefulWidget {
|
||||
const OtpScreen({super.key});
|
||||
@override
|
||||
State<OtpScreen> createState() => _OtpScreenState();
|
||||
}
|
||||
|
||||
class _OtpScreenState extends State<OtpScreen> {
|
||||
static const _otpLength = 4;
|
||||
final List<TextEditingController> _controllers =
|
||||
List.generate(_otpLength, (_) => TextEditingController());
|
||||
final List<FocusNode> _focusNodes =
|
||||
List.generate(_otpLength, (_) => FocusNode());
|
||||
int _resendSeconds = 60;
|
||||
Timer? _resendTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startResendTimer();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_focusNodes[0].requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_resendTimer?.cancel();
|
||||
for (final c in _controllers) {
|
||||
c.dispose();
|
||||
}
|
||||
for (final f in _focusNodes) {
|
||||
f.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startResendTimer() {
|
||||
_resendSeconds = 60;
|
||||
_resendTimer?.cancel();
|
||||
_resendTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (_resendSeconds <= 0) {
|
||||
timer.cancel();
|
||||
} else {
|
||||
setState(() => _resendSeconds--);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
String get _otpCode => _controllers.map((c) => c.text).join();
|
||||
|
||||
bool get _isComplete => _otpCode.length == _otpLength;
|
||||
|
||||
void _onDigitChanged(int index, String value) {
|
||||
if (value.length > 1) {
|
||||
// لصق تلقائي: يوزّع الأرقام الملصوقة على الخانات بالترتيب
|
||||
final digits = value.replaceAll(RegExp(r'[^0-9]'), '');
|
||||
for (int i = 0; i < _otpLength && i < digits.length; i++) {
|
||||
_controllers[i].text = digits[i];
|
||||
}
|
||||
final nextIdx = digits.length.clamp(0, _otpLength - 1);
|
||||
_focusNodes[nextIdx].requestFocus();
|
||||
} else if (value.isNotEmpty) {
|
||||
if (index < _otpLength - 1) {
|
||||
_focusNodes[index + 1].requestFocus();
|
||||
} else {
|
||||
_focusNodes[index].unfocus();
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {});
|
||||
|
||||
if (_isComplete) {
|
||||
_submit();
|
||||
}
|
||||
}
|
||||
|
||||
void _onKeyPress(int index, KeyEvent event) {
|
||||
if (event is KeyDownEvent &&
|
||||
event.logicalKey == LogicalKeyboardKey.backspace &&
|
||||
_controllers[index].text.isEmpty &&
|
||||
index > 0) {
|
||||
_controllers[index - 1].clear();
|
||||
_focusNodes[index - 1].requestFocus();
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
if (!_isComplete) return;
|
||||
context.read<AuthCubit>().verify(_otpCode);
|
||||
}
|
||||
|
||||
void _resend() {
|
||||
final phone = context.read<AuthCubit>().state.phone;
|
||||
context.read<AuthCubit>().sendOtp(phone);
|
||||
_startResendTimer();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final theme = Theme.of(context);
|
||||
final cs = theme.colorScheme;
|
||||
final phone = context.watch<AuthCubit>().state.phone;
|
||||
|
||||
return 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 isLoading = state.status == AuthStatus.authenticating;
|
||||
|
||||
return TripzScaffold(
|
||||
padding: false,
|
||||
safeAreaTop: false,
|
||||
bottomAction: TripzButton(
|
||||
onPressed: (_isComplete && !isLoading) ? _submit : null,
|
||||
label: l10n.confirm,
|
||||
loading: isLoading,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// ── شريط علوي بلون primary (يطابق TopAppBar في stitch) ──
|
||||
SafeArea(
|
||||
bottom: false,
|
||||
child: SizedBox(
|
||||
height: kToolbarHeight,
|
||||
child: ColoredBox(
|
||||
color: cs.primary,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: T.s8),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const BackButtonIcon(),
|
||||
color: cs.onPrimary,
|
||||
onPressed: () => context.go('/phone'),
|
||||
),
|
||||
const SizedBox(width: T.s8),
|
||||
Text(
|
||||
l10n.confirmPhoneNumber,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
color: cs.onPrimary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: T.pagePadding),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: T.s24),
|
||||
|
||||
// نص الشرح + تعديل الرقم
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.otpSentTo(phone),
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.go('/phone'),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: T.s8),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: Text(
|
||||
l10n.edit,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: cs.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: T.s32),
|
||||
|
||||
// ── خانات الرمز — ترتيب LTR ثابت (يطابق dir="ltr" في stitch) ──
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(_otpLength, (i) {
|
||||
final hasFocus = _focusNodes[i].hasFocus;
|
||||
final hasValue = _controllers[i].text.isNotEmpty;
|
||||
return Padding(
|
||||
padding: EdgeInsetsDirectional.only(
|
||||
end: i < _otpLength - 1 ? T.s16 : 0,
|
||||
),
|
||||
child: Container(
|
||||
width: 60,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(T.r12),
|
||||
boxShadow: (hasFocus || hasValue) ? const [T.shadowSm] : null,
|
||||
),
|
||||
child: KeyboardListener(
|
||||
focusNode: FocusNode(),
|
||||
onKeyEvent: (event) => _onKeyPress(i, event),
|
||||
child: TextField(
|
||||
controller: _controllers[i],
|
||||
focusNode: _focusNodes[i],
|
||||
textAlign: TextAlign.center,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 1,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: cs.onSurface,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
counterText: '',
|
||||
filled: true,
|
||||
fillColor: cs.surfaceContainerLowest,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 14),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(T.r12),
|
||||
borderSide: BorderSide(color: cs.outlineVariant),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(T.r12),
|
||||
borderSide: BorderSide(
|
||||
color: hasValue ? cs.primary : cs.outlineVariant,
|
||||
width: hasValue ? 1.5 : 1,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(T.r12),
|
||||
borderSide: BorderSide(color: cs.primary, width: 2),
|
||||
),
|
||||
),
|
||||
onChanged: (v) => _onDigitChanged(i, v),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: T.s24),
|
||||
|
||||
// عدّاد إعادة الإرسال
|
||||
Center(
|
||||
child: _resendSeconds > 0
|
||||
? Text(
|
||||
l10n.resendIn(_resendSeconds),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
)
|
||||
: TripzButton(
|
||||
onPressed: _resend,
|
||||
style: TripzButtonStyle.text,
|
||||
label: l10n.resendCode,
|
||||
fullWidth: false,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/l10n/app_localizations.dart';
|
||||
import '../../../core/build_config.dart';
|
||||
import '../cubit/auth_cubit.dart';
|
||||
import '../cubit/auth_state.dart';
|
||||
import '../../../core/design/tokens.dart';
|
||||
import '../../../core/utils/phone_normalizer.dart';
|
||||
import '../../../core/ui/phone_intel/intl_phone_field.dart';
|
||||
import '../../../core/ui/phone_intel/phone_number.dart';
|
||||
import '../../../core/ui/tripz_scaffold.dart';
|
||||
import '../../../core/ui/tripz_button.dart';
|
||||
|
||||
/// شاشة إدخال الهاتف — تطابق stitch/phone_login.html:
|
||||
/// خلفية فاتحة + شعار بالمنتصف بالأعلى + sheet أبيض سفلي بحواف مدورة
|
||||
/// فيه حقل الهاتف (علم + كود دولة + رقم) وزر إرسال الكود.
|
||||
class PhoneScreen extends StatefulWidget {
|
||||
const PhoneScreen({super.key});
|
||||
@override
|
||||
State<PhoneScreen> createState() => _PhoneScreenState();
|
||||
}
|
||||
|
||||
class _PhoneScreenState extends State<PhoneScreen> {
|
||||
PhoneNumber? _phoneNumber;
|
||||
bool _valid = false;
|
||||
|
||||
void _sendOtp(BuildContext context) {
|
||||
if (_phoneNumber == null) return;
|
||||
final normalized = PhoneNormalizer.normalize(
|
||||
_phoneNumber!.number,
|
||||
isoCode: _phoneNumber!.countryISOCode,
|
||||
);
|
||||
context.read<AuthCubit>().sendOtp(normalized);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final theme = Theme.of(context);
|
||||
final cs = theme.colorScheme;
|
||||
|
||||
return 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 isLoading = state.status == AuthStatus.authenticating;
|
||||
|
||||
return TripzScaffold(
|
||||
backgroundColor: cs.surfaceContainerLow,
|
||||
padding: false,
|
||||
safeAreaBottom: false,
|
||||
bottomAction: TripzButton(
|
||||
onPressed: (_valid && !isLoading) ? () => _sendOtp(context) : null,
|
||||
label: l10n.sendCode,
|
||||
loading: isLoading,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// ── منطقة الشعار (النصف العلوي) ──
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: T.s32),
|
||||
child: Image.asset(
|
||||
'assets/images/login_hero.png',
|
||||
width: 200,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) => Icon(
|
||||
Icons.directions_car_filled_rounded,
|
||||
size: 96,
|
||||
color: cs.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── بطاقة الإدخال السفلية (منطقة الإبهام) ──
|
||||
Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerLowest,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(T.r24)),
|
||||
boxShadow: const [T.shadowLg],
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(T.pagePadding, T.s16, T.pagePadding, T.s32),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// مقبض السحب الزخرفي
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(T.rCircle),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: T.s24),
|
||||
|
||||
// العنوان
|
||||
Text(
|
||||
l10n.loginTitle,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: T.s8),
|
||||
Text(
|
||||
l10n.phoneLoginSubtitle,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: T.s24),
|
||||
|
||||
// حقل الهاتف — علم + كود الدولة (افتراضي BuildConfig.defaultCountry) + الرقم
|
||||
IntlPhoneField(
|
||||
initialCountryCode: BuildConfig.defaultCountry,
|
||||
languageCode: Localizations.localeOf(context).languageCode,
|
||||
textAlign: TextAlign.left,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
dropdownTextStyle: theme.textTheme.bodyLarge,
|
||||
flagsButtonPadding: const EdgeInsets.symmetric(horizontal: T.s12),
|
||||
dropdownDecoration: BoxDecoration(
|
||||
border: BorderDirectional(
|
||||
end: BorderSide(color: cs.outlineVariant),
|
||||
),
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.phoneHint,
|
||||
filled: true,
|
||||
fillColor: cs.surfaceContainerLowest,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(T.r8),
|
||||
borderSide: BorderSide(color: cs.outlineVariant),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(T.r8),
|
||||
borderSide: BorderSide(color: cs.outlineVariant),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(T.r8),
|
||||
borderSide: BorderSide(color: cs.primary, width: 1.5),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: T.s16, vertical: T.s16),
|
||||
),
|
||||
onChanged: (phone) {
|
||||
_phoneNumber = phone;
|
||||
final num = phone.number;
|
||||
_valid = num.isNotEmpty && num.length >= 7;
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter/foundation.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.search(query);
|
||||
emit(state.copyWith(results: results, searching: false));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(
|
||||
searching: false, error: e.toString(), clearError: false));
|
||||
}
|
||||
}
|
||||
|
||||
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.search(query);
|
||||
emit(state.copyWith(results: results, searching: false));
|
||||
} catch (e, st) {
|
||||
debugPrint('Map Search Error: $e\n$st');
|
||||
emit(state.copyWith(
|
||||
searching: false, error: e.toString(), clearError: false));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> searchVoiceDirectly(String query,
|
||||
{double? lat, double? lng}) async {
|
||||
_debounce?.cancel();
|
||||
if (query.trim().isEmpty) return;
|
||||
emit(state.copyWith(searching: true, clearError: true));
|
||||
try {
|
||||
final results = await _mapsRepo.voiceSearch(query, lat: lat, lng: lng);
|
||||
emit(state.copyWith(results: results, searching: false));
|
||||
} catch (e, st) {
|
||||
debugPrint('Map Voice Search Error: $e\n$st');
|
||||
emit(state.copyWith(
|
||||
searching: false, error: e.toString(), clearError: false));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_debounce?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
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);
|
||||
|
||||
// نوسّط الكاميرا على الموقع فوراً — لا ننتظر العنوان العكسي.
|
||||
emit(state.copyWith(
|
||||
loading: false,
|
||||
currentLocation: loc,
|
||||
cameraTarget: loc,
|
||||
));
|
||||
|
||||
// العنوان العكسي تحسين إضافي: فشله يجب ألّا يمنع توسيط الخريطة.
|
||||
try {
|
||||
final place = await _mapsRepo.reverseGeocode(pos.latitude, pos.longitude);
|
||||
emit(state.copyWith(pickup: place));
|
||||
} catch (_) {
|
||||
emit(state.copyWith(
|
||||
pickup: PlaceModel(name: '', lat: loc.latitude, lng: loc.longitude),
|
||||
));
|
||||
}
|
||||
} 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));
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../core/storage/saved_places_store.dart';
|
||||
|
||||
class SavedPlacesState {
|
||||
final List<SavedPlace> places;
|
||||
final bool loading;
|
||||
final String? error;
|
||||
|
||||
const SavedPlacesState({
|
||||
this.places = const [],
|
||||
this.loading = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
SavedPlacesState copyWith({
|
||||
List<SavedPlace>? places,
|
||||
bool? loading,
|
||||
String? error,
|
||||
}) {
|
||||
return SavedPlacesState(
|
||||
places: places ?? this.places,
|
||||
loading: loading ?? this.loading,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SavedPlacesCubit extends Cubit<SavedPlacesState> {
|
||||
final SavedPlacesStore _store;
|
||||
|
||||
SavedPlacesCubit(this._store) : super(const SavedPlacesState());
|
||||
|
||||
Future<void> load() async {
|
||||
emit(state.copyWith(loading: true, error: null));
|
||||
try {
|
||||
final places = await _store.getAll();
|
||||
emit(SavedPlacesState(places: places));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(loading: false, error: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> addPlace(SavedPlace place) async {
|
||||
try {
|
||||
await _store.add(place);
|
||||
await load();
|
||||
} catch (e) {
|
||||
emit(state.copyWith(error: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> removePlace(String id) async {
|
||||
try {
|
||||
await _store.remove(id);
|
||||
await load();
|
||||
} catch (e) {
|
||||
emit(state.copyWith(error: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setFavorite(String id, FavoriteType type) async {
|
||||
try {
|
||||
await _store.setFavorite(id, type);
|
||||
await load();
|
||||
} catch (e) {
|
||||
emit(state.copyWith(error: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearFavorite(FavoriteType type) async {
|
||||
try {
|
||||
await _store.clearFavorite(type);
|
||||
await load();
|
||||
} catch (e) {
|
||||
emit(state.copyWith(error: e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import '../../../core/api/antlaq_api.dart';
|
||||
import '../../../core/api/api_client.dart';
|
||||
import 'models/place_model.dart';
|
||||
import 'models/route_model.dart';
|
||||
|
||||
/// نقطة وصول وحيدة لبيانات الخرائط والبحث الصوتي الذكي عبر الباك إند.
|
||||
class MapsRepository {
|
||||
final AntlaqApi _api;
|
||||
final ApiClient? _apiClient;
|
||||
|
||||
MapsRepository(this._api, [this._apiClient]);
|
||||
|
||||
/// بحث صوتي ذكي يمر عبر الباك إند (Gemini AI).
|
||||
Future<List<PlaceModel>> voiceSearch(String text, {double? lat, double? lng}) async {
|
||||
if (text.trim().isEmpty) return [];
|
||||
try {
|
||||
if (_apiClient != null) {
|
||||
final res = await _apiClient.dio.post('/maps/voice-search', data: {
|
||||
'text': text,
|
||||
if (lat != null) 'lat': lat,
|
||||
if (lng != null) 'lng': lng,
|
||||
});
|
||||
final list = res.data as List?;
|
||||
if (list != null) {
|
||||
return list
|
||||
.map((e) => PlaceModel.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return search(text);
|
||||
}
|
||||
|
||||
/// بحث أماكن — يمر عبر GET /api/geocoding/search.
|
||||
Future<List<PlaceModel>> search(String query) async {
|
||||
if (query.trim().length < 2) return [];
|
||||
final items = await _api.search(query);
|
||||
return items
|
||||
.map((e) => PlaceModel.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// تفكيك عنوان عكسي (إحداثيات → نص).
|
||||
Future<PlaceModel> reverseGeocode(double lat, double lng) async {
|
||||
final j = await _api.reverse(lat, lng);
|
||||
final name = (j['name_ar'] ?? j['name'] ?? j['formattedAddress'] ?? '') as String;
|
||||
final addr = (j['address'] ?? j['full_address'] ?? j['formattedAddress'] ?? '') as String;
|
||||
return PlaceModel.fromJson({
|
||||
'name': name,
|
||||
'full_address': addr.isNotEmpty ? addr : name,
|
||||
'latitude': lat,
|
||||
'longitude': lng,
|
||||
});
|
||||
}
|
||||
|
||||
/// حساب المسار بين نقطتين عبر الباك إند (GET /maps/route).
|
||||
Future<RouteModel> getRoute({
|
||||
required double fromLat,
|
||||
required double fromLng,
|
||||
required double toLat,
|
||||
required double toLng,
|
||||
}) async {
|
||||
try {
|
||||
if (_apiClient != null) {
|
||||
final res = await _apiClient.dio.get('/maps/route', queryParameters: {
|
||||
'fromLat': fromLat,
|
||||
'fromLng': fromLng,
|
||||
'toLat': toLat,
|
||||
'toLng': toLng,
|
||||
});
|
||||
if (res.data != null && res.data is Map<String, dynamic>) {
|
||||
return RouteModel.fromJson(res.data as Map<String, dynamic>);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
final j = await _api.route(
|
||||
fromLat: fromLat,
|
||||
fromLng: fromLng,
|
||||
toLat: toLat,
|
||||
toLng: toLng,
|
||||
);
|
||||
return RouteModel.fromJson(j);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:intaleq_maps/intaleq_maps.dart';
|
||||
|
||||
/// باب/مدخل مكان — من استجابة انطلق.
|
||||
class Gate extends Equatable {
|
||||
final String nameAr;
|
||||
final String nameEn;
|
||||
final double lat;
|
||||
final double lng;
|
||||
final bool isMainGate;
|
||||
|
||||
const Gate({
|
||||
required this.nameAr,
|
||||
required this.nameEn,
|
||||
required this.lat,
|
||||
required this.lng,
|
||||
this.isMainGate = false,
|
||||
});
|
||||
|
||||
factory Gate.fromJson(Map<String, dynamic> j) => Gate(
|
||||
nameAr: (j['name_ar'] ?? '') as String,
|
||||
nameEn: (j['name_en'] ?? '') as String,
|
||||
lat: (j['latitude'] as num).toDouble(),
|
||||
lng: (j['longitude'] as num).toDouble(),
|
||||
isMainGate: j['is_main_gate'] as bool? ?? false,
|
||||
);
|
||||
|
||||
LatLng get latLng => LatLng(lat, lng);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [nameAr, nameEn, lat, lng, isMainGate];
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'gate_model.dart';
|
||||
|
||||
/// نموذج مكان من نتائج بحث انطلق (geocode / search).
|
||||
class PlaceModel extends Equatable {
|
||||
final String? placeId;
|
||||
final String name;
|
||||
final String? address;
|
||||
final double lat;
|
||||
final double lng;
|
||||
final String? category;
|
||||
final double? totalScore;
|
||||
final String? source;
|
||||
final List<Gate> gates;
|
||||
|
||||
const PlaceModel({
|
||||
this.placeId,
|
||||
required this.name,
|
||||
this.address,
|
||||
required this.lat,
|
||||
required this.lng,
|
||||
this.category,
|
||||
this.totalScore,
|
||||
this.source,
|
||||
this.gates = const [],
|
||||
});
|
||||
|
||||
/// استجابة انطلق الحقيقية — الحقول الم标准.
|
||||
factory PlaceModel.fromJson(Map<String, dynamic> j) {
|
||||
final lat = (j['latitude'] ?? j['location']?['lat'] ?? 0) as num;
|
||||
final lng = (j['longitude'] ?? j['location']?['lng'] ?? 0) as num;
|
||||
return PlaceModel(
|
||||
placeId: j['id'] as String?,
|
||||
name: (j['name'] ?? '') as String,
|
||||
address: (j['full_address'] ?? j['address']) as String?,
|
||||
lat: lat.toDouble(),
|
||||
lng: lng.toDouble(),
|
||||
category: j['category'] as String?,
|
||||
totalScore: (j['totalScore'] as num?)?.toDouble(),
|
||||
source: j['source'] as String?,
|
||||
gates: (j['gates'] as List?)
|
||||
?.map((g) => Gate.fromJson(g as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const [],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
if (placeId != null) 'id': placeId,
|
||||
'name': name,
|
||||
if (address != null) 'full_address': address,
|
||||
'latitude': lat,
|
||||
'longitude': lng,
|
||||
if (category != null) 'category': category,
|
||||
if (totalScore != null) 'totalScore': totalScore,
|
||||
if (source != null) 'source': source,
|
||||
};
|
||||
|
||||
/// هل لهذا المكان أبواب متعددة?
|
||||
bool get hasMultipleGates => gates.length > 1;
|
||||
|
||||
/// الباب الرئيسي إن وُجد.
|
||||
Gate? get mainGate => gates.cast<Gate?>().firstWhere(
|
||||
(g) => g!.isMainGate,
|
||||
orElse: () => null,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props =>
|
||||
[placeId, name, address, lat, lng, category, totalScore, source, gates];
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
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];
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
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];
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
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;
|
||||
final String? routeName;
|
||||
final String? startName;
|
||||
final String? endName;
|
||||
final List<String>? tags;
|
||||
final List<double>? bbox;
|
||||
final double? trafficFactor;
|
||||
|
||||
const RouteModel({
|
||||
required this.distanceMeters,
|
||||
required this.durationSeconds,
|
||||
this.polyline,
|
||||
this.routeName,
|
||||
this.startName,
|
||||
this.endName,
|
||||
this.tags,
|
||||
this.bbox,
|
||||
this.trafficFactor,
|
||||
});
|
||||
|
||||
/// استجابة مسار انطلق الحقيقية (تُمرر عبر الباك إند كما هي).
|
||||
factory RouteModel.fromJson(Map<String, dynamic> j) {
|
||||
final dist = (j['distance'] ?? 0) as num;
|
||||
final dur = (j['duration'] ?? 0) as num;
|
||||
return RouteModel(
|
||||
distanceMeters: dist.toInt(),
|
||||
durationSeconds: dur.toInt(),
|
||||
polyline: j['points'] as String?,
|
||||
routeName: j['routeName'] as String?,
|
||||
startName: j['startName'] as String?,
|
||||
endName: j['endName'] as String?,
|
||||
tags: (j['tags'] as List?)?.cast<String>(),
|
||||
bbox: (j['bbox'] as List?)?.map((e) => (e as num).toDouble()).toList(),
|
||||
trafficFactor: (j['trafficFactor'] as num?)?.toDouble(),
|
||||
);
|
||||
}
|
||||
|
||||
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' : ''}';
|
||||
}
|
||||
|
||||
/// تحويل bbox إلى LatLngBounds لضبط الكاميرا مع حماية للمواقع القريبة على iOS.
|
||||
LatLngBounds? getLatLngBounds() {
|
||||
if (bbox == null || bbox!.length < 4) return null;
|
||||
var minLat = bbox![1];
|
||||
var minLng = bbox![0];
|
||||
var maxLat = bbox![3];
|
||||
var maxLng = bbox![2];
|
||||
|
||||
// إذا كانت المسافة بين النقاط قريبة جداً (أقل من 0.002 درجة)، نضمن توسعاً أمنياً أدنى لمنع عطل وقفل كاميرا MapLibre على iOS
|
||||
if ((maxLat - minLat).abs() < 0.002) {
|
||||
minLat -= 0.001;
|
||||
maxLat += 0.001;
|
||||
}
|
||||
if ((maxLng - minLng).abs() < 0.002) {
|
||||
minLng -= 0.001;
|
||||
maxLng += 0.001;
|
||||
}
|
||||
|
||||
return LatLngBounds(
|
||||
southwest: LatLng(minLat, minLng),
|
||||
northeast: LatLng(maxLat, maxLng),
|
||||
);
|
||||
}
|
||||
|
||||
/// فك الـ polyline عبر PolylineUtils من الباكيج.
|
||||
List<LatLng> decodePolyline() {
|
||||
if (polyline == null || polyline!.isEmpty) return [];
|
||||
return PolylineUtils.decode(polyline!);
|
||||
}
|
||||
|
||||
/// هل المسار مباشر (لا أضلاع)?
|
||||
bool get isStraightLine => tags?.contains('straight-line') ?? false;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
distanceMeters,
|
||||
durationSeconds,
|
||||
polyline,
|
||||
routeName,
|
||||
startName,
|
||||
endName,
|
||||
tags,
|
||||
bbox,
|
||||
trafficFactor,
|
||||
];
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
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];
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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>);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,528 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intaleq_maps/intaleq_maps.dart';
|
||||
import '../../../core/l10n/app_localizations.dart';
|
||||
import '../../../core/design/tokens.dart';
|
||||
import '../../auth/cubit/auth_cubit.dart';
|
||||
import '../../auth/cubit/auth_state.dart';
|
||||
import '../../trip/bloc/trip_bloc.dart';
|
||||
import '../../trip/bloc/trip_state.dart';
|
||||
import '../../trip/bloc/trip_event.dart';
|
||||
import '../cubit/home_cubit.dart';
|
||||
import '../cubit/saved_places_cubit.dart';
|
||||
import '../data/models/place_model.dart';
|
||||
import '../data/maps_repository.dart';
|
||||
import '../../../core/storage/saved_places_store.dart';
|
||||
import '../../../core/di.dart';
|
||||
|
||||
/// زر عائم دائري صغير — للقائمة وإعادة التوسيط فوق الخريطة.
|
||||
class _MapCircleButton extends StatelessWidget {
|
||||
const _MapCircleButton({required this.icon, required this.onPressed});
|
||||
|
||||
final IconData icon;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surface,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: const [T.shadowSm],
|
||||
),
|
||||
child: IconButton(
|
||||
icon: Icon(icon, color: cs.onSurface),
|
||||
onPressed: onPressed,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
IntaleqMapController? _mapController;
|
||||
bool _isSelectingOnMap = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<TripBloc>().add(const TripCheckActive());
|
||||
context.read<SavedPlacesCubit>().load();
|
||||
});
|
||||
}
|
||||
|
||||
String _timeBasedGreeting(String? name, AppLocalizations l10n) {
|
||||
final hour = DateTime.now().hour;
|
||||
final isMorning = hour >= 5 && hour < 12;
|
||||
final isAr = Localizations.localeOf(context).languageCode == 'ar';
|
||||
final prefix = isMorning
|
||||
? (isAr ? 'صباح الخير' : 'Good morning')
|
||||
: (isAr ? 'مساء الخير' : 'Good evening');
|
||||
|
||||
final firstName = (name != null && name.trim().isNotEmpty)
|
||||
? name.trim().split(' ').first
|
||||
: '';
|
||||
return firstName.isNotEmpty ? '$prefix، $firstName' : prefix;
|
||||
}
|
||||
|
||||
String _whereToPrompt(String? name, AppLocalizations l10n) {
|
||||
final firstName = (name != null && name.trim().isNotEmpty)
|
||||
? name.trim().split(' ').first
|
||||
: '';
|
||||
if (firstName.isNotEmpty) {
|
||||
final isAr = Localizations.localeOf(context).languageCode == 'ar';
|
||||
return isAr ? 'إلى أين يا $firstName؟' : 'Where to, $firstName?';
|
||||
}
|
||||
return l10n.whereTo;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return BlocListener<TripBloc, TripState>(
|
||||
listenWhen: (prev, cur) => prev.trip?.status != cur.trip?.status,
|
||||
listener: (context, state) {
|
||||
if (state.trip != null && !state.isCompleted) {
|
||||
context.push('/trip-tracking');
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: cs.surface,
|
||||
// الخريطة تملأ الشاشة حتى الحافة العلوية (docs/29 §3.6) — كل العناصر
|
||||
// العائمة فوقها تعيش داخل SafeArea الخاصة بها لا الشاشة كلها.
|
||||
extendBodyBehindAppBar: true,
|
||||
body: Stack(
|
||||
children: [
|
||||
// ── الخريطة (75% من الشاشة) ──
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: MediaQuery.of(context).size.height * 0.75,
|
||||
child: BlocConsumer<HomeCubit, HomeState>(
|
||||
listenWhen: (prev, cur) =>
|
||||
prev.currentLocation == null && cur.currentLocation != null,
|
||||
buildWhen: (prev, cur) =>
|
||||
prev.currentLocation != cur.currentLocation ||
|
||||
prev.pickup != cur.pickup ||
|
||||
prev.destination != cur.destination,
|
||||
listener: (context, state) {
|
||||
if (state.currentLocation != null && _mapController != null) {
|
||||
_mapController!.animateCamera(
|
||||
CameraUpdate.newLatLngZoom(state.currentLocation!, 16),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
final center =
|
||||
state.currentLocation ?? const LatLng(31.9539, 35.9106);
|
||||
return IntaleqMap(
|
||||
apiKey: '',
|
||||
initialCameraPosition:
|
||||
CameraPosition(target: center, zoom: 15),
|
||||
mapType:
|
||||
isDark ? IntaleqMapType.normal : IntaleqMapType.light,
|
||||
myLocationEnabled: true,
|
||||
myLocationButtonEnabled: false,
|
||||
zoomControlsEnabled: false,
|
||||
compassEnabled: false,
|
||||
onMapCreated: (controller) {
|
||||
_mapController = controller;
|
||||
if (state.currentLocation != null) {
|
||||
Future.delayed(const Duration(milliseconds: 300), () {
|
||||
controller.animateCamera(
|
||||
CameraUpdate.newLatLngZoom(
|
||||
state.currentLocation!, 16),
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
markers: _buildMarkers(state),
|
||||
onCameraMove: (position) {
|
||||
context
|
||||
.read<HomeCubit>()
|
||||
.updateCameraTarget(position.target);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// زر القائمة أعلى-بداية
|
||||
PositionedDirectional(
|
||||
top: MediaQuery.of(context).padding.top + T.s8,
|
||||
start: T.pagePadding,
|
||||
child: _MapCircleButton(
|
||||
icon: Icons.menu_rounded,
|
||||
onPressed: () => _showLogoutDialog(context, l10n),
|
||||
),
|
||||
),
|
||||
|
||||
// زر إعادة التوسيط عائم أعلى البطاقة السفلية
|
||||
PositionedDirectional(
|
||||
end: T.pagePadding,
|
||||
bottom: (_isSelectingOnMap ? 100 : MediaQuery.of(context).size.height * 0.28) + T.s16,
|
||||
child: BlocBuilder<HomeCubit, HomeState>(
|
||||
builder: (context, state) {
|
||||
return _MapCircleButton(
|
||||
icon: Icons.my_location_rounded,
|
||||
onPressed: state.currentLocation == null
|
||||
? () {}
|
||||
: () => _mapController?.animateCamera(
|
||||
CameraUpdate.newLatLngZoom(
|
||||
state.currentLocation!, 16),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
if (_isSelectingOnMap)
|
||||
// علامة المركز (دبوس) لاختيار الموقع
|
||||
Positioned(
|
||||
top: (MediaQuery.of(context).size.height * 0.75) / 2 - 20,
|
||||
left: MediaQuery.of(context).size.width / 2 - 20,
|
||||
child: const Icon(Icons.location_on, size: 40, color: Colors.black),
|
||||
),
|
||||
|
||||
// ── البطاقة السفلية: «إلى أين؟» والأماكن المحفوظة ──
|
||||
if (!_isSelectingOnMap)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: MediaQuery.of(context).size.height * 0.30,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
T.pagePadding, T.s24, T.pagePadding, T.s16),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surface,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(T.r24),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, -5),
|
||||
)
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// التحية الزمنية مع اسم الراكب بتصميم أنيق
|
||||
BlocBuilder<AuthCubit, AuthState>(
|
||||
builder: (context, authState) {
|
||||
final greeting = _timeBasedGreeting(authState.user?.name, l10n);
|
||||
return Text(
|
||||
greeting,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
color: cs.onSurface,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: T.s24),
|
||||
|
||||
// حقل «إلى أين؟»
|
||||
BlocBuilder<AuthCubit, AuthState>(
|
||||
builder: (context, authState) {
|
||||
final searchPrompt = _whereToPrompt(authState.user?.name, l10n);
|
||||
return Material(
|
||||
color: cs.surfaceContainerLowest,
|
||||
elevation: 2,
|
||||
shadowColor: cs.shadow.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(T.r16),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(T.r16),
|
||||
onTap: () => _openDestinationSearch(context),
|
||||
child: Container(
|
||||
height: 60,
|
||||
padding: const EdgeInsets.symmetric(horizontal: T.s16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(T.r16),
|
||||
border: Border.all(
|
||||
color: cs.outlineVariant.withValues(alpha: 0.5),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primaryContainer.withValues(alpha: 0.5),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.search_rounded,
|
||||
color: cs.primary,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: T.s16),
|
||||
Expanded(
|
||||
child: Text(
|
||||
searchPrompt,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(
|
||||
color: cs.onSurface,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
Divider(color: cs.outlineVariant.withValues(alpha: 0.3), height: 1),
|
||||
const SizedBox(height: T.s12),
|
||||
|
||||
// الأماكن المحفوظة (سطر أفقي أنيق)
|
||||
BlocBuilder<SavedPlacesCubit, SavedPlacesState>(
|
||||
builder: (context, savedState) {
|
||||
final items = savedState.places.take(3).toList();
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_buildQuickAction(context, Icons.home_rounded, l10n.homePlace, items.isNotEmpty ? items.first : null),
|
||||
Container(width: 1, height: 32, color: cs.outlineVariant.withValues(alpha: 0.3)),
|
||||
_buildQuickAction(context, Icons.work_rounded, l10n.workPlace, items.length > 1 ? items[1] : null),
|
||||
Container(width: 1, height: 32, color: cs.outlineVariant.withValues(alpha: 0.3)),
|
||||
_buildQuickAction(context, Icons.star_rounded, l10n.favorites, items.length > 2 ? items[2] : null),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: T.s8),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
// زر تأكيد الوجهة في وضع اختيار الخريطة
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
T.pagePadding, T.s16, T.pagePadding, T.s32),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surface,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(T.r24),
|
||||
),
|
||||
boxShadow: const [T.shadowLg],
|
||||
),
|
||||
child: FilledButton(
|
||||
onPressed: _confirmMapDestination,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(T.r12),
|
||||
),
|
||||
),
|
||||
child: Text('تأكيد الوجهة', style: Theme.of(context).textTheme.titleMedium?.copyWith(color: cs.onPrimary)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onPlaceTap(BuildContext context, SavedPlace place) {
|
||||
context.read<HomeCubit>().setDestination(PlaceModel(
|
||||
name: place.name,
|
||||
address: place.address,
|
||||
lat: place.lat,
|
||||
lng: place.lng,
|
||||
));
|
||||
final state = context.read<HomeCubit>().state;
|
||||
if (state.pickup != null) {
|
||||
_openRoutePreview(context, state);
|
||||
}
|
||||
}
|
||||
|
||||
Set<Marker> _buildMarkers(HomeState state) {
|
||||
final markers = <Marker>{};
|
||||
if (state.pickup != null) {
|
||||
markers.add(Marker(
|
||||
markerId: const MarkerId('pickup'),
|
||||
position: LatLng(state.pickup!.lat, state.pickup!.lng),
|
||||
icon: InlqBitmap.defaultMarkerWithHue(210),
|
||||
anchor: const Offset(0.5, 1.0),
|
||||
zIndex: 2,
|
||||
));
|
||||
}
|
||||
if (state.destination != null) {
|
||||
markers.add(Marker(
|
||||
markerId: const MarkerId('destination'),
|
||||
position: LatLng(state.destination!.lat, state.destination!.lng),
|
||||
icon: InlqBitmap.defaultMarkerWithHue(0),
|
||||
anchor: const Offset(0.5, 1.0),
|
||||
zIndex: 1,
|
||||
));
|
||||
}
|
||||
return markers;
|
||||
}
|
||||
|
||||
void _openDestinationSearch(BuildContext context) {
|
||||
final homeCubit = context.read<HomeCubit>();
|
||||
final pickup = homeCubit.state.pickup;
|
||||
final currentLocation = homeCubit.state.currentLocation;
|
||||
|
||||
context.push<dynamic>('/destination', extra: {
|
||||
'currentLocation': currentLocation,
|
||||
}).then((result) {
|
||||
if (!mounted) return;
|
||||
if (result is Map && result['pickFromMap'] == true) {
|
||||
setState(() {
|
||||
_isSelectingOnMap = true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final place = result as PlaceModel?;
|
||||
if (place != null && pickup != null) {
|
||||
homeCubit.setDestination(place);
|
||||
// ignore: use_build_context_synchronously
|
||||
context.push('/route-preview', extra: {
|
||||
'pickup': pickup,
|
||||
'destination': place,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _confirmMapDestination() async {
|
||||
final homeCubit = context.read<HomeCubit>();
|
||||
final target = homeCubit.state.cameraTarget;
|
||||
if (target == null) return;
|
||||
|
||||
// We reverse geocode the target to get the address name
|
||||
final repo = getIt<MapsRepository>();
|
||||
try {
|
||||
final place = await repo.reverseGeocode(target.latitude, target.longitude);
|
||||
setState(() {
|
||||
_isSelectingOnMap = false;
|
||||
});
|
||||
homeCubit.setDestination(place);
|
||||
if (homeCubit.state.pickup != null) {
|
||||
_openRoutePreview(context, homeCubit.state);
|
||||
}
|
||||
} catch (_) {
|
||||
// In case of error, still set it with empty name
|
||||
setState(() {
|
||||
_isSelectingOnMap = false;
|
||||
});
|
||||
homeCubit.setDestination(PlaceModel(
|
||||
name: 'موقع محدد على الخريطة',
|
||||
lat: target.latitude,
|
||||
lng: target.longitude,
|
||||
));
|
||||
if (homeCubit.state.pickup != null) {
|
||||
_openRoutePreview(context, homeCubit.state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _openRoutePreview(BuildContext context, HomeState state) {
|
||||
if (state.pickup == null || state.destination == null) return;
|
||||
context.push('/route-preview', extra: {
|
||||
'pickup': state.pickup,
|
||||
'destination': state.destination,
|
||||
});
|
||||
}
|
||||
|
||||
void _showLogoutDialog(BuildContext context, AppLocalizations l10n) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(l10n.logout),
|
||||
content: Text(l10n.logout),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: Text(l10n.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(ctx);
|
||||
await context.read<AuthCubit>().logout();
|
||||
if (context.mounted) context.go('/phone');
|
||||
},
|
||||
style: FilledButton.styleFrom(backgroundColor: cs.error),
|
||||
child: Text(l10n.logout),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuickAction(
|
||||
BuildContext context, IconData icon, String label, SavedPlace? place) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return InkWell(
|
||||
onTap: place != null ? () => _onPlaceTap(context, place) : null,
|
||||
borderRadius: BorderRadius.circular(T.r12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: T.s12, vertical: T.s8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon,
|
||||
color: place != null
|
||||
? cs.primary
|
||||
: cs.onSurfaceVariant.withValues(alpha: 0.5),
|
||||
size: 28),
|
||||
const SizedBox(height: T.s4),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: place != null
|
||||
? cs.onSurface
|
||||
: cs.onSurfaceVariant.withValues(alpha: 0.5),
|
||||
fontWeight:
|
||||
place != null ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,540 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intaleq_maps/intaleq_maps.dart';
|
||||
import '../../../core/l10n/app_localizations.dart';
|
||||
import '../../../core/design/tokens.dart';
|
||||
import '../../../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 '../data/models/route_model.dart';
|
||||
import '../data/maps_repository.dart';
|
||||
import '../../../core/di.dart';
|
||||
import 'widgets/ride_class_card.dart';
|
||||
|
||||
List<LatLng> _decodePolyline(String encoded) {
|
||||
return PolylineUtils.decode(encoded);
|
||||
}
|
||||
|
||||
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;
|
||||
RouteModel? _route;
|
||||
List<LatLng> _polylinePoints = [];
|
||||
bool _routeLoading = true;
|
||||
String? _routeError;
|
||||
final _promoController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchRoute();
|
||||
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
|
||||
void dispose() {
|
||||
_promoController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _fetchRoute() async {
|
||||
setState(() {
|
||||
_routeLoading = true;
|
||||
_routeError = null;
|
||||
});
|
||||
try {
|
||||
final repo = getIt<MapsRepository>();
|
||||
final route = await repo.getRoute(
|
||||
fromLat: widget.pickup.lat,
|
||||
fromLng: widget.pickup.lng,
|
||||
toLat: widget.destination.lat,
|
||||
toLng: widget.destination.lng,
|
||||
);
|
||||
final points = await compute(_decodePolyline, route.polyline ?? '');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_route = route;
|
||||
_polylinePoints = points;
|
||||
_routeLoading = false;
|
||||
});
|
||||
_fitCamera();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_routeError = e.toString();
|
||||
_routeLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _fitCamera() {
|
||||
if (_mapController == null) return;
|
||||
final bounds = _route?.getLatLngBounds();
|
||||
if (bounds != null) {
|
||||
_mapController!.moveCamera(CameraUpdate.newLatLngBounds(bounds));
|
||||
} else {
|
||||
_mapController!.moveCamera(CameraUpdate.newLatLngZoom(
|
||||
LatLng(widget.pickup.lat, widget.pickup.lng),
|
||||
15,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@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: Stack(
|
||||
children: [
|
||||
// الخريطة بكامل الشاشة
|
||||
if (_routeLoading)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else if (_routeError != null)
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(l10n.errorGeneric),
|
||||
const SizedBox(height: T.s12),
|
||||
TripzButton(
|
||||
label: l10n.retry,
|
||||
onPressed: _fetchRoute,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
IntaleqMap(
|
||||
apiKey: '',
|
||||
initialCameraPosition: CameraPosition(
|
||||
target: LatLng(
|
||||
(pickupLoc.latitude + destLoc.latitude) / 2,
|
||||
(pickupLoc.longitude + destLoc.longitude) / 2,
|
||||
),
|
||||
zoom: 14,
|
||||
),
|
||||
mapType: Theme.of(context).brightness == Brightness.dark
|
||||
? IntaleqMapType.normal
|
||||
: IntaleqMapType.light,
|
||||
myLocationEnabled: false,
|
||||
zoomControlsEnabled: false,
|
||||
compassEnabled: false,
|
||||
onMapCreated: (c) {
|
||||
_mapController = c;
|
||||
_fitCamera();
|
||||
},
|
||||
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: {
|
||||
// حماية محرك C++ من الانهيار إذا كانت النقاط أقل من 2
|
||||
if (_polylinePoints.length >= 2)
|
||||
Polyline(
|
||||
polylineId: const PolylineId('route'),
|
||||
points: _polylinePoints,
|
||||
color: cs.primary,
|
||||
width: T.polylineWidth,
|
||||
),
|
||||
},
|
||||
),
|
||||
|
||||
// زر الرجوع العائم (Top Left)
|
||||
Positioned(
|
||||
top: MediaQuery.of(context).padding.top + T.s8,
|
||||
left: T.s12,
|
||||
child: FloatingActionButton.small(
|
||||
heroTag: 'back',
|
||||
backgroundColor: cs.surface.withValues(alpha: 0.9),
|
||||
elevation: 4,
|
||||
onPressed: () => context.pop(),
|
||||
child: Icon(Icons.arrow_back_rounded, color: cs.onSurface),
|
||||
),
|
||||
),
|
||||
|
||||
// ملخص المسار العائم (Top Right)
|
||||
Positioned(
|
||||
top: MediaQuery.of(context).padding.top + T.s12,
|
||||
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.s16, vertical: T.s12),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surface.withValues(alpha: 0.95),
|
||||
borderRadius: BorderRadius.circular(T.r24),
|
||||
boxShadow: const [T.shadowSm],
|
||||
),
|
||||
child: Text(
|
||||
l10n.routeSummary(
|
||||
state.quote!.distanceText ?? '',
|
||||
state.quote!.durationText ?? '',
|
||||
),
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom Sheet: فئات الرحلات
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.55,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surface,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(24),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, -5),
|
||||
)
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// شريط السحب (Grab handle)
|
||||
const SizedBox(height: T.s12),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 5,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.outlineVariant.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: T.s12),
|
||||
|
||||
Expanded(
|
||||
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,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyLarge
|
||||
?.copyWith(color: cs.error)),
|
||||
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(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: T.pagePadding),
|
||||
child: Text(
|
||||
l10n.rideClasses,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: T.s12),
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: T.pagePadding),
|
||||
itemCount: classes.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
const SizedBox(height: T.s12),
|
||||
itemBuilder: (context, index) {
|
||||
final cls = classes[index];
|
||||
return RideClassCard(
|
||||
quoteClass: cls,
|
||||
selected: state.selectedClass ==
|
||||
cls.vehicleClass,
|
||||
onTap: () => context
|
||||
.read<QuoteCubit>()
|
||||
.selectClass(cls.vehicleClass),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
// شريط الدفع وكود الخصم
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
T.pagePadding, T.s12, T.pagePadding, T.s8),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surface,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: cs.outlineVariant
|
||||
.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_PaymentRow(
|
||||
method: state.paymentMethod,
|
||||
onChanged: (m) => context
|
||||
.read<QuoteCubit>()
|
||||
.setPaymentMethod(m),
|
||||
),
|
||||
const SizedBox(height: T.s12),
|
||||
_PromoRow(
|
||||
controller: _promoController,
|
||||
onApply: (code) => context
|
||||
.read<QuoteCubit>()
|
||||
.setCouponCode(code),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// زر طلب الرحلة
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
T.pagePadding, 0, T.pagePadding, T.s12),
|
||||
child: BlocBuilder<TripBloc, TripState>(
|
||||
builder: (context, tripState) {
|
||||
final isLoading =
|
||||
tripState.requestStatus ==
|
||||
TripRequestStatus.loading;
|
||||
return 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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PromoRow extends StatefulWidget {
|
||||
final TextEditingController controller;
|
||||
final ValueChanged<String?> onApply;
|
||||
|
||||
const _PromoRow({required this.controller, required this.onApply});
|
||||
|
||||
@override
|
||||
State<_PromoRow> createState() => _PromoRowState();
|
||||
}
|
||||
|
||||
class _PromoRowState extends State<_PromoRow> {
|
||||
@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.s8),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(T.r12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.local_offer_outlined,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: T.s12),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: widget.controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.couponCode,
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
TripzButton(
|
||||
label: l10n.confirm,
|
||||
style: TripzButtonStyle.text,
|
||||
fullWidth: false,
|
||||
onPressed: () {
|
||||
final code = widget.controller.text.trim();
|
||||
widget.onApply(code.isEmpty ? null : code);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
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.r16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: T.s16, vertical: T.s12),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? cs.primaryContainer.withValues(alpha: 0.2) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(T.r16),
|
||||
border: Border.all(
|
||||
color: selected ? cs.primary : cs.outlineVariant.withValues(alpha: 0.3),
|
||||
width: selected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// أيقونة المركبة
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? cs.primary.withValues(alpha: 0.1) : cs.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.directions_car_filled_rounded,
|
||||
color: selected ? cs.primary : cs.onSurfaceVariant,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: T.s16),
|
||||
|
||||
// اسم الفئة + السعة
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
quoteClass.name,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.person_outline_rounded,
|
||||
size: 14, color: cs.onSurfaceVariant),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
l10n.capacity(quoteClass.capacity),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// الوقت التقديري + السعر
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'${quoteClass.price} ${quoteClass.currency}',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
color: selected ? cs.primary : cs.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
quoteClass.estimatedTimeText,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../../core/design/tokens.dart';
|
||||
import '../../../core/l10n/app_localizations.dart';
|
||||
import '../../../core/ui/tripz_scaffold.dart';
|
||||
import '../../../core/ui/tripz_button.dart';
|
||||
|
||||
class OnboardingScreen extends StatefulWidget {
|
||||
const OnboardingScreen({super.key});
|
||||
|
||||
@override
|
||||
State<OnboardingScreen> createState() => _OnboardingScreenState();
|
||||
}
|
||||
|
||||
class _OnboardingScreenState extends State<OnboardingScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final PageController _pageController = PageController();
|
||||
int _currentIndex = 0;
|
||||
|
||||
late final AnimationController _pulseController;
|
||||
late final Animation<double> _pulseAnimation;
|
||||
|
||||
static const _onboardingKey = 'onboarding_seen';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pulseController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1400),
|
||||
)..repeat(reverse: true);
|
||||
_pulseAnimation = Tween<double>(begin: 1.0, end: 1.08).animate(
|
||||
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pulseController.dispose();
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _markSeen() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_onboardingKey, true);
|
||||
}
|
||||
|
||||
void _nextPage(int pageCount) {
|
||||
if (_currentIndex < pageCount - 1) {
|
||||
_pageController.nextPage(
|
||||
duration: T.slow,
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
} else {
|
||||
_markSeen();
|
||||
context.go('/phone');
|
||||
}
|
||||
}
|
||||
|
||||
void _skip() {
|
||||
_markSeen();
|
||||
context.go('/phone');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final cs = theme.colorScheme;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
final pages = [
|
||||
_OnboardingPageData(
|
||||
title: l10n.onboardingTitle1,
|
||||
subtitle: l10n.onboardingSubtitle1,
|
||||
image: 'assets/images/onboarding_1.png',
|
||||
fallbackIcon: Icons.touch_app_rounded,
|
||||
),
|
||||
_OnboardingPageData(
|
||||
title: l10n.onboardingTitle2,
|
||||
subtitle: l10n.onboardingSubtitle2,
|
||||
image: 'assets/images/onboarding_2.png',
|
||||
fallbackIcon: Icons.location_on_rounded,
|
||||
),
|
||||
_OnboardingPageData(
|
||||
title: l10n.onboardingTitle3,
|
||||
subtitle: l10n.onboardingSubtitle3,
|
||||
image: 'assets/images/onboarding_3.png',
|
||||
fallbackIcon: Icons.payment_rounded,
|
||||
),
|
||||
];
|
||||
|
||||
return TripzScaffold(
|
||||
backgroundColor: cs.surface,
|
||||
padding: false,
|
||||
bottomAction: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// ── Page Indicators ──
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(pages.length, (index) {
|
||||
final isActive = index == _currentIndex;
|
||||
return AnimatedContainer(
|
||||
duration: T.medium,
|
||||
margin: const EdgeInsets.symmetric(horizontal: T.s4),
|
||||
height: T.s8,
|
||||
width: isActive ? T.s24 : T.s8,
|
||||
decoration: BoxDecoration(
|
||||
color: isActive
|
||||
? cs.primary
|
||||
: cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(T.rCircle),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
const SizedBox(height: T.s24),
|
||||
|
||||
// ── Primary Button ──
|
||||
TripzButton(
|
||||
onPressed: () => _nextPage(pages.length),
|
||||
label: _currentIndex == pages.length - 1 ? l10n.getStarted : l10n.next,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// ── Skip Button ──
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: T.pagePadding,
|
||||
vertical: T.s8,
|
||||
),
|
||||
child: TripzButton(
|
||||
onPressed: _skip,
|
||||
style: TripzButtonStyle.text,
|
||||
label: l10n.skip,
|
||||
fullWidth: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── PageView ──
|
||||
Expanded(
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
itemCount: pages.length,
|
||||
onPageChanged: (index) {
|
||||
setState(() => _currentIndex = index);
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final data = pages[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: T.s24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// ── الرسم التوضيحي (أصل مسجّل) مع نبض خفيف ──
|
||||
AnimatedScale(
|
||||
scale: _pulseAnimation.value,
|
||||
duration: const Duration(milliseconds: 100),
|
||||
child: SizedBox(
|
||||
width: 240,
|
||||
height: 240,
|
||||
child: Image.asset(
|
||||
data.image,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
data.fallbackIcon,
|
||||
size: 88,
|
||||
color: cs.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: T.s48),
|
||||
|
||||
// ── Title ──
|
||||
Text(
|
||||
data.title,
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
color: cs.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: T.s12),
|
||||
|
||||
// ── Subtitle ──
|
||||
Text(
|
||||
data.subtitle,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OnboardingPageData {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String image;
|
||||
final IconData fallbackIcon;
|
||||
|
||||
const _OnboardingPageData({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.image,
|
||||
required this.fallbackIcon,
|
||||
});
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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];
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../../core/app_info.dart';
|
||||
import '../../../core/design/tokens.dart';
|
||||
import '../../../core/di.dart';
|
||||
import '../../../core/api/api_client.dart';
|
||||
import '../../auth/cubit/auth_cubit.dart';
|
||||
import '../../auth/cubit/auth_state.dart';
|
||||
import '../../../core/ui/tripz_scaffold.dart';
|
||||
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
||||
|
||||
class SplashScreen extends StatefulWidget {
|
||||
const SplashScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SplashScreen> createState() => _SplashScreenState();
|
||||
}
|
||||
|
||||
class _SplashScreenState extends State<SplashScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
static const _kOnboardingSeen = 'onboarding_seen';
|
||||
|
||||
late final AnimationController _revealController;
|
||||
late final Animation<double> _fade;
|
||||
late final Animation<double> _scale;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// حركة دخول واحدة هادئة: fade + scale ضمن أزمنة tokens (docs/29 §3.1)
|
||||
_revealController = AnimationController(vsync: this, duration: T.slow);
|
||||
final curved = CurvedAnimation(
|
||||
parent: _revealController,
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
_fade = curved;
|
||||
_scale = Tween<double>(begin: 0.85, end: 1.0).animate(curved);
|
||||
_revealController.forward();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _route());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_revealController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _route() async {
|
||||
FlutterNativeSplash.remove();
|
||||
// Minimum 800ms for the native splash to fade out gracefully
|
||||
await Future.delayed(const Duration(milliseconds: 800));
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
final authStatus = context.read<AuthCubit>().state.status;
|
||||
|
||||
if (authStatus != AuthStatus.authenticated) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (!mounted) return;
|
||||
final seen = prefs.getBool(_kOnboardingSeen) ?? false;
|
||||
if (!seen) {
|
||||
await prefs.setBool(_kOnboardingSeen, true);
|
||||
if (!mounted) return;
|
||||
context.go('/onboarding');
|
||||
} else {
|
||||
context.go('/phone');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final api = getIt<ApiClient>();
|
||||
|
||||
Future<Map<String, dynamic>?> pendingRating() async {
|
||||
try {
|
||||
final r = await api.dio
|
||||
.get('/trips/rating/pending')
|
||||
.timeout(const Duration(seconds: 3));
|
||||
final data = r.data;
|
||||
if (data is Map<String, dynamic> && data['pending'] != null) {
|
||||
return data;
|
||||
}
|
||||
return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> hasActiveTrip() async {
|
||||
try {
|
||||
final r = await api.dio
|
||||
.get('/trips/mine')
|
||||
.timeout(const Duration(seconds: 3));
|
||||
final data = r.data;
|
||||
if (data == null) return false;
|
||||
final items = (data['items'] ?? data) as List;
|
||||
if (items.isEmpty) return false;
|
||||
final status = items.first['status'] as String? ?? '';
|
||||
return [
|
||||
'searching',
|
||||
'assigned',
|
||||
'driver_arriving',
|
||||
'driver_arrived',
|
||||
'in_progress',
|
||||
].contains(status);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
final results = await Future.wait([pendingRating(), hasActiveTrip()]);
|
||||
final rating = results[0] as Map<String, dynamic>?;
|
||||
final activeTrip = results[1] as bool;
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (rating != null) {
|
||||
context.go('/trip-rating', extra: rating);
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeTrip) {
|
||||
context.go('/trip-tracking');
|
||||
return;
|
||||
}
|
||||
|
||||
context.go('/home');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
// Just show the logo briefly — native splash handles the branded experience
|
||||
return TripzScaffold(
|
||||
backgroundColor: cs.surface,
|
||||
padding: false,
|
||||
safeAreaTop: false,
|
||||
safeAreaBottom: false,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Center(
|
||||
child: AnimatedBuilder(
|
||||
animation: _revealController,
|
||||
builder: (context, child) => Opacity(
|
||||
opacity: _fade.value,
|
||||
child: Transform.scale(scale: _scale.value, child: child),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: T.s48),
|
||||
child: Image.asset(
|
||||
'assets/images/logo.png',
|
||||
width: 240,
|
||||
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) => Icon(
|
||||
Icons.directions_car_filled_rounded,
|
||||
size: 120,
|
||||
color: cs.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// مؤشر تحميل خفيف ورقم الإصدار أسفل الشاشة — يظهر بعد اكتمال حركة الشعار
|
||||
Positioned(
|
||||
bottom: T.s32,
|
||||
child: FadeTransition(
|
||||
opacity: _revealController,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(3, (i) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: T.s4),
|
||||
child: _PulsingDot(color: cs.primary, delay: i * 150),
|
||||
);
|
||||
}),
|
||||
),
|
||||
const SizedBox(height: T.s16),
|
||||
Text(
|
||||
AppInfo.formattedVersion,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.5),
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// نقطة نابضة صغيرة — مؤشر تحميل زخرفي بلا شبكة، `CustomPaint`-free ويكفي هنا Container بسيط.
|
||||
class _PulsingDot extends StatefulWidget {
|
||||
const _PulsingDot({required this.color, required this.delay});
|
||||
|
||||
final Color color;
|
||||
final int delay;
|
||||
|
||||
@override
|
||||
State<_PulsingDot> createState() => _PulsingDotState();
|
||||
}
|
||||
|
||||
class _PulsingDotState extends State<_PulsingDot>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(vsync: this, duration: T.slow * 3)
|
||||
..repeat(reverse: true);
|
||||
Future.delayed(Duration(milliseconds: widget.delay), () {
|
||||
if (mounted) _controller.forward();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, _) => Opacity(
|
||||
opacity: 0.2 + (_controller.value * 0.6),
|
||||
child: Container(
|
||||
width: T.s8,
|
||||
height: T.s8,
|
||||
decoration: BoxDecoration(color: widget.color, shape: BoxShape.circle),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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 (_) {}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
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];
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
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];
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
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'];
|
||||
if (!active.contains(trip.status)) return null;
|
||||
|
||||
// تصفية: إذا مرت أكثر من 60 دقيقة على إنشاء الرحلة، لا تُعتبر نشطة
|
||||
if (trip.createdAt != null) {
|
||||
final age = DateTime.now().difference(trip.createdAt!);
|
||||
if (age.inMinutes >= 60) return null;
|
||||
}
|
||||
return trip;
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
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 '../../../core/ui/tripz_scaffold.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: TripzScaffold(
|
||||
title: l10n.rateYourTrip,
|
||||
showBackButton: true,
|
||||
onBack: () {
|
||||
context.read<TripBloc>().add(const TripClear());
|
||||
context.go('/home');
|
||||
},
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
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 '../../../core/ui/tripz_scaffold.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)!;
|
||||
|
||||
return BlocBuilder<TripBloc, TripState>(
|
||||
builder: (context, state) {
|
||||
final trip = state.trip;
|
||||
if (trip == null) {
|
||||
return TripzScaffold(
|
||||
title: l10n.rideCompleted,
|
||||
showBackButton: true,
|
||||
onBack: () => context.go('/home'),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
final fare = trip.fare ?? 0;
|
||||
final currency = trip.currency ?? 'JOD';
|
||||
|
||||
return TripzScaffold(
|
||||
title: l10n.rideCompleted,
|
||||
showBackButton: false,
|
||||
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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
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 '../../../core/ui/tripz_scaffold.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> {
|
||||
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)!;
|
||||
|
||||
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 TripzScaffold(
|
||||
showBackButton: true,
|
||||
onBack: () {
|
||||
context.read<TripBloc>().add(const TripClear());
|
||||
context.go('/home');
|
||||
},
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: T.s16),
|
||||
Text(l10n.loading),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
mapType: Theme.of(context).brightness == Brightness.dark
|
||||
? IntaleqMapType.normal
|
||||
: IntaleqMapType.light,
|
||||
myLocationEnabled: true,
|
||||
myLocationButtonEnabled: false,
|
||||
zoomControlsEnabled: false,
|
||||
compassEnabled: false,
|
||||
onMapCreated: (_) {},
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
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(l10n.driverRatingValue, 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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
// File generated by FlutterFire CLI.
|
||||
// ignore_for_file: type=lint
|
||||
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
|
||||
import 'package:flutter/foundation.dart'
|
||||
show defaultTargetPlatform, kIsWeb, TargetPlatform;
|
||||
|
||||
/// Default [FirebaseOptions] for use with your Firebase apps.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// import 'firebase_options.dart';
|
||||
/// // ...
|
||||
/// await Firebase.initializeApp(
|
||||
/// options: DefaultFirebaseOptions.currentPlatform,
|
||||
/// );
|
||||
/// ```
|
||||
class DefaultFirebaseOptions {
|
||||
static FirebaseOptions get currentPlatform {
|
||||
if (kIsWeb) {
|
||||
return web;
|
||||
}
|
||||
switch (defaultTargetPlatform) {
|
||||
case TargetPlatform.android:
|
||||
return android;
|
||||
case TargetPlatform.iOS:
|
||||
return ios;
|
||||
case TargetPlatform.macOS:
|
||||
return macos;
|
||||
case TargetPlatform.windows:
|
||||
return windows;
|
||||
case TargetPlatform.linux:
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions have not been configured for linux - '
|
||||
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||
);
|
||||
default:
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions are not supported for this platform.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static const FirebaseOptions android = FirebaseOptions(
|
||||
apiKey: 'AIzaSyCPew9iGolLyI8f-uZUD7QhG4IE0hl20LU',
|
||||
appId: '1:139325012385:android:552cbaeca54851979fb3e0',
|
||||
messagingSenderId: '139325012385',
|
||||
projectId: 'tripz-7883a',
|
||||
storageBucket: 'tripz-7883a.firebasestorage.app',
|
||||
);
|
||||
static const FirebaseOptions ios = FirebaseOptions(
|
||||
apiKey: 'AIzaSyB6chzJf14YXUM9wWXIGNLlHfmNg5Jrw6E',
|
||||
appId: '1:139325012385:ios:964034e702133d609fb3e0',
|
||||
messagingSenderId: '139325012385',
|
||||
projectId: 'tripz-7883a',
|
||||
storageBucket: 'tripz-7883a.firebasestorage.app',
|
||||
iosBundleId: 'com.mobileapp.store.ride',
|
||||
);
|
||||
static const FirebaseOptions web = FirebaseOptions(
|
||||
apiKey: 'AIzaSyAUIVdZt8pNaplroGUJMe1QP2-OXZj4Yz8',
|
||||
appId: '1:139325012385:web:0820c0966e1dca869fb3e0',
|
||||
messagingSenderId: '139325012385',
|
||||
projectId: 'tripz-7883a',
|
||||
authDomain: 'tripz-7883a.firebaseapp.com',
|
||||
storageBucket: 'tripz-7883a.firebasestorage.app',
|
||||
measurementId: 'G-53TJZG613C',
|
||||
);
|
||||
|
||||
static const FirebaseOptions macos = FirebaseOptions(
|
||||
apiKey: 'AIzaSyB6chzJf14YXUM9wWXIGNLlHfmNg5Jrw6E',
|
||||
appId: '1:139325012385:ios:964034e702133d609fb3e0',
|
||||
messagingSenderId: '139325012385',
|
||||
projectId: 'tripz-7883a',
|
||||
storageBucket: 'tripz-7883a.firebasestorage.app',
|
||||
iosBundleId: 'com.mobileapp.store.ride',
|
||||
);
|
||||
static const FirebaseOptions windows = FirebaseOptions(
|
||||
apiKey: 'AIzaSyAVtyV7YVMeLbwA1UlNPxV9FhCzT0kjeAE',
|
||||
appId: '1:594687661098:web:d9f43a2091395d87595f53',
|
||||
messagingSenderId: '594687661098',
|
||||
projectId: 'ride-b1bd8',
|
||||
authDomain: 'ride-b1bd8.firebaseapp.com',
|
||||
storageBucket: 'ride-b1bd8.appspot.com',
|
||||
measurementId: 'G-C3DWQ8Z062',
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
||||
import 'firebase_options.dart';
|
||||
import 'core/di.dart';
|
||||
import 'app.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsBinding widgetsBinding = WidgetsFlutterBinding.ensureInitialized();
|
||||
FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);
|
||||
try {
|
||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||
} catch (_) {
|
||||
// لا نُسقط الإقلاع لو تعذّر Firebase
|
||||
}
|
||||
await setupDi();
|
||||
runApp(const TripzApp());
|
||||
}
|
||||
Reference in New Issue
Block a user