feat: implement splash and onboarding flows, update app assets, and add project design documentation

This commit is contained in:
Hamza-Ayed
2026-07-24 04:15:07 +03:00
parent 9f7bc31408
commit 213b459912
208 changed files with 23801 additions and 1066 deletions
+65
View File
@@ -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>;
}
}
+11 -3
View File
@@ -3,6 +3,7 @@ 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).
///
@@ -21,15 +22,22 @@ class ApiClient {
ApiClient(this._store)
: dio = Dio(BaseOptions(
baseUrl: AppConfig.baseUrl,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 20),
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;
final device = await _resolveDeviceId();
// 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();
@@ -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);
}
}
+9
View File
@@ -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';
}
+1
View File
@@ -10,6 +10,7 @@ class BuildConfig {
// ألوان المستأجر (البذرة)
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
+5 -2
View File
@@ -22,9 +22,12 @@ class AppConfig {
/// دور هذا التطبيق — يميّز تطبيق الراكب عن السائق عند التسجيل.
static const String appRole = 'rider';
/// مفتاح الخريطة — يُمرَّر من البيئة. لا يُكتب في الكود (docs/23 §6).
/// مفتاح الخريطة — يُضمَّن في التطبيق ويُقيَّد ببصمة التطبيق (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: '',
defaultValue: 'in_9478b32836d19cff73db3063',
);
}
+2 -1
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../build_config.dart';
import 'tripz_colors.dart';
import 'typography.dart';
@@ -6,7 +7,7 @@ import 'typography.dart';
/// docs/26 §2, §4
ThemeData buildTheme({required Brightness brightness, required Locale locale}) {
final bool isDark = brightness == Brightness.dark;
final seed = const Color(0xFF0E7C66); // أخضر تركوازي — بذرة المستأجر
final seed = Color(BuildConfig.primaryColorValue); // أخضر تركوازي — بذرة المستأجر
final colorScheme = ColorScheme.fromSeed(
seedColor: seed,
+1
View File
@@ -11,6 +11,7 @@ abstract final class T {
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;
+6 -1
View File
@@ -1,6 +1,8 @@
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';
@@ -21,8 +23,9 @@ Future<void> setupDi() async {
);
// ميزات Phase 1: الخرائط + الأسعار
getIt.registerLazySingleton<AntlaqApi>(() => AntlaqApi());
getIt.registerLazySingleton<MapsRepository>(
() => MapsRepository(getIt<ApiClient>()),
() => MapsRepository(getIt<AntlaqApi>(), getIt<ApiClient>()),
);
getIt.registerLazySingleton<TariffRepository>(
() => TariffRepository(getIt<ApiClient>()),
@@ -44,4 +47,6 @@ Future<void> setupDi() async {
getIt.registerLazySingleton<FcmService>(
() => FcmService(getIt<TripEventMerger>()),
);
getIt.registerLazySingleton<SavedPlacesStore>(() => SavedPlacesStore());
}
+53 -1
View File
@@ -172,6 +172,58 @@
"distance": {"type": "String"}
}
},
"edit": "تعديل",
"resendIn": "إعادة إرسال خلال {seconds}ث",
"@resendIn": {
"placeholders": {
"seconds": {"type": "int"}
}
},
"resendCode": "إعادة إرسال الرمز",
"nameTitle": "ما اسمك؟",
"nameSubtitle": "ساعدنا في تخصيص تجربتك",
"nameHint": "اسمك",
"firstNameHint": "الاسم الأول",
"lastNameHint": "اسم العائلة",
"nameButton": "متابعة",
"reconnect": "جارٍ إعادة الاتصال...",
"tripExpired": "انتهت صلاحية الرحلة"
"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": "تخطي"
}
+53 -1
View File
@@ -172,6 +172,58 @@
"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"
"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"
}
@@ -812,6 +812,60 @@ abstract class AppLocalizations {
/// **'مسافة الرحلة: {distance}'**
String tripDistance(String distance);
/// No description provided for @edit.
///
/// In ar, this message translates to:
/// **'تعديل'**
String get edit;
/// No description provided for @resendIn.
///
/// In ar, this message translates to:
/// **'إعادة إرسال خلال {seconds}ث'**
String resendIn(int seconds);
/// No description provided for @resendCode.
///
/// In ar, this message translates to:
/// **'إعادة إرسال الرمز'**
String get resendCode;
/// No description provided for @nameTitle.
///
/// In ar, this message translates to:
/// **'ما اسمك؟'**
String get nameTitle;
/// No description provided for @nameSubtitle.
///
/// In ar, this message translates to:
/// **'ساعدنا في تخصيص تجربتك'**
String get nameSubtitle;
/// No description provided for @nameHint.
///
/// In ar, this message translates to:
/// **'اسمك'**
String get nameHint;
/// No description provided for @firstNameHint.
///
/// In ar, this message translates to:
/// **'الاسم الأول'**
String get firstNameHint;
/// No description provided for @lastNameHint.
///
/// In ar, this message translates to:
/// **'اسم العائلة'**
String get lastNameHint;
/// No description provided for @nameButton.
///
/// In ar, this message translates to:
/// **'متابعة'**
String get nameButton;
/// No description provided for @reconnect.
///
/// In ar, this message translates to:
@@ -823,6 +877,234 @@ abstract class AppLocalizations {
/// In ar, this message translates to:
/// **'انتهت صلاحية الرحلة'**
String get tripExpired;
/// No description provided for @loginTitle.
///
/// In ar, this message translates to:
/// **'مرحباً بك في Tripz'**
String get loginTitle;
/// No description provided for @phoneLoginSubtitle.
///
/// In ar, this message translates to:
/// **'أدخل رقم هاتفك لتسجيل الدخول'**
String get phoneLoginSubtitle;
/// No description provided for @sendOtpButton.
///
/// In ar, this message translates to:
/// **'إرسال رمز التحقق'**
String get sendOtpButton;
/// No description provided for @nameEntryTitle.
///
/// In ar, this message translates to:
/// **'ما اسمك؟'**
String get nameEntryTitle;
/// No description provided for @homeTitle.
///
/// In ar, this message translates to:
/// **'الرئيسية'**
String get homeTitle;
/// No description provided for @destinationTitle.
///
/// In ar, this message translates to:
/// **'الوجهة'**
String get destinationTitle;
/// No description provided for @routeTitle.
///
/// In ar, this message translates to:
/// **'المسار'**
String get routeTitle;
/// No description provided for @whereToHint.
///
/// In ar, this message translates to:
/// **'إلى أين تريد الذهاب؟'**
String get whereToHint;
/// No description provided for @currentLocation.
///
/// In ar, this message translates to:
/// **'الموقع الحالي'**
String get currentLocation;
/// No description provided for @savedPlaces.
///
/// In ar, this message translates to:
/// **'الأماكن المحفوظة'**
String get savedPlaces;
/// No description provided for @home.
///
/// In ar, this message translates to:
/// **'المنزل'**
String get home;
/// No description provided for @work.
///
/// In ar, this message translates to:
/// **'العمل'**
String get work;
/// No description provided for @pickFromMap.
///
/// In ar, this message translates to:
/// **'اختيار من الخريطة'**
String get pickFromMap;
/// No description provided for @searchHint.
///
/// In ar, this message translates to:
/// **'ابحث عن مكان...'**
String get searchHint;
/// No description provided for @noResultsFound.
///
/// In ar, this message translates to:
/// **'لا توجد نتائج'**
String get noResultsFound;
/// No description provided for @connecting.
///
/// In ar, this message translates to:
/// **'جارٍ الاتصال...'**
String get connecting;
/// No description provided for @tripSummary.
///
/// In ar, this message translates to:
/// **'ملخص الرحلة'**
String get tripSummary;
/// No description provided for @totalDistance.
///
/// In ar, this message translates to:
/// **'المسافة الإجمالية'**
String get totalDistance;
/// No description provided for @estimatedTime.
///
/// In ar, this message translates to:
/// **'الوقت المقدر'**
String get estimatedTime;
/// No description provided for @totalFare.
///
/// In ar, this message translates to:
/// **'الأجرة الإجمالية'**
String get totalFare;
/// No description provided for @chooseRide.
///
/// In ar, this message translates to:
/// **'اختر نوع الرحلة'**
String get chooseRide;
/// No description provided for @promoCode.
///
/// In ar, this message translates to:
/// **'كود الترويج'**
String get promoCode;
/// No description provided for @errorTitle.
///
/// In ar, this message translates to:
/// **'حدث خطأ'**
String get errorTitle;
/// No description provided for @errorSubtitle.
///
/// In ar, this message translates to:
/// **'يرجى المحاولة مرة أخرى'**
String get errorSubtitle;
/// No description provided for @emptyTitle.
///
/// In ar, this message translates to:
/// **'لا توجد بيانات'**
String get emptyTitle;
/// No description provided for @emptySubtitle.
///
/// In ar, this message translates to:
/// **'لم يتم العثور على محتوى حالياً'**
String get emptySubtitle;
/// No description provided for @signIn.
///
/// In ar, this message translates to:
/// **'تسجيل الدخول'**
String get signIn;
/// No description provided for @confirmPhoneNumber.
///
/// In ar, this message translates to:
/// **'تأكيد رقم الهاتف'**
String get confirmPhoneNumber;
/// No description provided for @driverRatingValue.
///
/// In ar, this message translates to:
/// **'التقييم'**
String get driverRatingValue;
/// No description provided for @missingDataError.
///
/// In ar, this message translates to:
/// **'بيانات مفقودة'**
String get missingDataError;
/// No description provided for @onboardingTitle1.
///
/// In ar, this message translates to:
/// **'اطلب بلمسة واحدة'**
String get onboardingTitle1;
/// No description provided for @onboardingSubtitle1.
///
/// In ar, this message translates to:
/// **'أدخل وجهتك واحصل على سائق في ثوانٍ'**
String get onboardingSubtitle1;
/// No description provided for @onboardingTitle2.
///
/// In ar, this message translates to:
/// **'تتبع رحلتك مباشرة'**
String get onboardingTitle2;
/// No description provided for @onboardingSubtitle2.
///
/// In ar, this message translates to:
/// **'شاهد سائقك وهو يقترب في الوقت الفعلي'**
String get onboardingSubtitle2;
/// No description provided for @onboardingTitle3.
///
/// In ar, this message translates to:
/// **'ادفع كما يناسبك'**
String get onboardingTitle3;
/// No description provided for @onboardingSubtitle3.
///
/// In ar, this message translates to:
/// **'اختر الدفع نقدًا أو بالبطاقة أو عبر المحفظة'**
String get onboardingSubtitle3;
/// No description provided for @getStarted.
///
/// In ar, this message translates to:
/// **'ابدأ الآن'**
String get getStarted;
/// No description provided for @skip.
///
/// In ar, this message translates to:
/// **'تخطي'**
String get skip;
}
class _AppLocalizationsDelegate
@@ -385,9 +385,153 @@ class AppLocalizationsAr extends AppLocalizations {
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 => 'تخطي';
}
@@ -386,9 +386,153 @@ class AppLocalizationsEn extends AppLocalizations {
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';
}
+40 -8
View File
@@ -2,10 +2,14 @@ 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';
@@ -15,45 +19,67 @@ 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: '/phone',
initialLocation: '/splash',
refreshListenable: _StreamRefresh(auth.stream),
redirect: (context, state) {
final status = auth.state.status;
final loc = state.matchedLocation;
final onAuthPages = loc == '/phone' || loc == '/otp';
final onSplash = loc == '/splash';
final onOnboarding = loc == '/onboarding';
final onAuthPages = loc == '/phone' || loc == '/otp' || loc == '/name';
final onNamePage = loc == '/name';
if (status == AuthStatus.authenticated) {
return onAuthPages ? '/home' : null;
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: (_, __) => BlocProvider(
create: (_) => DestinationCubit(getIt<MapsRepository>()),
child: const DestinationScreen(),
),
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',
@@ -62,7 +88,13 @@ GoRouter buildRouter(AuthCubit auth) {
final pickup = extra?['pickup'] as PlaceModel?;
final destination = extra?['destination'] as PlaceModel?;
if (pickup == null || destination == null) {
return const Scaffold(body: Center(child: Text('Missing data')));
return Builder(
builder: (ctx) => Scaffold(
body: Center(
child: Text(AppLocalizations.of(ctx)?.missingDataError ?? ''),
),
),
);
}
return BlocProvider(
create: (_) => QuoteCubit(getIt<TariffRepository>()),
@@ -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);
}
}
@@ -9,7 +9,17 @@ class TokenStore {
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();
}
-39
View File
@@ -1,39 +0,0 @@
import 'package:flutter/material.dart';
import '../design/tokens.dart';
/// حالة اللاشيء — رسالة + أيقونة.
/// docs/26 §7
class EmptyView extends StatelessWidget {
const EmptyView({
super.key,
this.icon = Icons.inbox_rounded,
this.message,
});
final IconData icon;
final String? message;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Center(
child: Padding(
padding: const EdgeInsets.all(T.s32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 64, color: cs.onSurfaceVariant.withValues(alpha: 0.4)),
const SizedBox(height: T.s16),
Text(
message ?? 'لا توجد عناصر بعد',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: cs.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
}
-48
View File
@@ -1,48 +0,0 @@
import 'package:flutter/material.dart';
import '../design/tokens.dart';
/// حالة الخطأ — رسالة بالعربية + زر إعادة المحاولة.
/// docs/26 §7
class ErrorView extends StatelessWidget {
const ErrorView({
super.key,
this.message,
this.onRetry,
});
final String? message;
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Center(
child: Padding(
padding: const EdgeInsets.all(T.s32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline_rounded,
size: 64, color: cs.error.withValues(alpha: 0.6)),
const SizedBox(height: T.s16),
Text(
message ?? 'حدث خطأ ما',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: cs.onSurface,
),
textAlign: TextAlign.center,
),
if (onRetry != null) ...[
const SizedBox(height: T.s16),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh_rounded, size: 20),
label: const Text('أعد المحاولة'),
),
],
],
),
),
);
}
}
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)';
}
+247 -13
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../design/tokens.dart';
import '../l10n/app_localizations.dart';
/// السكافولد الموحّد — كل شاشة في التطبيق تُبنى داخله.
/// يحوّل `status` من Cubit/Bloc إلى: skeleton / error / empty / محتوى.
@@ -107,23 +108,51 @@ class ErrorView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
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: [
Icon(Icons.error_outline_rounded, size: 48, color: cs.error),
const SizedBox(height: T.s16),
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(
'حدث خطأ',
style: Theme.of(context).textTheme.titleMedium,
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.s16),
FilledButton(onPressed: onRetry, child: const Text('إعادة المحاولة')),
const SizedBox(height: T.s24),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh_rounded, size: T.iconSm),
label: Text(l10n?.retry ?? 'أعد المحاولة'),
),
],
],
),
@@ -132,13 +161,182 @@ class ErrorView extends StatelessWidget {
}
}
class Skeleton extends StatelessWidget {
class Skeleton extends StatefulWidget {
const Skeleton({super.key});
@override
Widget build(BuildContext context) {
return const Center(child: CircularProgressIndicator());
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 {
@@ -146,10 +344,46 @@ class EmptyView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final l10n = AppLocalizations.of(context);
return Center(
child: Text(
'لا توجد بيانات',
style: Theme.of(context).textTheme.bodyLarge,
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,
),
],
),
),
);
}
+3 -4
View File
@@ -18,21 +18,21 @@ class TripzSheet extends StatelessWidget {
final bool isScrollControlled;
/// مساعد للاستدعاء بأمر واحد
static Future<T?> show<T>(
static Future<R?> show<R>(
BuildContext context, {
String? title,
Widget? child,
Widget? bottomAction,
bool isScrollControlled = true,
}) {
return showModalBottomSheet<T>(
return showModalBottomSheet<R>(
context: context,
isScrollControlled: isScrollControlled,
useSafeArea: true,
builder: (_) => TripzSheet(
title: title,
child: child,
bottomAction: bottomAction,
child: child,
),
);
}
@@ -40,7 +40,6 @@ class TripzSheet extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final bottomPadding = MediaQuery.of(context).viewInsets.bottom;
return Padding(
@@ -22,6 +22,7 @@ class TripzTextField extends StatelessWidget {
this.maxLength,
this.contentPadding,
this.focusNode,
this.textInputAction,
});
final TextEditingController? controller;
@@ -40,6 +41,7 @@ class TripzTextField extends StatelessWidget {
final int? maxLength;
final EdgeInsetsGeometry? contentPadding;
final FocusNode? focusNode;
final TextInputAction? textInputAction;
@override
Widget build(BuildContext context) {
@@ -55,6 +57,7 @@ class TripzTextField extends StatelessWidget {
controller: controller,
focusNode: focusNode,
keyboardType: keyboardType,
textInputAction: textInputAction,
obscureText: obscureText,
enabled: enabled,
onChanged: onChanged,
@@ -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;
}
}