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
@@ -0,0 +1,65 @@
|
||||
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>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/// استثناءات الشبكة الموحّدة — كل خطأ يتحول إلى صنف هنا.
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/// مرجع معلومات التطبيق ورقم الإصدار الموحد.
|
||||
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';
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/// مُولَّد من المانيفست عبر سكربت 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;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/// إعداد وقت التشغيل — يُمرَّر عبر --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',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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());
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
{
|
||||
"@@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": "تخطي"
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
{
|
||||
"@@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
@@ -0,0 +1,537 @@
|
||||
// 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 => 'تخطي';
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
// 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';
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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
@@ -0,0 +1,168 @@
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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)';
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
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),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user