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:
Hamza-Ayed
2026-07-27 05:12:04 +03:00
co-authored by Claude Opus 5
parent fff76c949f
commit 9909d9b4c1
809 changed files with 0 additions and 0 deletions
@@ -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),
);
}
+52
View File
@@ -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;
}
}