feat: N1d/N2/N3 + قانون فلاتر وإعادة ضبط تطبيق السائق
الباك إند: - تعيين أول أدمن صار يُنشئ المستخدم إن لم يوجد — كان يشترط دخولاً سابقاً، وهي بيضة ودجاجة تمنع الدخول إلى لوحة أي مستأجر جديد أصلاً. - OTP_DEV_MODE كان يفشل مفتوحاً (`!== 'false'`): غياب المتغيّر أو خطأ مطبعي يترك الإنتاج برمز ثابت يفتح كل حساب، ويحرس السحب المالي كذلك. صار `=== 'true'` في الإعداد وفي قارئَيه، مع تحذير عند الإقلاع. - N2: التحقّق من اللوغو عند الرفع (PNG/JPEG · 512+ · مربّع · سقف 5MB) عبر قراءة الترويسة بلا اعتمادية — بدل اكتشاف أيقونة ممطوطة بعد النشر. N3: السكربت يولّد build_config.dart بأعلام const (طبقات docs/22 §1.5)، ويضبط bundle IDs واسم التطبيق والأيقونات/splash، و--build يشغّل Shorebird. فلاتر: - docs/23: قانون مُلزِم للتطبيقين (البنية · Cubit · طبقة الشبكة · الأعلام). - السائق: أُزيل Dart القديم (GetX) مع الإبقاء على الأصيل والإضافات وشهادات التوقيع وShorebird وFirebase الخاص به، وأُعيد هيكلته مطابقاً للراكب. - طبقة الشبكة ترسل x-device-id وتجدّد التوكن عند 401 مرة واحدة فقط. - الحزم موحّدة بين التطبيقين، والـAPI https حصراً في الاثنين. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
3ab74a13a8
commit
de4bbd00ac
@@ -0,0 +1,103 @@
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import '../config.dart';
|
||||
import '../storage/token_store.dart';
|
||||
|
||||
/// عميل HTTP لكل نداءات الـAPI (docs/23 §4).
|
||||
///
|
||||
/// **نقطة الشبكة الوحيدة** في التطبيق: لا `http.get` ولا `Dio` آخر في أي
|
||||
/// مكان. السبب أن كل ما يفرضه السيرفر (المستأجر · التوكن · بصمة الجهاز ·
|
||||
/// التوقيع) يُحقن هنا مرة واحدة؛ نداءٌ يلتفّ حول هذا الملف هو نداء بلا
|
||||
/// مصادقة يفشل عند تفعيل الحراسات على السيرفر.
|
||||
class ApiClient {
|
||||
final Dio dio;
|
||||
final TokenStore _store;
|
||||
|
||||
/// بصمة الجهاز (docs/17 — D2). تُحسب مرة وتُخزَّن: قراءتها من النظام في كل
|
||||
/// طلب استدعاءُ قناة أصيلة بلا داعٍ.
|
||||
static String? _deviceId;
|
||||
|
||||
ApiClient(this._store)
|
||||
: dio = Dio(BaseOptions(
|
||||
baseUrl: AppConfig.baseUrl,
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 20),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
)) {
|
||||
dio.interceptors.add(InterceptorsWrapper(
|
||||
onRequest: (options, handler) async {
|
||||
options.headers['x-tenant-id'] = AppConfig.tenantSlug;
|
||||
|
||||
final device = await _resolveDeviceId();
|
||||
if (device != null) options.headers['x-device-id'] = device;
|
||||
|
||||
final token = await _store.access();
|
||||
if (token != null && token.isNotEmpty) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
handler.next(options);
|
||||
},
|
||||
onError: (e, handler) async {
|
||||
// تجديد التوكن عند 401 — **مرة واحدة**. بلا علامة `_retried` يعيد
|
||||
// الطلبُ الفاشل تجديداً يفشل فيعيد… حلقة لانهائية تستنزف البطارية
|
||||
// وتغرق السيرفر عند انتهاء الجلسة فعلاً.
|
||||
final isAuthError = e.response?.statusCode == 401;
|
||||
final alreadyRetried = e.requestOptions.extra['_retried'] == true;
|
||||
final isRefreshCall = e.requestOptions.path.contains('/auth/refresh');
|
||||
|
||||
if (!isAuthError || alreadyRetried || isRefreshCall) {
|
||||
return handler.next(e);
|
||||
}
|
||||
|
||||
final refreshed = await _tryRefresh();
|
||||
if (!refreshed) {
|
||||
await _store.clear(); // الجلسة انتهت فعلاً — لا نُبقي توكناً ميتاً
|
||||
return handler.next(e);
|
||||
}
|
||||
|
||||
try {
|
||||
e.requestOptions.extra['_retried'] = true;
|
||||
final retry = await dio.fetch(e.requestOptions);
|
||||
return handler.resolve(retry);
|
||||
} on DioException catch (err) {
|
||||
return handler.next(err);
|
||||
}
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
Future<bool> _tryRefresh() async {
|
||||
final rt = await _store.refresh();
|
||||
if (rt == null || rt.isEmpty) return false;
|
||||
try {
|
||||
// Dio نظيف: المرور بالعميل نفسه يُدخل التوكن المنتهي في الترويسة.
|
||||
final plain = Dio(BaseOptions(baseUrl: AppConfig.baseUrl));
|
||||
final r = await plain.post('/auth/refresh', data: {'refresh_token': rt});
|
||||
await _store.save(
|
||||
r.data['access_token'] as String,
|
||||
(r.data['refresh_token'] ?? rt) as String,
|
||||
);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String?> _resolveDeviceId() async {
|
||||
if (_deviceId != null) return _deviceId;
|
||||
try {
|
||||
final info = DeviceInfoPlugin();
|
||||
if (Platform.isAndroid) {
|
||||
_deviceId = (await info.androidInfo).id;
|
||||
} else if (Platform.isIOS) {
|
||||
_deviceId = (await info.iosInfo).identifierForVendor;
|
||||
}
|
||||
} catch (_) {
|
||||
// تعذّر قراءة الجهاز: نُرسل بلا الترويسة بدل إسقاط الطلب. السيرفر
|
||||
// يرفض عند تفعيل الربط، وهذا أوضح من فشل صامت هنا.
|
||||
_deviceId = null;
|
||||
}
|
||||
return _deviceId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/// إعداد وقت التشغيل — يُمرَّر عبر --dart-define عند البناء.
|
||||
///
|
||||
/// ما يخصّ **المستأجر** (اسمه، ألوانه، أعلام ميزاته) لا يوضع هنا: مكانه
|
||||
/// `build_config.dart` المُولَّد من المانيفست (docs/22 §1.5). هذا الملف
|
||||
/// للإعداد التقني المشترك بين كل المستأجرين.
|
||||
class AppConfig {
|
||||
/// الـAPI عبر TLS حصراً (docs/20). المنفذ الخام مغلق على مستوى الشبكة،
|
||||
/// فأي عودة إلى http تعني تطبيقاً لا يتصل أصلاً.
|
||||
static const String baseUrl = String.fromEnvironment(
|
||||
'BASE_URL',
|
||||
defaultValue: 'https://tripz-api.intaleqapp.com/api',
|
||||
);
|
||||
|
||||
/// slug المستأجر. الافتراض للتطوير؛ البناء المولَّد يمرّره صراحةً.
|
||||
static const String tenantSlug = String.fromEnvironment(
|
||||
'TENANT',
|
||||
defaultValue: 'siro',
|
||||
);
|
||||
|
||||
static const String appName = 'Tripz Driver';
|
||||
|
||||
/// دور هذا التطبيق — يميّز تطبيق السائق عن الراكب عند التسجيل.
|
||||
static const String appRole = 'driver';
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'api/api_client.dart';
|
||||
import 'storage/token_store.dart';
|
||||
import '../features/auth/data/auth_repository.dart';
|
||||
|
||||
final GetIt getIt = GetIt.instance;
|
||||
|
||||
/// حقن التبعيات (docs/23 §1).
|
||||
///
|
||||
/// **هذه نقطة إقفال الميزات المدفوعة**: الموديول المحروس يُسجَّل داخل
|
||||
/// `if (Features.x)` فلا يوجد أصلاً حين لا تكون الميزة مشتراة — بدل فحص
|
||||
/// متناثر في كل شاشة يُنسى أحدها (docs/22 §1.5).
|
||||
///
|
||||
/// مثال عند وصول الدردشة:
|
||||
/// ```dart
|
||||
/// if (Features.chat) {
|
||||
/// getIt.registerLazySingleton<ChatRepository>(() => ChatRepository(getIt()));
|
||||
/// }
|
||||
/// ```
|
||||
Future<void> setupDi() async {
|
||||
getIt.registerLazySingleton<TokenStore>(() => TokenStore());
|
||||
getIt.registerLazySingleton<ApiClient>(() => ApiClient(getIt<TokenStore>()));
|
||||
getIt.registerLazySingleton<AuthRepository>(
|
||||
() => AuthRepository(getIt<ApiClient>(), getIt<TokenStore>()),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../features/auth/cubit/auth_cubit.dart';
|
||||
import '../features/auth/cubit/auth_state.dart';
|
||||
import '../features/auth/view/phone_screen.dart';
|
||||
import '../features/auth/view/otp_screen.dart';
|
||||
import '../features/home/view/home_screen.dart';
|
||||
|
||||
GoRouter buildRouter(AuthCubit auth) {
|
||||
return GoRouter(
|
||||
initialLocation: '/phone',
|
||||
refreshListenable: _StreamRefresh(auth.stream),
|
||||
redirect: (context, state) {
|
||||
final status = auth.state.status;
|
||||
final loc = state.matchedLocation;
|
||||
final onAuthPages = loc == '/phone' || loc == '/otp';
|
||||
|
||||
if (status == AuthStatus.authenticated) {
|
||||
return onAuthPages ? '/home' : null;
|
||||
}
|
||||
if (status == AuthStatus.codeSent && loc == '/phone') return '/otp';
|
||||
if (status != AuthStatus.authenticated && !onAuthPages) return '/phone';
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
GoRoute(path: '/phone', builder: (_, __) => const PhoneScreen()),
|
||||
GoRoute(path: '/otp', builder: (_, __) => const OtpScreen()),
|
||||
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _StreamRefresh extends ChangeNotifier {
|
||||
late final StreamSubscription<dynamic> _sub;
|
||||
_StreamRefresh(Stream<dynamic> stream) {
|
||||
notifyListeners();
|
||||
_sub = stream.asBroadcastStream().listen((_) => notifyListeners());
|
||||
}
|
||||
@override
|
||||
void dispose() {
|
||||
_sub.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
/// تخزين آمن للتوكنات (access + refresh).
|
||||
class TokenStore {
|
||||
final FlutterSecureStorage _s = const FlutterSecureStorage();
|
||||
|
||||
Future<void> save(String access, String refresh) async {
|
||||
await _s.write(key: 'access_token', value: access);
|
||||
await _s.write(key: 'refresh_token', value: refresh);
|
||||
}
|
||||
|
||||
Future<String?> access() => _s.read(key: 'access_token');
|
||||
Future<String?> refresh() => _s.read(key: 'refresh_token');
|
||||
Future<void> clear() => _s.deleteAll();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// ثيم Tripz — أساس بسيط، خط عربي (mohanad) وألوان.
|
||||
ThemeData buildTheme() {
|
||||
const seed = Color(0xFF0E7C66); // أخضر تركوازي
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: seed),
|
||||
fontFamily: 'mohanad',
|
||||
inputDecorationTheme: const InputDecorationTheme(
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
),
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user