Update Saqel Platform: 2026-08-31 01:52:23
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
/// Global Application Configuration
|
||||
class AppConfig {
|
||||
AppConfig._();
|
||||
|
||||
// Official Live Saqel API Gateway
|
||||
static const String productionBaseUrl = 'https://saqel.intaleqapp.com';
|
||||
static const String localBaseUrl = 'http://127.0.0.1:8000';
|
||||
|
||||
// Active Base URL (Default to live production API Gateway)
|
||||
static String baseUrl = productionBaseUrl;
|
||||
|
||||
static const String appNameAr = 'صَقِل';
|
||||
static const String appNameEn = 'Saqel';
|
||||
static const String appTaglineAr = 'المنصة التعليمية الشاملة للثانوية العامة';
|
||||
static const String appTaglineEn = 'Comprehensive EdTech Platform for High School';
|
||||
|
||||
// API Endpoints
|
||||
static const String otpRequestEndpoint = '/api/auth/otp/request';
|
||||
static const String otpVerifyEndpoint = '/api/auth/otp/verify';
|
||||
static const String loginNationalIdEndpoint = '/api/auth/student/login-national-id';
|
||||
static const String meEndpoint = '/api/auth/me';
|
||||
static const String logoutEndpoint = '/api/auth/logout';
|
||||
static const String studentLessonsEndpoint = '/api/student/lessons';
|
||||
static const String guardianDashboardEndpoint = '/api/guardian/dashboard';
|
||||
static const String studentProfileStatusEndpoint = '/api/student/profile/status';
|
||||
static const String studentProfileSetupEndpoint = '/api/student/profile/setup';
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/// Multi-language Localization Dictionary for Saqel Platform
|
||||
class AppStrings {
|
||||
AppStrings._();
|
||||
|
||||
static bool isArabic = true;
|
||||
|
||||
// General
|
||||
static String get appName => isArabic ? 'صَقِل' : 'Saqel';
|
||||
static String get studentPortal => isArabic ? 'منصة الطالب' : 'Student Portal';
|
||||
static String get guardianPortal => isArabic ? 'بوابة ولي الأمر' : 'Guardian Portal';
|
||||
static String get studentRole => isArabic ? 'الطالب 🎓' : 'Student 🎓';
|
||||
static String get guardianRole => isArabic ? 'ولي الأمر 👨👧👦' : 'Guardian 👨👧👦';
|
||||
static String get logout => isArabic ? 'تسجيل الخروج' : 'Logout';
|
||||
static String get retry => isArabic ? 'إعادة المحاولة' : 'Retry';
|
||||
static String get loading => isArabic ? 'جارٍ التحميل...' : 'Loading...';
|
||||
static String get emptyData => isArabic ? 'لا توجد بيانات حالياً' : 'No data available';
|
||||
|
||||
// Auth
|
||||
static String get enterPhoneTitle => isArabic ? 'تسجيل الدخول عبر الواتساب' : 'Login via WhatsApp';
|
||||
static String get enterPhoneSubtitle => isArabic
|
||||
? 'أدخل رقم هاتفك لاستلام رمز التحقق الفوري (OTP) بدون كلمات مرور'
|
||||
: 'Enter your phone number to receive instant OTP verification';
|
||||
static String get phoneNumber => isArabic ? 'رقم الهاتف (مثال: 079XXXXXXX)' : 'Phone Number';
|
||||
static String get requestOtp => isArabic ? 'إرسال رمز التحقق 📲' : 'Send OTP Code 📲';
|
||||
static String get enterOtpTitle => isArabic ? 'تأكيد رمز التحقق' : 'Verify OTP Code';
|
||||
static String get enterOtpSubtitle => isArabic
|
||||
? 'تم إرسال رمز التحقق عبر الواتساب إلى رقمك'
|
||||
: 'Verification code sent via WhatsApp to your number';
|
||||
static String get verifyOtp => isArabic ? 'تأكيد ودخول المنصة' : 'Verify & Continue';
|
||||
static String get enterNationalIdTitle => isArabic ? 'الرقم الوطني للطالب' : 'National ID';
|
||||
static String get enterNationalIdSubtitle => isArabic
|
||||
? 'يرجى إدخال الرقم الوطني الخاص بالطالب لفتح الملف الأكاديمي'
|
||||
: 'Enter the student National ID to access the academic profile';
|
||||
static String get nationalId => isArabic ? 'الرقم الوطني (10 أرقام)' : 'National ID (10 digits)';
|
||||
static String get continueBtn => isArabic ? 'متابعة' : 'Continue';
|
||||
|
||||
// Student Dashboard
|
||||
static String get tawjihiCurriculum => isArabic ? 'المنهاج الوزاري المعتمد 📚' : 'Ministry Curriculum 📚';
|
||||
static String get readinessScore => isArabic ? 'مؤشر الجاهزية للوزاري' : 'Tawjihi Readiness Score';
|
||||
static String get socraticCheckpoint => isArabic ? 'فحص سقراطي 🎯' : 'Socratic Checkpoint 🎯';
|
||||
static String get noLessonsFound => isArabic ? 'لا توجد دروس مسجلة للمرحلة الحالية' : 'No lessons recorded for this grade yet';
|
||||
|
||||
// Guardian Dashboard
|
||||
static String get linkedChildren => isArabic ? 'الأبناء المسجلون برقم الهاتف 📱' : 'Linked Children 📱';
|
||||
static String get noChildrenLinked => isArabic ? 'لا يوجد أبناء مرتبطون بهذا الرقم حالياً' : 'No children linked to this number';
|
||||
static String get whatsappReports => isArabic ? 'تقارير نبيه التلقائية' : 'Nabeh Automated Reports';
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../config/app_config.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
/// Unified Secure REST API Client with Automatic JWT, Device Fingerprint & Debug Telemetry
|
||||
class ApiClient {
|
||||
final http.Client _client;
|
||||
final StorageService _storage;
|
||||
|
||||
ApiClient({http.Client? client, StorageService? storage})
|
||||
: _client = client ?? http.Client(),
|
||||
_storage = storage ?? StorageService();
|
||||
|
||||
Future<Map<String, String>> _buildHeaders({bool requiresAuth = true}) async {
|
||||
final headers = <String, String>{
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
|
||||
final deviceId = await _storage.getDeviceId();
|
||||
if (deviceId != null) {
|
||||
headers['X-Device-Fingerprint'] = deviceId;
|
||||
}
|
||||
|
||||
if (requiresAuth) {
|
||||
final token = await _storage.getToken();
|
||||
if (token != null && token.isNotEmpty) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
Uri _buildUri(String path, [Map<String, dynamic>? queryParams]) {
|
||||
final base = AppConfig.baseUrl.replaceAll(RegExp(r'/+$'), '');
|
||||
final cleanPath = path.startsWith('/') ? path : '/$path';
|
||||
final uriString = '$base$cleanPath';
|
||||
final uri = Uri.parse(uriString);
|
||||
if (queryParams != null && queryParams.isNotEmpty) {
|
||||
return uri.replace(queryParameters: queryParams.map((k, v) => MapEntry(k, v.toString())));
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
Future<dynamic> get(String path, {Map<String, dynamic>? queryParams, bool requiresAuth = true}) async {
|
||||
final uri = _buildUri(path, queryParams);
|
||||
final headers = await _buildHeaders(requiresAuth: requiresAuth);
|
||||
|
||||
AppLogger.request(method: 'GET', uri: uri, headers: headers);
|
||||
final stopwatch = Stopwatch()..start();
|
||||
|
||||
try {
|
||||
final response = await _client.get(uri, headers: headers);
|
||||
stopwatch.stop();
|
||||
return _handleResponse(response, method: 'GET', uri: uri, duration: stopwatch.elapsed);
|
||||
} catch (e, stack) {
|
||||
stopwatch.stop();
|
||||
AppLogger.error('GET Request Failed ($uri)', error: e, stackTrace: stack);
|
||||
throw ApiException('فشل الاتصال بالخادم: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<dynamic> post(String path, {Map<String, dynamic>? body, bool requiresAuth = true}) async {
|
||||
final uri = _buildUri(path);
|
||||
final headers = await _buildHeaders(requiresAuth: requiresAuth);
|
||||
|
||||
AppLogger.request(method: 'POST', uri: uri, headers: headers, body: body);
|
||||
final stopwatch = Stopwatch()..start();
|
||||
|
||||
try {
|
||||
final response = await _client.post(
|
||||
uri,
|
||||
headers: headers,
|
||||
body: body != null ? jsonEncode(body) : null,
|
||||
);
|
||||
stopwatch.stop();
|
||||
return _handleResponse(response, method: 'POST', uri: uri, duration: stopwatch.elapsed);
|
||||
} catch (e, stack) {
|
||||
stopwatch.stop();
|
||||
AppLogger.error('POST Request Failed ($uri)', error: e, stackTrace: stack);
|
||||
throw ApiException('فشل إرسال الطلب للخادم: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
dynamic _handleResponse(
|
||||
http.Response response, {
|
||||
required String method,
|
||||
required Uri uri,
|
||||
required Duration duration,
|
||||
}) {
|
||||
dynamic data;
|
||||
try {
|
||||
data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
} catch (_) {
|
||||
data = response.body;
|
||||
}
|
||||
|
||||
AppLogger.response(
|
||||
method: method,
|
||||
uri: uri,
|
||||
statusCode: response.statusCode,
|
||||
responseBody: data,
|
||||
duration: duration,
|
||||
);
|
||||
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
return data;
|
||||
}
|
||||
|
||||
final message = (data is Map && data['message'] != null)
|
||||
? data['message']
|
||||
: (data is Map && data['error'] != null)
|
||||
? data['error']
|
||||
: 'خطأ من الخادم (${response.statusCode})';
|
||||
|
||||
if (response.statusCode == 401) {
|
||||
AppLogger.log('Unauthorized (401) received, clearing session.', tag: 'AUTH');
|
||||
_storage.clearSession();
|
||||
throw UnauthorizedException(message.toString());
|
||||
}
|
||||
|
||||
throw ApiException(message.toString(), statusCode: response.statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
class ApiException implements Exception {
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
|
||||
ApiException(this.message, {this.statusCode});
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
class UnauthorizedException extends ApiException {
|
||||
UnauthorizedException(super.message) : super(statusCode: 401);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import '../../data/models/user_model.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
/// Resilient Storage Service for JWT Tokens, Device ID, and User Data
|
||||
/// Supports macOS Keychain + In-Memory Fallback to prevent -34018 crashes
|
||||
class StorageService {
|
||||
final FlutterSecureStorage _storage;
|
||||
static final Map<String, String> _memoryFallback = {};
|
||||
|
||||
StorageService({FlutterSecureStorage? storage})
|
||||
: _storage = storage ??
|
||||
const FlutterSecureStorage(
|
||||
mOptions: MacOsOptions(
|
||||
accessibility: KeychainAccessibility.first_unlock,
|
||||
),
|
||||
aOptions: AndroidOptions(
|
||||
encryptedSharedPreferences: true,
|
||||
),
|
||||
);
|
||||
|
||||
static const String _keyToken = 'saqel_jwt_token';
|
||||
static const String _keyUser = 'saqel_user_data';
|
||||
static const String _keyRole = 'saqel_active_role';
|
||||
static const String _keyIdentityToken = 'saqel_identity_token';
|
||||
static const String _keyDeviceId = 'saqel_device_id';
|
||||
|
||||
Future<void> _writeSafe(String key, String value) async {
|
||||
_memoryFallback[key] = value;
|
||||
try {
|
||||
await _storage.write(key: key, value: value);
|
||||
} on PlatformException catch (e) {
|
||||
AppLogger.log('Keychain write notice ($key): ${e.message} (Using secure memory fallback)', tag: 'STORAGE');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<String?> _readSafe(String key) async {
|
||||
try {
|
||||
final val = await _storage.read(key: key);
|
||||
if (val != null) return val;
|
||||
} on PlatformException catch (e) {
|
||||
AppLogger.log('Keychain read notice ($key): ${e.message}', tag: 'STORAGE');
|
||||
} catch (_) {}
|
||||
return _memoryFallback[key];
|
||||
}
|
||||
|
||||
Future<void> _deleteSafe(String key) async {
|
||||
_memoryFallback.remove(key);
|
||||
try {
|
||||
await _storage.delete(key: key);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> saveToken(String token) async {
|
||||
await _writeSafe(_keyToken, token);
|
||||
}
|
||||
|
||||
Future<String?> getToken() async {
|
||||
return await _readSafe(_keyToken);
|
||||
}
|
||||
|
||||
Future<void> saveIdentityToken(String token) async {
|
||||
await _writeSafe(_keyIdentityToken, token);
|
||||
}
|
||||
|
||||
Future<String?> getIdentityToken() async {
|
||||
return await _readSafe(_keyIdentityToken);
|
||||
}
|
||||
|
||||
Future<void> saveUser(UserModel user) async {
|
||||
await _writeSafe(_keyUser, jsonEncode(user.toJson()));
|
||||
}
|
||||
|
||||
Future<UserModel?> getUser() async {
|
||||
final raw = await _readSafe(_keyUser);
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
try {
|
||||
final json = jsonDecode(raw);
|
||||
return UserModel.fromJson(json);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveActiveRole(String role) async {
|
||||
await _writeSafe(_keyRole, role);
|
||||
}
|
||||
|
||||
Future<String?> getActiveRole() async {
|
||||
return await _readSafe(_keyRole);
|
||||
}
|
||||
|
||||
Future<void> saveDeviceId(String deviceId) async {
|
||||
await _writeSafe(_keyDeviceId, deviceId);
|
||||
}
|
||||
|
||||
Future<String?> getDeviceId() async {
|
||||
return await _readSafe(_keyDeviceId);
|
||||
}
|
||||
|
||||
Future<void> clearSession() async {
|
||||
await _deleteSafe(_keyToken);
|
||||
await _deleteSafe(_keyUser);
|
||||
await _deleteSafe(_keyRole);
|
||||
await _deleteSafe(_keyIdentityToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Apple Luxury Palette & Brand Tokens for Saqel Platform
|
||||
class AppColors {
|
||||
AppColors._();
|
||||
|
||||
// Dark OLED Palette
|
||||
static const Color darkBackground = Color(0xFF06090F);
|
||||
static const Color darkSurface = Color(0xFF0D111A);
|
||||
static const Color darkCard = Color(0xFF131A26);
|
||||
static const Color darkCardBorder = Color(0x1FFFFFFF);
|
||||
|
||||
// Light Palette
|
||||
static const Color lightBackground = Color(0xFFF8FAFC);
|
||||
static const Color lightSurface = Color(0xFFFFFFFF);
|
||||
static const Color lightCard = Color(0xFFF1F5F9);
|
||||
static const Color lightCardBorder = Color(0x0F000000);
|
||||
|
||||
// Accent Colors
|
||||
static const Color appleBlue = Color(0xFF0071E3);
|
||||
static const Color saqelCyan = Color(0xFF00F5D4);
|
||||
static const Color guardianAmber = Color(0xFFF59E0B);
|
||||
static const Color teacherEmerald = Color(0xFF10B981);
|
||||
static const Color adminViolet = Color(0xFF8B5CF6);
|
||||
static const Color whatsappGreen = Color(0xFF25D366);
|
||||
static const Color errorRed = Color(0xFFEF4444);
|
||||
|
||||
// Text Colors
|
||||
static const Color textPrimaryDark = Color(0xFFF8FAFC);
|
||||
static const Color textSecondaryDark = Color(0xFF94A3B8);
|
||||
static const Color textMutedDark = Color(0xFF64748B);
|
||||
|
||||
static const Color textPrimaryLight = Color(0xFF0F172A);
|
||||
static const Color textSecondaryLight = Color(0xFF475569);
|
||||
static const Color textMutedLight = Color(0xFF94A3B8);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'app_colors.dart';
|
||||
|
||||
/// Apple Official San Francisco & SF Arabic Typography Standards
|
||||
class AppTypography {
|
||||
AppTypography._();
|
||||
|
||||
static const String primaryFont = '-apple-system';
|
||||
static const List<String> fallbackFonts = [
|
||||
'SF Pro Display',
|
||||
'SF Pro Text',
|
||||
'SF Arabic',
|
||||
'Geeza Pro',
|
||||
'Cairo',
|
||||
'system-ui',
|
||||
];
|
||||
|
||||
static const TextStyle displayLarge = TextStyle(
|
||||
fontFamily: primaryFont,
|
||||
fontFamilyFallback: fallbackFonts,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.5,
|
||||
);
|
||||
|
||||
static const TextStyle headlineMedium = TextStyle(
|
||||
fontFamily: primaryFont,
|
||||
fontFamilyFallback: fallbackFonts,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.3,
|
||||
);
|
||||
|
||||
static const TextStyle titleMedium = TextStyle(
|
||||
fontFamily: primaryFont,
|
||||
fontFamilyFallback: fallbackFonts,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.2,
|
||||
);
|
||||
|
||||
static const TextStyle bodyMedium = TextStyle(
|
||||
fontFamily: primaryFont,
|
||||
fontFamilyFallback: fallbackFonts,
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.5,
|
||||
);
|
||||
|
||||
static const TextStyle labelSmall = TextStyle(
|
||||
fontFamily: primaryFont,
|
||||
fontFamilyFallback: fallbackFonts,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
);
|
||||
}
|
||||
|
||||
/// Official Apple Dark & Light Theme Factory
|
||||
class AppTheme {
|
||||
AppTheme._();
|
||||
|
||||
static ThemeData get darkTheme {
|
||||
return ThemeData(
|
||||
brightness: Brightness.dark,
|
||||
scaffoldBackgroundColor: AppColors.darkBackground,
|
||||
fontFamily: AppTypography.primaryFont,
|
||||
fontFamilyFallback: AppTypography.fallbackFonts,
|
||||
colorScheme: const ColorScheme.dark(
|
||||
primary: AppColors.appleBlue,
|
||||
secondary: AppColors.saqelCyan,
|
||||
surface: AppColors.darkSurface,
|
||||
error: AppColors.errorRed,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: AppColors.darkCard,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
side: const BorderSide(color: AppColors.darkCardBorder),
|
||||
),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.appleBlue,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 15),
|
||||
textStyle: AppTypography.titleMedium.copyWith(color: Colors.white),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static ThemeData get lightTheme {
|
||||
return ThemeData(
|
||||
brightness: Brightness.light,
|
||||
scaffoldBackgroundColor: AppColors.lightBackground,
|
||||
fontFamily: AppTypography.primaryFont,
|
||||
fontFamilyFallback: AppTypography.fallbackFonts,
|
||||
colorScheme: const ColorScheme.light(
|
||||
primary: AppColors.appleBlue,
|
||||
secondary: AppColors.saqelCyan,
|
||||
surface: AppColors.lightSurface,
|
||||
error: AppColors.errorRed,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: AppColors.lightCard,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
side: const BorderSide(color: AppColors.lightCardBorder),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Enterprise Logging Utility for Saqel Platform
|
||||
/// Active in Debug mode (kDebugMode), completely silent in Production
|
||||
class AppLogger {
|
||||
AppLogger._();
|
||||
|
||||
static void log(String message, {String tag = 'SAQEL'}) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('[$tag] $message');
|
||||
}
|
||||
}
|
||||
|
||||
static void request({
|
||||
required String method,
|
||||
required Uri uri,
|
||||
Map<String, String>? headers,
|
||||
dynamic body,
|
||||
}) {
|
||||
if (!kDebugMode) return;
|
||||
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('\n🌐 ─── [HTTP REQUEST] ──────────────────────────────────────────');
|
||||
buffer.writeln('➡️ Method: $method');
|
||||
buffer.writeln('🔗 URL: $uri');
|
||||
if (headers != null && headers.isNotEmpty) {
|
||||
final safeHeaders = Map<String, String>.from(headers);
|
||||
if (safeHeaders.containsKey('Authorization')) {
|
||||
final auth = safeHeaders['Authorization']!;
|
||||
safeHeaders['Authorization'] = auth.length > 20 ? '${auth.substring(0, 15)}...' : 'Bearer [REDACTED]';
|
||||
}
|
||||
buffer.writeln('📋 Headers: ${jsonEncode(safeHeaders)}');
|
||||
}
|
||||
if (body != null) {
|
||||
buffer.writeln('📦 Body: ${body is String ? body : jsonEncode(body)}');
|
||||
}
|
||||
buffer.writeln('───────────────────────────────────────────────────────────────');
|
||||
debugPrint(buffer.toString());
|
||||
}
|
||||
|
||||
static void response({
|
||||
required String method,
|
||||
required Uri uri,
|
||||
required int statusCode,
|
||||
dynamic responseBody,
|
||||
Duration? duration,
|
||||
}) {
|
||||
if (!kDebugMode) return;
|
||||
|
||||
final isSuccess = statusCode >= 200 && statusCode < 300;
|
||||
final emoji = isSuccess ? '✅' : '⚠️';
|
||||
final buffer = StringBuffer();
|
||||
|
||||
buffer.writeln('\n$emoji ─── [HTTP RESPONSE $statusCode] ────────────────────────────────');
|
||||
buffer.writeln('⬅️ Method: $method');
|
||||
buffer.writeln('🔗 URL: $uri');
|
||||
if (duration != null) {
|
||||
buffer.writeln('⏱️ Time: ${duration.inMilliseconds}ms');
|
||||
}
|
||||
if (responseBody != null) {
|
||||
buffer.writeln('📦 Data: ${responseBody is String ? responseBody : jsonEncode(responseBody)}');
|
||||
}
|
||||
buffer.writeln('───────────────────────────────────────────────────────────────');
|
||||
debugPrint(buffer.toString());
|
||||
}
|
||||
|
||||
static void error(String message, {dynamic error, StackTrace? stackTrace, String tag = 'ERROR'}) {
|
||||
if (!kDebugMode) return;
|
||||
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('\n🚨 ─── [ERROR: $tag] ──────────────────────────────────────────');
|
||||
buffer.writeln('❌ Message: $message');
|
||||
if (error != null) {
|
||||
buffer.writeln('⚠️ Error: $error');
|
||||
}
|
||||
if (stackTrace != null) {
|
||||
buffer.writeln('📍 Trace: \n$stackTrace');
|
||||
}
|
||||
buffer.writeln('───────────────────────────────────────────────────────────────');
|
||||
debugPrint(buffer.toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// Ultra-Luxury Apple-Grade SnackBar / Toast Notification System
|
||||
class SaqelToast {
|
||||
SaqelToast._();
|
||||
|
||||
/// Show Success Toast (Emerald / Green)
|
||||
static void showSuccess(
|
||||
BuildContext context,
|
||||
String message, {
|
||||
String? title,
|
||||
Duration duration = const Duration(seconds: 3),
|
||||
}) {
|
||||
_show(
|
||||
context: context,
|
||||
message: message,
|
||||
title: title,
|
||||
icon: CupertinoIcons.checkmark_circle_fill,
|
||||
iconColor: AppColors.teacherEmerald,
|
||||
borderColor: AppColors.teacherEmerald.withAlpha(90),
|
||||
backgroundColor: const Color(0xE60D1A14),
|
||||
duration: duration,
|
||||
);
|
||||
}
|
||||
|
||||
/// Show Error Toast (Crimson / Red)
|
||||
static void showError(
|
||||
BuildContext context,
|
||||
String message, {
|
||||
String? title,
|
||||
Duration duration = const Duration(seconds: 4),
|
||||
}) {
|
||||
_show(
|
||||
context: context,
|
||||
message: message,
|
||||
title: title,
|
||||
icon: CupertinoIcons.exclamationmark_circle_fill,
|
||||
iconColor: AppColors.errorRed,
|
||||
borderColor: AppColors.errorRed.withAlpha(90),
|
||||
backgroundColor: const Color(0xE61A0D0D),
|
||||
duration: duration,
|
||||
);
|
||||
}
|
||||
|
||||
/// Show Info Toast (Apple Blue / Cyan)
|
||||
static void showInfo(
|
||||
BuildContext context,
|
||||
String message, {
|
||||
String? title,
|
||||
Duration duration = const Duration(seconds: 3),
|
||||
}) {
|
||||
_show(
|
||||
context: context,
|
||||
message: message,
|
||||
title: title,
|
||||
icon: CupertinoIcons.info_circle_fill,
|
||||
iconColor: AppColors.appleBlue,
|
||||
borderColor: AppColors.appleBlue.withAlpha(90),
|
||||
backgroundColor: const Color(0xE60D1424),
|
||||
duration: duration,
|
||||
);
|
||||
}
|
||||
|
||||
/// Show WhatsApp / Nabeh Toast (WhatsApp Green)
|
||||
static void showWhatsApp(
|
||||
BuildContext context,
|
||||
String message, {
|
||||
String? title,
|
||||
Duration duration = const Duration(seconds: 3),
|
||||
}) {
|
||||
_show(
|
||||
context: context,
|
||||
message: message,
|
||||
title: title,
|
||||
icon: CupertinoIcons.chat_bubble_fill,
|
||||
iconColor: AppColors.whatsappGreen,
|
||||
borderColor: AppColors.whatsappGreen.withAlpha(100),
|
||||
backgroundColor: const Color(0xE6091A12),
|
||||
duration: duration,
|
||||
);
|
||||
}
|
||||
|
||||
static void _show({
|
||||
required BuildContext context,
|
||||
required String message,
|
||||
String? title,
|
||||
required IconData icon,
|
||||
required Color iconColor,
|
||||
required Color borderColor,
|
||||
required Color backgroundColor,
|
||||
required Duration duration,
|
||||
}) {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
messenger.hideCurrentSnackBar();
|
||||
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
duration: duration,
|
||||
elevation: 0,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.only(bottom: 24, left: 16, right: 16),
|
||||
padding: EdgeInsets.zero,
|
||||
backgroundColor: Colors.transparent,
|
||||
content: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: borderColor, width: 1.2),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x99000000),
|
||||
blurRadius: 25,
|
||||
offset: Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: iconColor.withAlpha(35),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon, color: iconColor, size: 20),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (title != null && title.isNotEmpty) ...[
|
||||
Text(
|
||||
title,
|
||||
style: AppTypography.titleMedium.copyWith(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
],
|
||||
Text(
|
||||
message,
|
||||
style: AppTypography.bodyMedium.copyWith(
|
||||
fontSize: 12.5,
|
||||
color: const Color(0xFFE2E8F0),
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/// Real Lesson Model from /api/student/lessons
|
||||
class LessonModel {
|
||||
final int id;
|
||||
final int courseId;
|
||||
final String title;
|
||||
final int sequenceOrder;
|
||||
final String storageType;
|
||||
final String? videoUuid;
|
||||
final String? hlsUrl;
|
||||
final String? thumbnailUrl;
|
||||
final String? aiVideoUrl;
|
||||
final int durationSeconds;
|
||||
final bool isFreePreview;
|
||||
final String courseTitle;
|
||||
final bool isAiVersion;
|
||||
final int? checkpointCount;
|
||||
|
||||
LessonModel({
|
||||
required this.id,
|
||||
required this.courseId,
|
||||
required this.title,
|
||||
required this.sequenceOrder,
|
||||
required this.storageType,
|
||||
this.videoUuid,
|
||||
this.hlsUrl,
|
||||
this.thumbnailUrl,
|
||||
this.aiVideoUrl,
|
||||
required this.durationSeconds,
|
||||
required this.isFreePreview,
|
||||
required this.courseTitle,
|
||||
required this.isAiVersion,
|
||||
this.checkpointCount,
|
||||
});
|
||||
|
||||
factory LessonModel.fromJson(Map<String, dynamic> json) {
|
||||
return LessonModel(
|
||||
id: json['id'] is int ? json['id'] : int.tryParse(json['id']?.toString() ?? '0') ?? 0,
|
||||
courseId: json['course_id'] is int ? json['course_id'] : int.tryParse(json['course_id']?.toString() ?? '0') ?? 0,
|
||||
title: json['title']?.toString() ?? 'درس تفاعلي',
|
||||
sequenceOrder: json['sequence_order'] is int ? json['sequence_order'] : int.tryParse(json['sequence_order']?.toString() ?? '1') ?? 1,
|
||||
storageType: json['storage_type']?.toString() ?? 'local',
|
||||
videoUuid: json['video_uuid']?.toString(),
|
||||
hlsUrl: json['hls_url']?.toString(),
|
||||
thumbnailUrl: json['thumbnail_url']?.toString(),
|
||||
aiVideoUrl: json['ai_video_url']?.toString(),
|
||||
durationSeconds: json['duration_seconds'] is int ? json['duration_seconds'] : int.tryParse(json['duration_seconds']?.toString() ?? '0') ?? 0,
|
||||
isFreePreview: json['is_free_preview'] == 1 || json['is_free_preview'] == true,
|
||||
courseTitle: json['course_title']?.toString() ?? 'المنهاج المعتمد',
|
||||
isAiVersion: json['is_ai_version'] == 1 || json['is_ai_version'] == true,
|
||||
checkpointCount: json['checkpoint_count'] is int ? json['checkpoint_count'] : int.tryParse(json['checkpoint_count']?.toString() ?? '0'),
|
||||
);
|
||||
}
|
||||
|
||||
String get formattedDuration {
|
||||
if (durationSeconds <= 0) return 'درس تفاعلي';
|
||||
final minutes = durationSeconds ~/ 60;
|
||||
final seconds = durationSeconds % 60;
|
||||
return '$minutes دقيقة ${seconds > 0 ? '$seconds ثانية' : ''}';
|
||||
}
|
||||
}
|
||||
|
||||
/// Real Child Profile & Academic Progress Model for Guardian
|
||||
class GuardianChildModel {
|
||||
final int id;
|
||||
final String uuid;
|
||||
final String name;
|
||||
final String? nationalId;
|
||||
final String? gradeLevel;
|
||||
final String? stream;
|
||||
final String? schoolName;
|
||||
final double readinessScore;
|
||||
final int examsPassed;
|
||||
final int examsTotal;
|
||||
|
||||
GuardianChildModel({
|
||||
required this.id,
|
||||
required this.uuid,
|
||||
required this.name,
|
||||
this.nationalId,
|
||||
this.gradeLevel,
|
||||
this.stream,
|
||||
this.schoolName,
|
||||
this.readinessScore = 0.0,
|
||||
this.examsPassed = 0,
|
||||
this.examsTotal = 0,
|
||||
});
|
||||
|
||||
factory GuardianChildModel.fromJson(Map<String, dynamic> json) {
|
||||
return GuardianChildModel(
|
||||
id: json['id'] is int ? json['id'] : int.tryParse(json['id']?.toString() ?? '0') ?? 0,
|
||||
uuid: json['uuid']?.toString() ?? '',
|
||||
name: json['full_name']?.toString() ?? json['name']?.toString() ?? 'الطالب',
|
||||
nationalId: json['national_id']?.toString(),
|
||||
gradeLevel: json['grade_level']?.toString(),
|
||||
stream: json['stream']?.toString(),
|
||||
schoolName: json['school_name']?.toString() ?? 'مدرسة معتمدة',
|
||||
readinessScore: json['tawjihi_readiness_score'] != null
|
||||
? double.tryParse(json['tawjihi_readiness_score'].toString()) ?? 0.0
|
||||
: (json['readiness_score'] != null ? double.tryParse(json['readiness_score'].toString()) ?? 0.0 : 0.0),
|
||||
examsPassed: json['exams_passed_count'] is int ? json['exams_passed_count'] : int.tryParse(json['exams_passed_count']?.toString() ?? '0') ?? 0,
|
||||
examsTotal: json['exams_total_count'] is int ? json['exams_total_count'] : int.tryParse(json['exams_total_count']?.toString() ?? '0') ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/// Real User Profile Model (Student / Guardian / Teacher)
|
||||
class UserModel {
|
||||
final int id;
|
||||
final String uuid;
|
||||
final String name;
|
||||
final String phone;
|
||||
final String role;
|
||||
final String? nationalId;
|
||||
final String? gradeLevel;
|
||||
final String? stream;
|
||||
final double? readinessScore;
|
||||
final int? schoolId;
|
||||
final bool isCompleted;
|
||||
|
||||
UserModel({
|
||||
required this.id,
|
||||
required this.uuid,
|
||||
required this.name,
|
||||
required this.phone,
|
||||
required this.role,
|
||||
this.nationalId,
|
||||
this.gradeLevel,
|
||||
this.stream,
|
||||
this.readinessScore,
|
||||
this.schoolId,
|
||||
this.isCompleted = true,
|
||||
});
|
||||
|
||||
factory UserModel.fromJson(Map<String, dynamic> json) {
|
||||
return UserModel(
|
||||
id: json['id'] is int ? json['id'] : int.tryParse(json['id']?.toString() ?? '0') ?? 0,
|
||||
uuid: json['uuid']?.toString() ?? '',
|
||||
name: json['name']?.toString() ?? json['full_name']?.toString() ?? 'مستخدم صَقِل',
|
||||
phone: json['phone']?.toString() ?? '',
|
||||
role: json['role']?.toString() ?? 'student',
|
||||
nationalId: json['national_id']?.toString(),
|
||||
gradeLevel: json['grade_level']?.toString(),
|
||||
stream: json['stream']?.toString(),
|
||||
readinessScore: json['readiness_score'] != null
|
||||
? double.tryParse(json['readiness_score'].toString())
|
||||
: null,
|
||||
schoolId: json['school_id'] != null
|
||||
? int.tryParse(json['school_id'].toString())
|
||||
: null,
|
||||
isCompleted: json['is_completed'] ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'uuid': uuid,
|
||||
'name': name,
|
||||
'phone': phone,
|
||||
'role': role,
|
||||
'national_id': nationalId,
|
||||
'grade_level': gradeLevel,
|
||||
'stream': stream,
|
||||
'readiness_score': readinessScore,
|
||||
'school_id': schoolId,
|
||||
'is_completed': isCompleted,
|
||||
};
|
||||
}
|
||||
|
||||
bool get isStudent => role == 'student';
|
||||
bool get isGuardian => role == 'guardian';
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
import '../../core/services/storage_service.dart';
|
||||
import '../models/user_model.dart';
|
||||
import '../models/lesson_model.dart';
|
||||
|
||||
/// Authentication Repository (OTP, National ID, me, logout)
|
||||
class AuthRepository {
|
||||
final ApiClient _api;
|
||||
final StorageService _storage;
|
||||
|
||||
AuthRepository({ApiClient? api, StorageService? storage})
|
||||
: _api = api ?? ApiClient(),
|
||||
_storage = storage ?? StorageService();
|
||||
|
||||
Future<Map<String, dynamic>> requestOtp(String phoneNumber, {String role = 'student'}) async {
|
||||
final res = await _api.post(
|
||||
AppConfig.otpRequestEndpoint,
|
||||
body: {
|
||||
'phone_number': phoneNumber,
|
||||
'role': role,
|
||||
},
|
||||
requiresAuth: false,
|
||||
);
|
||||
return res is Map<String, dynamic> ? res : {};
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> verifyOtp(String phoneNumber, String otp, {String role = 'student'}) async {
|
||||
final res = await _api.post(
|
||||
AppConfig.otpVerifyEndpoint,
|
||||
body: {
|
||||
'phone_number': phoneNumber,
|
||||
'otp': otp,
|
||||
'role': role,
|
||||
},
|
||||
requiresAuth: false,
|
||||
);
|
||||
|
||||
if (res is Map<String, dynamic>) {
|
||||
final data = res['data'];
|
||||
if (data != null && data is Map) {
|
||||
if (data['token'] != null) {
|
||||
final token = data['token'].toString();
|
||||
await _storage.saveToken(token);
|
||||
await _storage.saveActiveRole(role);
|
||||
if (data['user'] != null) {
|
||||
final user = UserModel.fromJson(Map<String, dynamic>.from(data['user']));
|
||||
await _storage.saveUser(user);
|
||||
}
|
||||
} else if (data['identity_token'] != null) {
|
||||
await _storage.saveIdentityToken(data['identity_token'].toString());
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> verifyNationalId(String identityToken, String nationalId) async {
|
||||
final res = await _api.post(
|
||||
AppConfig.loginNationalIdEndpoint,
|
||||
body: {
|
||||
'identity_token': identityToken,
|
||||
'national_id': nationalId,
|
||||
},
|
||||
requiresAuth: false,
|
||||
);
|
||||
|
||||
if (res is Map<String, dynamic> && res['data'] != null) {
|
||||
final data = res['data'];
|
||||
if (data['token'] != null) {
|
||||
final token = data['token'].toString();
|
||||
await _storage.saveToken(token);
|
||||
await _storage.saveActiveRole('student');
|
||||
if (data['user'] != null) {
|
||||
final user = UserModel.fromJson(Map<String, dynamic>.from(data['user']));
|
||||
await _storage.saveUser(user);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
throw ApiException('فشل التحقق من الرقم الوطني');
|
||||
}
|
||||
|
||||
Future<UserModel> setupStudentProfile({
|
||||
required String identityToken,
|
||||
required String fullName,
|
||||
required String nationalId,
|
||||
String gradeLevel = 'tawjihi_2008',
|
||||
String stream = 'scientific',
|
||||
}) async {
|
||||
final res = await _api.post(
|
||||
AppConfig.studentProfileSetupEndpoint,
|
||||
body: {
|
||||
'identity_token': identityToken,
|
||||
'full_name': fullName,
|
||||
'national_id': nationalId,
|
||||
'grade_level': gradeLevel,
|
||||
'stream': stream,
|
||||
},
|
||||
requiresAuth: false,
|
||||
);
|
||||
|
||||
if (res is Map<String, dynamic> && res['data'] != null) {
|
||||
final data = res['data'];
|
||||
if (data['token'] != null) {
|
||||
final token = data['token'].toString();
|
||||
await _storage.saveToken(token);
|
||||
await _storage.saveActiveRole('student');
|
||||
}
|
||||
if (data['user'] != null) {
|
||||
final user = UserModel.fromJson(Map<String, dynamic>.from(data['user']));
|
||||
await _storage.saveUser(user);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
throw ApiException('فشل استكمال فتح الملف الأكاديمي');
|
||||
}
|
||||
|
||||
Future<UserModel?> getMe() async {
|
||||
try {
|
||||
final res = await _api.get(AppConfig.meEndpoint);
|
||||
if (res is Map && res['data'] != null) {
|
||||
final user = UserModel.fromJson(Map<String, dynamic>.from(res['data']));
|
||||
await _storage.saveUser(user);
|
||||
return user;
|
||||
}
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
try {
|
||||
await _api.post(AppConfig.logoutEndpoint);
|
||||
} catch (_) {}
|
||||
await _storage.clearSession();
|
||||
}
|
||||
}
|
||||
|
||||
/// Student Repository (Lessons, Curriculum, Progress)
|
||||
class StudentRepository {
|
||||
final ApiClient _api;
|
||||
|
||||
StudentRepository({ApiClient? api}) : _api = api ?? ApiClient();
|
||||
|
||||
Future<List<LessonModel>> getLessons({String? gradeLevel, String? stream}) async {
|
||||
final queryParams = <String, dynamic>{};
|
||||
if (gradeLevel != null) queryParams['grade_level'] = gradeLevel;
|
||||
if (stream != null) queryParams['stream'] = stream;
|
||||
|
||||
final res = await _api.get(AppConfig.studentLessonsEndpoint, queryParams: queryParams);
|
||||
if (res is Map && res['data'] is List) {
|
||||
return (res['data'] as List)
|
||||
.map((item) => LessonModel.fromJson(Map<String, dynamic>.from(item)))
|
||||
.toList();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Guardian Repository (Dashboard, Linked Children, Real Progress)
|
||||
class GuardianRepository {
|
||||
final ApiClient _api;
|
||||
|
||||
GuardianRepository({ApiClient? api}) : _api = api ?? ApiClient();
|
||||
|
||||
Future<List<GuardianChildModel>> getDashboardChildren() async {
|
||||
final res = await _api.get(AppConfig.guardianDashboardEndpoint);
|
||||
if (res is Map && res['data'] is List) {
|
||||
return (res['data'] as List)
|
||||
.map((item) => GuardianChildModel.fromJson(Map<String, dynamic>.from(item)))
|
||||
.toList();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../core/services/storage_service.dart';
|
||||
import '../../core/utils/app_logger.dart';
|
||||
import '../../data/models/user_model.dart';
|
||||
import '../../data/repositories/app_repositories.dart';
|
||||
|
||||
abstract class AuthState {}
|
||||
|
||||
class AuthInitial extends AuthState {}
|
||||
class AuthLoading extends AuthState {}
|
||||
class AuthOtpSent extends AuthState {
|
||||
final String phoneNumber;
|
||||
final String role;
|
||||
AuthOtpSent({required this.phoneNumber, required this.role});
|
||||
}
|
||||
class AuthNeedsNationalId extends AuthState {
|
||||
final String identityToken;
|
||||
final String phoneNumber;
|
||||
AuthNeedsNationalId({required this.identityToken, required this.phoneNumber});
|
||||
}
|
||||
class AuthNeedsOnboarding extends AuthState {
|
||||
final String identityToken;
|
||||
final String nationalId;
|
||||
AuthNeedsOnboarding({required this.identityToken, required this.nationalId});
|
||||
}
|
||||
class Authenticated extends AuthState {
|
||||
final UserModel user;
|
||||
final String activeRole;
|
||||
Authenticated({required this.user, required this.activeRole});
|
||||
}
|
||||
class Unauthenticated extends AuthState {}
|
||||
class AuthError extends AuthState {
|
||||
final String message;
|
||||
AuthError(this.message);
|
||||
}
|
||||
|
||||
class AuthCubit extends Cubit<AuthState> {
|
||||
final AuthRepository _authRepo;
|
||||
final StorageService _storage;
|
||||
|
||||
AuthCubit({AuthRepository? authRepo, StorageService? storage})
|
||||
: _authRepo = authRepo ?? AuthRepository(),
|
||||
_storage = storage ?? StorageService(),
|
||||
super(AuthInitial());
|
||||
|
||||
Future<void> checkSession() async {
|
||||
AppLogger.log('Checking local session...', tag: 'AUTH_CUBIT');
|
||||
emit(AuthLoading());
|
||||
try {
|
||||
final token = await _storage.getToken();
|
||||
if (token == null || token.isEmpty) {
|
||||
AppLogger.log('No token found -> Unauthenticated', tag: 'AUTH_CUBIT');
|
||||
emit(Unauthenticated());
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.log('Token found -> Validating with /api/auth/me...', tag: 'AUTH_CUBIT');
|
||||
final user = await _authRepo.getMe();
|
||||
if (user != null) {
|
||||
final role = await _storage.getActiveRole() ?? user.role;
|
||||
AppLogger.log('User authenticated: ${user.name} (Role: $role)', tag: 'AUTH_CUBIT');
|
||||
emit(Authenticated(user: user, activeRole: role));
|
||||
} else {
|
||||
AppLogger.log('Failed to fetch user -> Unauthenticated', tag: 'AUTH_CUBIT');
|
||||
emit(Unauthenticated());
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.log('Session check error: $e', tag: 'AUTH_CUBIT');
|
||||
emit(Unauthenticated());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sendOtp(String phoneNumber, {String role = 'student'}) async {
|
||||
AppLogger.log('Requesting OTP for $phoneNumber (Role: $role)...', tag: 'AUTH_CUBIT');
|
||||
emit(AuthLoading());
|
||||
try {
|
||||
final cleanPhone = phoneNumber.trim();
|
||||
final res = await _authRepo.requestOtp(cleanPhone, role: role);
|
||||
if (res['status'] == 'success') {
|
||||
AppLogger.log('OTP sent successfully to $cleanPhone', tag: 'AUTH_CUBIT');
|
||||
emit(AuthOtpSent(phoneNumber: cleanPhone, role: role));
|
||||
} else {
|
||||
final msg = res['message']?.toString() ?? 'فشل إرسال رمز التحقق';
|
||||
AppLogger.log('OTP request rejected: $msg', tag: 'AUTH_CUBIT');
|
||||
emit(AuthError(msg));
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.error('OTP Send Exception', error: e, tag: 'AUTH_CUBIT');
|
||||
emit(AuthError(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> verifyOtp(String phoneNumber, String otp, {String role = 'student'}) async {
|
||||
AppLogger.log('Verifying OTP for $phoneNumber...', tag: 'AUTH_CUBIT');
|
||||
emit(AuthLoading());
|
||||
try {
|
||||
final res = await _authRepo.verifyOtp(phoneNumber, otp.trim(), role: role);
|
||||
final data = res['data'];
|
||||
|
||||
if (data != null && data['token'] != null) {
|
||||
final user = UserModel.fromJson(Map<String, dynamic>.from(data['user']));
|
||||
AppLogger.log('OTP verified -> Authenticated: ${user.name}', tag: 'AUTH_CUBIT');
|
||||
emit(Authenticated(user: user, activeRole: role));
|
||||
} else if (data != null && data['identity_token'] != null) {
|
||||
AppLogger.log('OTP verified -> Requires National ID', tag: 'AUTH_CUBIT');
|
||||
emit(AuthNeedsNationalId(
|
||||
identityToken: data['identity_token'].toString(),
|
||||
phoneNumber: phoneNumber,
|
||||
));
|
||||
} else {
|
||||
final msg = res['message']?.toString() ?? 'رمز التحقق غير صحيح';
|
||||
AppLogger.log('OTP Verification Failed: $msg', tag: 'AUTH_CUBIT');
|
||||
emit(AuthError(msg));
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.error('OTP Verify Exception', error: e, tag: 'AUTH_CUBIT');
|
||||
emit(AuthError(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> verifyNationalId(String identityToken, String nationalId) async {
|
||||
AppLogger.log('Verifying National ID: $nationalId...', tag: 'AUTH_CUBIT');
|
||||
emit(AuthLoading());
|
||||
try {
|
||||
final res = await _authRepo.verifyNationalId(identityToken, nationalId.trim());
|
||||
final data = res['data'];
|
||||
|
||||
if (data != null && data['requires_onboarding'] == true) {
|
||||
AppLogger.log('National ID not registered -> Requires Onboarding', tag: 'AUTH_CUBIT');
|
||||
emit(AuthNeedsOnboarding(
|
||||
identityToken: identityToken,
|
||||
nationalId: nationalId.trim(),
|
||||
));
|
||||
} else if (data != null && data['user'] != null) {
|
||||
final user = UserModel.fromJson(Map<String, dynamic>.from(data['user']));
|
||||
AppLogger.log('National ID verified -> Student authenticated: ${user.name}', tag: 'AUTH_CUBIT');
|
||||
emit(Authenticated(user: user, activeRole: 'student'));
|
||||
} else {
|
||||
emit(AuthError(res['message']?.toString() ?? 'فشل التحقق من الرقم الوطني'));
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.error('National ID Exception', error: e, tag: 'AUTH_CUBIT');
|
||||
emit(AuthError(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setupProfile({
|
||||
required String identityToken,
|
||||
required String fullName,
|
||||
required String nationalId,
|
||||
String gradeLevel = 'tawjihi_2008',
|
||||
String stream = 'scientific',
|
||||
}) async {
|
||||
AppLogger.log('Setting up profile for $fullName ($nationalId)...', tag: 'AUTH_CUBIT');
|
||||
emit(AuthLoading());
|
||||
try {
|
||||
final user = await _authRepo.setupStudentProfile(
|
||||
identityToken: identityToken,
|
||||
fullName: fullName.trim(),
|
||||
nationalId: nationalId.trim(),
|
||||
gradeLevel: gradeLevel,
|
||||
stream: stream,
|
||||
);
|
||||
AppLogger.log('Profile setup complete -> Student authenticated: ${user.name}', tag: 'AUTH_CUBIT');
|
||||
emit(Authenticated(user: user, activeRole: 'student'));
|
||||
} catch (e) {
|
||||
AppLogger.error('Setup Profile Exception', error: e, tag: 'AUTH_CUBIT');
|
||||
emit(AuthError(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> switchRole(String newRole) async {
|
||||
final currentState = state;
|
||||
if (currentState is Authenticated) {
|
||||
AppLogger.log('Switching role from ${currentState.activeRole} to $newRole', tag: 'AUTH_CUBIT');
|
||||
await _storage.saveActiveRole(newRole);
|
||||
emit(Authenticated(user: currentState.user, activeRole: newRole));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
AppLogger.log('Logging out user...', tag: 'AUTH_CUBIT');
|
||||
emit(AuthLoading());
|
||||
await _authRepo.logout();
|
||||
emit(Unauthenticated());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../core/utils/app_logger.dart';
|
||||
import '../../data/models/lesson_model.dart';
|
||||
import '../../data/repositories/app_repositories.dart';
|
||||
|
||||
// --- STUDENT CUBIT ---
|
||||
abstract class StudentState {}
|
||||
|
||||
class StudentInitial extends StudentState {}
|
||||
class StudentLoading extends StudentState {}
|
||||
class StudentLoaded extends StudentState {
|
||||
final List<LessonModel> lessons;
|
||||
final double readinessScore;
|
||||
StudentLoaded({required this.lessons, required this.readinessScore});
|
||||
}
|
||||
class StudentEmpty extends StudentState {}
|
||||
class StudentError extends StudentState {
|
||||
final String message;
|
||||
StudentError(this.message);
|
||||
}
|
||||
|
||||
class StudentCubit extends Cubit<StudentState> {
|
||||
final StudentRepository _studentRepo;
|
||||
|
||||
StudentCubit({StudentRepository? studentRepo})
|
||||
: _studentRepo = studentRepo ?? StudentRepository(),
|
||||
super(StudentInitial());
|
||||
|
||||
Future<void> fetchLessons({String? gradeLevel, String? stream, double initialReadiness = 0.0}) async {
|
||||
AppLogger.log('Fetching lessons (Grade: $gradeLevel, Stream: $stream)...', tag: 'STUDENT_CUBIT');
|
||||
emit(StudentLoading());
|
||||
try {
|
||||
final lessons = await _studentRepo.getLessons(gradeLevel: gradeLevel, stream: stream);
|
||||
AppLogger.log('Fetched ${lessons.length} lessons from API', tag: 'STUDENT_CUBIT');
|
||||
if (lessons.isEmpty) {
|
||||
emit(StudentEmpty());
|
||||
} else {
|
||||
emit(StudentLoaded(lessons: lessons, readinessScore: initialReadiness));
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.error('Fetch lessons failed', error: e, tag: 'STUDENT_CUBIT');
|
||||
emit(StudentError(e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- GUARDIAN CUBIT ---
|
||||
abstract class GuardianState {}
|
||||
|
||||
class GuardianInitial extends GuardianState {}
|
||||
class GuardianLoading extends GuardianState {}
|
||||
class GuardianLoaded extends GuardianState {
|
||||
final List<GuardianChildModel> children;
|
||||
final int selectedChildIndex;
|
||||
GuardianLoaded({required this.children, this.selectedChildIndex = 0});
|
||||
}
|
||||
class GuardianEmpty extends GuardianState {}
|
||||
class GuardianError extends GuardianState {
|
||||
final String message;
|
||||
GuardianError(this.message);
|
||||
}
|
||||
|
||||
class GuardianCubit extends Cubit<GuardianState> {
|
||||
final GuardianRepository _guardianRepo;
|
||||
|
||||
GuardianCubit({GuardianRepository? guardianRepo})
|
||||
: _guardianRepo = guardianRepo ?? GuardianRepository(),
|
||||
super(GuardianInitial());
|
||||
|
||||
Future<void> fetchDashboard() async {
|
||||
AppLogger.log('Fetching guardian dashboard children from API...', tag: 'GUARDIAN_CUBIT');
|
||||
emit(GuardianLoading());
|
||||
try {
|
||||
final children = await _guardianRepo.getDashboardChildren();
|
||||
AppLogger.log('Fetched ${children.length} linked children from API', tag: 'GUARDIAN_CUBIT');
|
||||
if (children.isEmpty) {
|
||||
emit(GuardianEmpty());
|
||||
} else {
|
||||
emit(GuardianLoaded(children: children, selectedChildIndex: 0));
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.error('Fetch guardian dashboard failed', error: e, tag: 'GUARDIAN_CUBIT');
|
||||
emit(GuardianError(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
void selectChild(int index) {
|
||||
final currentState = state;
|
||||
if (currentState is GuardianLoaded && index >= 0 && index < currentState.children.length) {
|
||||
AppLogger.log('Selected child: ${currentState.children[index].name}', tag: 'GUARDIAN_CUBIT');
|
||||
emit(GuardianLoaded(children: currentState.children, selectedChildIndex: index));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'core/config/app_config.dart';
|
||||
import 'core/localization/app_strings.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
import 'logic/cubits/auth_cubit.dart';
|
||||
import 'logic/cubits/dashboard_cubits.dart';
|
||||
import 'presentation/screens/auth/auth_screen.dart';
|
||||
import 'presentation/screens/home/unified_home_screen.dart';
|
||||
|
||||
void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
runApp(const SaqelAppRoot());
|
||||
}
|
||||
|
||||
class SaqelAppRoot extends StatefulWidget {
|
||||
const SaqelAppRoot({super.key});
|
||||
|
||||
@override
|
||||
State<SaqelAppRoot> createState() => _SaqelAppRootState();
|
||||
}
|
||||
|
||||
class _SaqelAppRootState extends State<SaqelAppRoot> {
|
||||
final ThemeMode _themeMode = ThemeMode.dark;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider<AuthCubit>(
|
||||
create: (context) => AuthCubit()..checkSession(),
|
||||
),
|
||||
BlocProvider<StudentCubit>(
|
||||
create: (context) => StudentCubit(),
|
||||
),
|
||||
BlocProvider<GuardianCubit>(
|
||||
create: (context) => GuardianCubit(),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
title: AppConfig.appNameAr,
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.lightTheme,
|
||||
darkTheme: AppTheme.darkTheme,
|
||||
themeMode: _themeMode,
|
||||
builder: (context, child) {
|
||||
// RTL / LTR Directionality Control
|
||||
return Directionality(
|
||||
textDirection: AppStrings.isArabic ? TextDirection.rtl : TextDirection.ltr,
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
home: const AuthGate(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Global Auth Gate: Auto-Directs to Unified Dashboard if Logged In, or AuthScreen
|
||||
class AuthGate extends StatelessWidget {
|
||||
const AuthGate({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<AuthCubit, AuthState>(
|
||||
builder: (context, state) {
|
||||
if (state is Authenticated) {
|
||||
return UnifiedHomeScreen(
|
||||
user: state.user,
|
||||
activeRole: state.activeRole,
|
||||
);
|
||||
} else if (state is AuthLoading) {
|
||||
return const Scaffold(
|
||||
body: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
// Default to AuthScreen for unauthenticated or OTP states
|
||||
return const AuthScreen();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/config/app_config.dart';
|
||||
import '../../../core/localization/app_strings.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/utils/saqel_toast.dart';
|
||||
import '../../../logic/cubits/auth_cubit.dart';
|
||||
import '../../widgets/luxury_widgets.dart';
|
||||
|
||||
class AuthScreen extends StatefulWidget {
|
||||
const AuthScreen({super.key});
|
||||
|
||||
@override
|
||||
State<AuthScreen> createState() => _AuthScreenState();
|
||||
}
|
||||
|
||||
class _AuthScreenState extends State<AuthScreen> {
|
||||
final TextEditingController _phoneController = TextEditingController();
|
||||
final TextEditingController _otpController = TextEditingController();
|
||||
final TextEditingController _nationalIdController = TextEditingController();
|
||||
final TextEditingController _fullNameController = TextEditingController();
|
||||
|
||||
String _selectedRole = 'student';
|
||||
final String _selectedGrade = 'tawjihi_2008';
|
||||
String _selectedStream = 'scientific';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneController.dispose();
|
||||
_otpController.dispose();
|
||||
_nationalIdController.dispose();
|
||||
_fullNameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: RadialGradient(
|
||||
center: Alignment(0.8, -0.6),
|
||||
radius: 1.3,
|
||||
colors: [
|
||||
Color(0xFF0A2540),
|
||||
AppColors.darkBackground,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
child: BlocConsumer<AuthCubit, AuthState>(
|
||||
listener: (context, state) {
|
||||
if (state is AuthError) {
|
||||
SaqelToast.showError(context, state.message, title: 'تنبيه المصادقة');
|
||||
} else if (state is AuthOtpSent) {
|
||||
SaqelToast.showWhatsApp(
|
||||
context,
|
||||
'تم إرسال كود التحقق بنجاح إلى الرقم ${state.phoneNumber}',
|
||||
title: 'رسالة واتساب من نبيه 📲',
|
||||
);
|
||||
} else if (state is AuthNeedsOnboarding) {
|
||||
SaqelToast.showInfo(
|
||||
context,
|
||||
'يرجى استكمال الاسم والفرع لفتح ملفك الأكاديمي',
|
||||
title: 'طالب جديد في صَقِل 🎓',
|
||||
);
|
||||
} else if (state is Authenticated) {
|
||||
SaqelToast.showSuccess(
|
||||
context,
|
||||
'أهلاً بك يا ${state.user.name}',
|
||||
title: 'تم تسجيل الدخول بنجاح ✨',
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
final isLoading = state is AuthLoading;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Server Connection Health Badge
|
||||
Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.darkSurface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: AppColors.darkCardBorder),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.saqelCyan,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
AppConfig.baseUrl.replaceAll('https://', '').replaceAll('http://', ''),
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondaryDark,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
if (state is AuthNeedsOnboarding)
|
||||
_buildOnboardingStep(context, state, isLoading)
|
||||
else if (state is AuthNeedsNationalId)
|
||||
_buildNationalIdStep(context, state, isLoading)
|
||||
else if (state is AuthOtpSent)
|
||||
_buildOtpStep(context, state, isLoading)
|
||||
else
|
||||
_buildPhoneStep(context, isLoading),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Step 1: Phone Input & Role Selector
|
||||
Widget _buildPhoneStep(BuildContext context, bool isLoading) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 76,
|
||||
height: 76,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [AppColors.appleBlue, AppColors.saqelCyan],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x4D00F5D4),
|
||||
blurRadius: 25,
|
||||
offset: Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'ص',
|
||||
style: TextStyle(
|
||||
fontSize: 38,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
AppStrings.enterPhoneTitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTypography.displayLarge.copyWith(color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
AppStrings.enterPhoneSubtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTypography.bodyMedium.copyWith(color: AppColors.textSecondaryDark),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
|
||||
// Role Segmented Pill
|
||||
Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.darkSurface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.darkCardBorder),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: isLoading ? null : () => setState(() => _selectedRole = 'student'),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: _selectedRole == 'student' ? AppColors.appleBlue : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
AppStrings.studentRole,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: _selectedRole == 'student' ? Colors.white : AppColors.textSecondaryDark,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: isLoading ? null : () => setState(() => _selectedRole = 'guardian'),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: _selectedRole == 'guardian' ? AppColors.guardianAmber : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
AppStrings.guardianRole,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: _selectedRole == 'guardian' ? Colors.black : AppColors.textSecondaryDark,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
LuxuryTextField(
|
||||
controller: _phoneController,
|
||||
label: AppStrings.phoneNumber,
|
||||
hint: '0791234567',
|
||||
keyboardType: TextInputType.phone,
|
||||
enabled: !isLoading,
|
||||
prefixIcon: const Icon(CupertinoIcons.phone_fill, color: AppColors.saqelCyan, size: 20),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
LuxuryButton(
|
||||
text: AppStrings.requestOtp,
|
||||
isLoading: isLoading,
|
||||
onPressed: () {
|
||||
final phone = _phoneController.text.trim();
|
||||
if (phone.isEmpty) {
|
||||
SaqelToast.showError(context, 'يرجى إدخال رقم الهاتف المسجل في منصة صَقِل');
|
||||
return;
|
||||
}
|
||||
context.read<AuthCubit>().sendOtp(phone, role: _selectedRole);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Step 2: OTP Verification
|
||||
Widget _buildOtpStep(BuildContext context, AuthOtpSent state, bool isLoading) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.whatsappGreen.withAlpha(35),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(CupertinoIcons.chat_bubble_2_fill, color: AppColors.whatsappGreen, size: 30),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
AppStrings.enterOtpTitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTypography.headlineMedium.copyWith(color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${AppStrings.enterOtpSubtitle} (${state.phoneNumber})',
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTypography.bodyMedium.copyWith(color: AppColors.textSecondaryDark),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
LuxuryTextField(
|
||||
controller: _otpController,
|
||||
label: 'رمز التحقق (6 أرقام)',
|
||||
hint: '123456',
|
||||
keyboardType: TextInputType.number,
|
||||
enabled: !isLoading,
|
||||
prefixIcon: const Icon(CupertinoIcons.lock_shield_fill, color: AppColors.appleBlue, size: 20),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
LuxuryButton(
|
||||
text: AppStrings.verifyOtp,
|
||||
isLoading: isLoading,
|
||||
onPressed: () {
|
||||
final otp = _otpController.text.trim();
|
||||
if (otp.isEmpty) {
|
||||
SaqelToast.showError(context, 'يرجى إدخال رمز التحقق المستلم عبر الواتساب');
|
||||
return;
|
||||
}
|
||||
context.read<AuthCubit>().verifyOtp(state.phoneNumber, otp, role: state.role);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
TextButton(
|
||||
onPressed: isLoading ? null : () => context.read<AuthCubit>().checkSession(),
|
||||
child: const Text(
|
||||
'تغيير رقم الهاتف ↺',
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondaryDark,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Step 3: National ID Verification (For Students)
|
||||
Widget _buildNationalIdStep(BuildContext context, AuthNeedsNationalId state, bool isLoading) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.saqelCyan.withAlpha(35),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(CupertinoIcons.person_crop_square_fill, color: AppColors.saqelCyan, size: 30),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
AppStrings.enterNationalIdTitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTypography.headlineMedium.copyWith(color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
AppStrings.enterNationalIdSubtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTypography.bodyMedium.copyWith(color: AppColors.textSecondaryDark),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
LuxuryTextField(
|
||||
controller: _nationalIdController,
|
||||
label: AppStrings.nationalId,
|
||||
hint: '2008XXXXXX',
|
||||
keyboardType: TextInputType.number,
|
||||
enabled: !isLoading,
|
||||
prefixIcon: const Icon(CupertinoIcons.number, color: AppColors.saqelCyan, size: 20),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
LuxuryButton(
|
||||
text: AppStrings.continueBtn,
|
||||
isLoading: isLoading,
|
||||
onPressed: () {
|
||||
final nationalId = _nationalIdController.text.trim();
|
||||
if (nationalId.isEmpty) {
|
||||
SaqelToast.showError(context, 'يرجى إدخال الرقم الوطني الخاص بالطالب');
|
||||
return;
|
||||
}
|
||||
context.read<AuthCubit>().verifyNationalId(state.identityToken, nationalId);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Step 4: Onboarding (For New Students not registered in school roster)
|
||||
Widget _buildOnboardingStep(BuildContext context, AuthNeedsOnboarding state, bool isLoading) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.appleBlue.withAlpha(35),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(CupertinoIcons.sparkles, color: AppColors.appleBlue, size: 30),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'استكمال بيانات الطالب 🎓',
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTypography.headlineMedium.copyWith(color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'يرجى إدخال اسمك الكامل واختيار الفرع الأكاديمي لضبط المنهاج',
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTypography.bodyMedium.copyWith(color: AppColors.textSecondaryDark),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
LuxuryTextField(
|
||||
controller: _fullNameController,
|
||||
label: 'الاسم الكامل للطالب',
|
||||
hint: 'حمزة أحمد الغويريين',
|
||||
enabled: !isLoading,
|
||||
prefixIcon: const Icon(CupertinoIcons.person_fill, color: AppColors.appleBlue, size: 20),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
// Stream Selector (العلمي / الأدبي)
|
||||
Text(
|
||||
'الفرع الأكاديمي',
|
||||
style: AppTypography.titleMedium.copyWith(color: AppColors.textSecondaryDark, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: isLoading ? null : () => setState(() => _selectedStream = 'scientific'),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: _selectedStream == 'scientific' ? AppColors.saqelCyan.withAlpha(30) : AppColors.darkSurface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: _selectedStream == 'scientific' ? AppColors.saqelCyan : AppColors.darkCardBorder,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'الفرع العلمي 🔬',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: _selectedStream == 'scientific' ? AppColors.saqelCyan : Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: isLoading ? null : () => setState(() => _selectedStream = 'literary'),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: _selectedStream == 'literary' ? AppColors.guardianAmber.withAlpha(30) : AppColors.darkSurface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: _selectedStream == 'literary' ? AppColors.guardianAmber : AppColors.darkCardBorder,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'الفرع الأدبي 📚',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: _selectedStream == 'literary' ? AppColors.guardianAmber : Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
LuxuryButton(
|
||||
text: 'فتح الملف والدخول للمنصة 🚀',
|
||||
isLoading: isLoading,
|
||||
onPressed: () {
|
||||
final name = _fullNameController.text.trim();
|
||||
if (name.isEmpty) {
|
||||
SaqelToast.showError(context, 'يرجى إدخال الاسم الكامل للطالب');
|
||||
return;
|
||||
}
|
||||
context.read<AuthCubit>().setupProfile(
|
||||
identityToken: state.identityToken,
|
||||
fullName: name,
|
||||
nationalId: state.nationalId,
|
||||
gradeLevel: _selectedGrade,
|
||||
stream: _selectedStream,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/localization/app_strings.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/utils/saqel_toast.dart';
|
||||
import '../../../data/models/user_model.dart';
|
||||
import '../../../data/models/lesson_model.dart';
|
||||
import '../../../logic/cubits/auth_cubit.dart';
|
||||
import '../../../logic/cubits/dashboard_cubits.dart';
|
||||
import '../../widgets/luxury_widgets.dart';
|
||||
|
||||
class UnifiedHomeScreen extends StatefulWidget {
|
||||
final UserModel user;
|
||||
final String activeRole;
|
||||
|
||||
const UnifiedHomeScreen({
|
||||
super.key,
|
||||
required this.user,
|
||||
required this.activeRole,
|
||||
});
|
||||
|
||||
@override
|
||||
State<UnifiedHomeScreen> createState() => _UnifiedHomeScreenState();
|
||||
}
|
||||
|
||||
class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
||||
late String _currentRole;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentRole = widget.activeRole;
|
||||
_refreshData();
|
||||
}
|
||||
|
||||
void _refreshData() {
|
||||
if (_currentRole == 'student') {
|
||||
context.read<StudentCubit>().fetchLessons(
|
||||
gradeLevel: widget.user.gradeLevel,
|
||||
stream: widget.user.stream,
|
||||
initialReadiness: widget.user.readinessScore ?? 0.0,
|
||||
);
|
||||
} else {
|
||||
context.read<GuardianCubit>().fetchDashboard();
|
||||
}
|
||||
}
|
||||
|
||||
void _switchRole(String newRole) {
|
||||
if (_currentRole == newRole) return;
|
||||
setState(() => _currentRole = newRole);
|
||||
context.read<AuthCubit>().switchRole(newRole);
|
||||
_refreshData();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isStudent = _currentRole == 'student';
|
||||
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: RadialGradient(
|
||||
center: isStudent ? const Alignment(0.8, -0.6) : const Alignment(-0.8, -0.6),
|
||||
radius: 1.3,
|
||||
colors: isStudent
|
||||
? const [Color(0xFF0A2540), AppColors.darkBackground]
|
||||
: const [Color(0xFF332008), AppColors.darkBackground],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildTopBar(isStudent),
|
||||
Expanded(
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: isStudent ? _buildStudentTab() : _buildGuardianTab(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Top App Bar with Persona Switcher & Logout
|
||||
Widget _buildTopBar(bool isStudent) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0x990D111A),
|
||||
border: Border(
|
||||
bottom: BorderSide(color: AppColors.darkCardBorder),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: isStudent
|
||||
? const [AppColors.appleBlue, AppColors.saqelCyan]
|
||||
: const [AppColors.guardianAmber, Color(0xFFFBBF24)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'ص',
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w900, color: Colors.black),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.user.name,
|
||||
style: AppTypography.titleMedium.copyWith(color: Colors.white),
|
||||
),
|
||||
Text(
|
||||
isStudent ? AppStrings.studentPortal : AppStrings.guardianPortal,
|
||||
style: AppTypography.labelSmall.copyWith(color: AppColors.textMutedDark),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
|
||||
// Persona Toggle
|
||||
Container(
|
||||
padding: const EdgeInsets.all(3),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.darkSurface,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: AppColors.darkCardBorder),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_buildRolePill('الطالب', isStudent, () => _switchRole('student')),
|
||||
_buildRolePill('ولي الأمر', !isStudent, () => _switchRole('guardian')),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Logout Icon
|
||||
IconButton(
|
||||
icon: const Icon(CupertinoIcons.power, color: AppColors.errorRed, size: 20),
|
||||
onPressed: () => context.read<AuthCubit>().logout(),
|
||||
tooltip: AppStrings.logout,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRolePill(String title, bool isSelected, VoidCallback onTap) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? const Color(0x33FFFFFF) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
|
||||
color: isSelected ? Colors.white : AppColors.textMutedDark,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 🎓 Real Student Tab (Data from API)
|
||||
Widget _buildStudentTab() {
|
||||
return BlocBuilder<StudentCubit, StudentState>(
|
||||
builder: (context, state) {
|
||||
if (state is StudentLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.saqelCyan),
|
||||
);
|
||||
} else if (state is StudentError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(CupertinoIcons.exclamationmark_triangle, color: AppColors.errorRed, size: 40),
|
||||
const SizedBox(height: 12),
|
||||
Text(state.message, style: const TextStyle(color: Colors.white)),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _refreshData,
|
||||
child: Text(AppStrings.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else if (state is StudentEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(CupertinoIcons.book, color: AppColors.textMutedDark, size: 48),
|
||||
const SizedBox(height: 12),
|
||||
Text(AppStrings.noLessonsFound, style: const TextStyle(color: AppColors.textSecondaryDark)),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else if (state is StudentLoaded) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => _refreshData(),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
// Real Readiness Score Card
|
||||
LuxuryCard(
|
||||
borderColor: AppColors.saqelCyan.withAlpha(50),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.saqelCyan.withAlpha(30),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
widget.user.gradeLevel ?? 'توجيهي 2008',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.saqelCyan,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
AppStrings.readinessScore,
|
||||
style: AppTypography.headlineMedium.copyWith(color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'تقييم حقيقي مبني على الكويزات والفحوصات السقراطية',
|
||||
style: AppTypography.bodyMedium.copyWith(color: AppColors.textSecondaryDark, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: AppColors.saqelCyan, width: 3),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${state.readinessScore.toStringAsFixed(0)}%',
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w900, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Real Lessons List from API
|
||||
Text(
|
||||
AppStrings.tawjihiCurriculum,
|
||||
style: AppTypography.titleMedium.copyWith(color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
...state.lessons.map((lesson) => _buildRealLessonItem(lesson)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRealLessonItem(LessonModel lesson) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: LuxuryCard(
|
||||
padding: const EdgeInsets.all(16),
|
||||
onTap: () {
|
||||
SaqelToast.showInfo(
|
||||
context,
|
||||
'تشغيل الدرس: ${lesson.title}',
|
||||
title: lesson.courseTitle,
|
||||
);
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.appleBlue.withAlpha(30),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(CupertinoIcons.play_fill, color: AppColors.appleBlue, size: 20),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
lesson.courseTitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.saqelCyan,
|
||||
),
|
||||
),
|
||||
if (lesson.isAiVersion) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.adminViolet.withAlpha(40),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'ذكاء اصطناعي ✨',
|
||||
style: TextStyle(fontSize: 9, fontWeight: FontWeight.w700, color: Color(0xFFA78BFA)),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
lesson.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
lesson.formattedDuration,
|
||||
style: const TextStyle(fontSize: 11, color: AppColors.textMutedDark),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 👨👧👦 Real Guardian Tab (Data from API)
|
||||
Widget _buildGuardianTab() {
|
||||
return BlocBuilder<GuardianCubit, GuardianState>(
|
||||
builder: (context, state) {
|
||||
if (state is GuardianLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.guardianAmber),
|
||||
);
|
||||
} else if (state is GuardianError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(CupertinoIcons.exclamationmark_triangle, color: AppColors.errorRed, size: 40),
|
||||
const SizedBox(height: 12),
|
||||
Text(state.message, style: const TextStyle(color: Colors.white)),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _refreshData,
|
||||
child: Text(AppStrings.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else if (state is GuardianEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(CupertinoIcons.person_2, color: AppColors.textMutedDark, size: 48),
|
||||
const SizedBox(height: 12),
|
||||
Text(AppStrings.noChildrenLinked, style: const TextStyle(color: AppColors.textSecondaryDark)),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else if (state is GuardianLoaded) {
|
||||
final selectedChild = state.children[state.selectedChildIndex];
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => _refreshData(),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
Text(
|
||||
AppStrings.linkedChildren,
|
||||
style: AppTypography.titleMedium.copyWith(color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// Multi-Child Switcher
|
||||
Row(
|
||||
children: List.generate(state.children.length, (index) {
|
||||
final isSelected = index == state.selectedChildIndex;
|
||||
final child = state.children[index];
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => context.read<GuardianCubit>().selectChild(index),
|
||||
child: Container(
|
||||
margin: EdgeInsets.only(left: index == 0 ? 8 : 0),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? AppColors.guardianAmber.withAlpha(35) : AppColors.darkCard,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: isSelected ? AppColors.guardianAmber : AppColors.darkCardBorder,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
child.name,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isSelected ? const Color(0xFFFBBF24) : Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
child.gradeLevel ?? 'طالب مسجل',
|
||||
style: const TextStyle(fontSize: 10.5, color: AppColors.textMutedDark),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Selected Child Performance Bento
|
||||
LuxuryCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'تقرير الطالب: ${selectedChild.name}',
|
||||
style: AppTypography.titleMedium.copyWith(color: Colors.white),
|
||||
),
|
||||
const Spacer(),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.teacherEmerald.withAlpha(30),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'جاهزية ${selectedChild.readinessScore.toStringAsFixed(1)}%',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.teacherEmerald,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'المدرسة: ${selectedChild.schoolName ?? 'مدرسة الثقافة العسكرية'}',
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.textMutedDark),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(color: AppColors.darkCardBorder),
|
||||
const SizedBox(height: 10),
|
||||
_buildGuardianStat('الامتحانات المنجزة بنجاح', '${selectedChild.examsPassed} من ${selectedChild.examsTotal}'),
|
||||
_buildGuardianStat('الرقم الوطني', selectedChild.nationalId ?? 'مسجل بالهاتف'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Real WhatsApp Reports Integration Banner
|
||||
LuxuryCard(
|
||||
backgroundColor: const Color(0xFF075E54).withAlpha(35),
|
||||
borderColor: AppColors.whatsappGreen.withAlpha(80),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(CupertinoIcons.chat_bubble_fill, color: AppColors.whatsappGreen, size: 24),
|
||||
SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'تقارير بوابة نبيه للواتساب',
|
||||
style: TextStyle(fontSize: 13.5, fontWeight: FontWeight.w700, color: Colors.white),
|
||||
),
|
||||
SizedBox(height: 2),
|
||||
Text(
|
||||
'تصلك إشعارات الأداء والغياب آلياً عبر رقم الواتساب المسجل',
|
||||
style: TextStyle(fontSize: 11, color: AppColors.textSecondaryDark),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGuardianStat(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondaryDark)),
|
||||
const Spacer(),
|
||||
Text(value, style: const TextStyle(fontSize: 12.5, fontWeight: FontWeight.w700, color: Colors.white)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
|
||||
/// Reusable Apple-Grade Frosted Glass Card
|
||||
class LuxuryCard extends StatelessWidget {
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry padding;
|
||||
final VoidCallback? onTap;
|
||||
final Color? borderColor;
|
||||
final Color? backgroundColor;
|
||||
|
||||
const LuxuryCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding = const EdgeInsets.all(20),
|
||||
this.onTap,
|
||||
this.borderColor,
|
||||
this.backgroundColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cardWidget = Container(
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor ?? AppColors.darkCard,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
border: Border.all(
|
||||
color: borderColor ?? AppColors.darkCardBorder,
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x66000000),
|
||||
blurRadius: 25,
|
||||
offset: Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
|
||||
if (onTap != null) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: cardWidget,
|
||||
);
|
||||
}
|
||||
return cardWidget;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reusable Apple-Grade Primary Button
|
||||
class LuxuryButton extends StatelessWidget {
|
||||
final String text;
|
||||
final VoidCallback? onPressed;
|
||||
final bool isLoading;
|
||||
final Color? backgroundColor;
|
||||
final Color? textColor;
|
||||
final IconData? icon;
|
||||
|
||||
const LuxuryButton({
|
||||
super.key,
|
||||
required this.text,
|
||||
required this.onPressed,
|
||||
this.isLoading = false,
|
||||
this.backgroundColor,
|
||||
this.textColor,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ElevatedButton(
|
||||
onPressed: isLoading ? null : onPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: backgroundColor ?? AppColors.appleBlue,
|
||||
foregroundColor: textColor ?? Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
child: isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Text(
|
||||
text,
|
||||
style: AppTypography.titleMedium.copyWith(
|
||||
color: textColor ?? Colors.white,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reusable Apple-Grade Text Field
|
||||
class LuxuryTextField extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final String label;
|
||||
final String hint;
|
||||
final TextInputType keyboardType;
|
||||
final Widget? prefixIcon;
|
||||
final bool enabled;
|
||||
|
||||
const LuxuryTextField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.label,
|
||||
required this.hint,
|
||||
this.keyboardType = TextInputType.text,
|
||||
this.prefixIcon,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: AppTypography.titleMedium.copyWith(
|
||||
color: AppColors.textSecondaryDark,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: controller,
|
||||
keyboardType: keyboardType,
|
||||
enabled: enabled,
|
||||
style: AppTypography.bodyMedium.copyWith(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: AppTypography.bodyMedium.copyWith(
|
||||
color: AppColors.textMutedDark,
|
||||
),
|
||||
prefixIcon: prefixIcon,
|
||||
filled: true,
|
||||
fillColor: AppColors.darkSurface,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: AppColors.darkCardBorder),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: AppColors.darkCardBorder),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: AppColors.appleBlue, width: 1.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user