feat: implement splash and onboarding flows, update app assets, and add project design documentation
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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": "تخطي"
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)';
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user