diff --git a/musadaq-app/android/app/src/main/AndroidManifest.xml b/musadaq-app/android/app/src/main/AndroidManifest.xml index f393f22..7e42337 100644 --- a/musadaq-app/android/app/src/main/AndroidManifest.xml +++ b/musadaq-app/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,20 @@ + + + + + + + + + + + + + + + + UIApplicationSupportsIndirectInputEvents + + + NSFaceIDUsageDescription + تطبيق مُصادَق يحتاج للوصول إلى تقنية التعرف على الوجه/البصمة لتسجيل الدخول بأمان وسرعة. + NSCameraUsageDescription + تطبيق مُصادَق يحتاج للوصول إلى الكاميرا لمسح الفواتير ضوئياً واستخراج البيانات منها. + NSMicrophoneUsageDescription + تطبيق مُصادَق يحتاج للوصول إلى الميكروفون لاستخدام المساعد الصوتي وتسجيل الملاحظات. + NSPhotoLibraryUsageDescription + تطبيق مُصادَق يحتاج للوصول إلى الصور لاختيار فواتير محفوظة مسبقاً في معرض الصور. diff --git a/musadaq-app/lib/app/routes/app_pages.dart b/musadaq-app/lib/app/routes/app_pages.dart index ce378a6..8ab3917 100644 --- a/musadaq-app/lib/app/routes/app_pages.dart +++ b/musadaq-app/lib/app/routes/app_pages.dart @@ -2,7 +2,10 @@ import 'package:get/get.dart'; import 'package:flutter/material.dart'; import '../../features/auth/views/phone_input_view.dart'; import '../../features/auth/views/otp_verify_view.dart'; +import '../../features/auth/views/biometric_setup_view.dart'; +import '../../features/auth/views/biometric_auth_view.dart'; import '../../features/dashboard/views/dashboard_view.dart'; +import '../../core/storage/secure_storage.dart'; part 'app_routes.dart'; @@ -13,9 +16,16 @@ class AppPages { GetPage( name: AppRoutes.SPLASH, page: () { - // Simple splash logic to navigate to login after delay - Future.delayed(const Duration(seconds: 2), () { - Get.offAllNamed(AppRoutes.PHONE_INPUT); + // Check login state after a short delay + Future.delayed(const Duration(seconds: 2), () async { + final token = await SecureStorage().getToken(); + if (token != null && token.isNotEmpty) { + // User is already logged in, request Biometric unlock before dashboard + Get.offAllNamed(AppRoutes.BIOMETRIC_AUTH); + } else { + // New user, go to login + Get.offAllNamed(AppRoutes.PHONE_INPUT); + } }); return const Scaffold( body: Center( @@ -39,6 +49,14 @@ class AppPages { name: AppRoutes.OTP_VERIFY, page: () => OtpVerifyView(), ), + GetPage( + name: AppRoutes.BIOMETRIC_SETUP, + page: () => BiometricSetupView(), + ), + GetPage( + name: AppRoutes.BIOMETRIC_AUTH, + page: () => const BiometricAuthView(), + ), GetPage( name: AppRoutes.DASHBOARD, page: () => DashboardView(), diff --git a/musadaq-app/lib/app/routes/app_routes.dart b/musadaq-app/lib/app/routes/app_routes.dart index 4c7471b..3380f3e 100644 --- a/musadaq-app/lib/app/routes/app_routes.dart +++ b/musadaq-app/lib/app/routes/app_routes.dart @@ -5,6 +5,7 @@ abstract class AppRoutes { static const PHONE_INPUT = '/phone-input'; static const OTP_VERIFY = '/otp-verify'; static const BIOMETRIC_SETUP = '/biometric-setup'; + static const BIOMETRIC_AUTH = '/biometric-auth'; static const LOGIN = '/login'; static const DASHBOARD = '/dashboard'; } diff --git a/musadaq-app/lib/core/utils/app_snackbar.dart b/musadaq-app/lib/core/utils/app_snackbar.dart new file mode 100644 index 0000000..fb5b539 --- /dev/null +++ b/musadaq-app/lib/core/utils/app_snackbar.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class AppSnackbar { + static void showSuccess(String title, String message) { + Get.snackbar( + title, + message, + backgroundColor: Colors.green.shade600, + colorText: Colors.white, + icon: const Icon(Icons.check_circle, color: Colors.white), + snackPosition: SnackPosition.BOTTOM, + margin: const EdgeInsets.all(16), + borderRadius: 12, + duration: const Duration(seconds: 3), + ); + } + + static void showError(String title, String message) { + Get.snackbar( + title, + message, + backgroundColor: Colors.red.shade600, + colorText: Colors.white, + icon: const Icon(Icons.error, color: Colors.white), + snackPosition: SnackPosition.BOTTOM, + margin: const EdgeInsets.all(16), + borderRadius: 12, + duration: const Duration(seconds: 4), + ); + } + + static void showInfo(String title, String message) { + Get.snackbar( + title, + message, + backgroundColor: Colors.blue.shade600, + colorText: Colors.white, + icon: const Icon(Icons.info, color: Colors.white), + snackPosition: SnackPosition.BOTTOM, + margin: const EdgeInsets.all(16), + borderRadius: 12, + duration: const Duration(seconds: 3), + ); + } + + static void showWarning(String title, String message) { + Get.snackbar( + title, + message, + backgroundColor: Colors.orange.shade800, + colorText: Colors.white, + icon: const Icon(Icons.warning, color: Colors.white), + snackPosition: SnackPosition.BOTTOM, + margin: const EdgeInsets.all(16), + borderRadius: 12, + duration: const Duration(seconds: 4), + ); + } +} diff --git a/musadaq-app/lib/core/utils/logger.dart b/musadaq-app/lib/core/utils/logger.dart new file mode 100644 index 0000000..35ed907 --- /dev/null +++ b/musadaq-app/lib/core/utils/logger.dart @@ -0,0 +1,18 @@ +import 'dart:developer' as developer; +import 'package:flutter/foundation.dart'; + +class AppLogger { + /// Custom print function that only logs in debug mode. + static void print(String message, {String name = 'Musadaq'}) { + if (kDebugMode) { + developer.log(message, name: name); + } + } + + /// Custom error logger + static void error(String message, [dynamic error, StackTrace? stackTrace]) { + if (kDebugMode) { + developer.log(message, name: 'Musadaq_Error', error: error, stackTrace: stackTrace); + } + } +} diff --git a/musadaq-app/lib/features/auth/controllers/auth_controller.dart b/musadaq-app/lib/features/auth/controllers/auth_controller.dart index 0b60d6f..7bdb7d7 100644 --- a/musadaq-app/lib/features/auth/controllers/auth_controller.dart +++ b/musadaq-app/lib/features/auth/controllers/auth_controller.dart @@ -5,6 +5,8 @@ import 'dart:io'; import '../../../core/network/dio_client.dart'; import '../../../core/storage/secure_storage.dart'; import '../../../app/routes/app_pages.dart'; +import '../../../core/utils/logger.dart'; +import '../../../core/utils/app_snackbar.dart'; class AuthController extends GetxController { final Dio _dio = DioClient().client; @@ -23,10 +25,13 @@ class AuthController extends GetxController { }); if (response.statusCode == 200) { + AppLogger.print('OTP Request Success: ${response.data}'); + AppSnackbar.showSuccess('نجاح', 'تم إرسال رمز التحقق بنجاح'); Get.toNamed(AppRoutes.OTP_VERIFY); } - } on DioException catch (e) { - Get.snackbar('خطأ', e.response?.data['message'] ?? 'فشل الاتصال بالخادم'); + } on DioException catch (e, stackTrace) { + AppLogger.error('OTP Request Failed', e.response?.data, stackTrace); + AppSnackbar.showError('خطأ', e.response?.data['message'] ?? 'فشل الاتصال بالخادم'); } finally { isLoading.value = false; } @@ -61,17 +66,21 @@ class AuthController extends GetxController { }); if (response.statusCode == 200) { + AppLogger.print('OTP Verify Success. Tokens received.'); final data = response.data['data']; // Save secure data await _storage.saveToken(data['access_token']); await _storage.saveDeviceSecret(data['device_secret']); - // Navigate to Dashboard or Biometric Setup - Get.offAllNamed(AppRoutes.DASHBOARD); + AppSnackbar.showSuccess('مرحباً بك', 'تم تسجيل الدخول بنجاح'); + + // Navigate to Biometric Setup + Get.offAllNamed(AppRoutes.BIOMETRIC_SETUP); } - } on DioException catch (e) { - Get.snackbar('خطأ', e.response?.data['message'] ?? 'رمز التحقق غير صحيح'); + } on DioException catch (e, stackTrace) { + AppLogger.error('OTP Verify Failed', e.response?.data, stackTrace); + AppSnackbar.showError('خطأ', e.response?.data['message'] ?? 'رمز التحقق غير صحيح'); } finally { isLoading.value = false; } diff --git a/musadaq-app/lib/features/auth/controllers/biometric_controller.dart b/musadaq-app/lib/features/auth/controllers/biometric_controller.dart new file mode 100644 index 0000000..e4b2021 --- /dev/null +++ b/musadaq-app/lib/features/auth/controllers/biometric_controller.dart @@ -0,0 +1,72 @@ +import 'package:get/get.dart'; +import 'package:local_auth/local_auth.dart'; +import '../../../core/storage/secure_storage.dart'; +import '../../../app/routes/app_pages.dart'; +import '../../../core/utils/logger.dart'; +import '../../../core/utils/app_snackbar.dart'; + +class BiometricController extends GetxController { + final LocalAuthentication auth = LocalAuthentication(); + final SecureStorage _storage = SecureStorage(); + + var isBiometricAvailable = false.obs; + var isAuthenticating = false.obs; + + @override + void onInit() { + super.onInit(); + checkBiometrics(); + } + + Future checkBiometrics() async { + try { + final canCheck = await auth.canCheckBiometrics; + final isSupported = await auth.isDeviceSupported(); + isBiometricAvailable.value = canCheck || isSupported; + AppLogger.print('Biometrics available: ${isBiometricAvailable.value}'); + } catch (e, stackTrace) { + isBiometricAvailable.value = false; + AppLogger.error('Failed to check biometrics support', e, stackTrace); + } + } + + Future authenticateAndGoToDashboard() async { + // Ensure we have checked biometric status first + await checkBiometrics(); + + if (!isBiometricAvailable.value) { + AppLogger.print('Biometrics not available, going directly to dashboard.'); + Get.offAllNamed(AppRoutes.DASHBOARD); + return; + } + + try { + isAuthenticating.value = true; + bool authenticated = await auth.authenticate( + localizedReason: 'الرجاء التحقق من هويتك للوصول إلى مُصادَق', + options: const AuthenticationOptions( + stickyAuth: true, + biometricOnly: false, // Allows PIN/Pattern fallback + ), + ); + + if (authenticated) { + AppLogger.print('Biometric authentication successful!'); + Get.offAllNamed(AppRoutes.DASHBOARD); + } else { + AppLogger.print('Biometric authentication cancelled or failed.'); + AppSnackbar.showWarning('فشل التحقق', 'لم نتمكن من التحقق من هويتك أو قمت بإلغاء العملية'); + } + } catch (e, stackTrace) { + AppLogger.error('Error during biometric auth', e, stackTrace); + AppSnackbar.showError('خطأ', 'حدث خطأ غير متوقع أثناء قراءة البصمة: $e'); + } finally { + isAuthenticating.value = false; + } + } + + void skipBiometricSetup() { + AppLogger.print('Skipped biometric setup.'); + Get.offAllNamed(AppRoutes.DASHBOARD); + } +} diff --git a/musadaq-app/lib/features/auth/views/biometric_auth_view.dart b/musadaq-app/lib/features/auth/views/biometric_auth_view.dart new file mode 100644 index 0000000..c1ee906 --- /dev/null +++ b/musadaq-app/lib/features/auth/views/biometric_auth_view.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/biometric_controller.dart'; + +class BiometricAuthView extends StatefulWidget { + const BiometricAuthView({super.key}); + + @override + State createState() => _BiometricAuthViewState(); +} + +class _BiometricAuthViewState extends State { + final BiometricController controller = Get.put(BiometricController()); + + @override + void initState() { + super.initState(); + // Auto trigger biometric prompt after view is rendered + WidgetsBinding.instance.addPostFrameCallback((_) { + controller.authenticateAndGoToDashboard(); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.lock_outline, size: 80, color: Color(0xFF0F4C81)), + const SizedBox(height: 24), + const Text( + 'التحقق من الهوية', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + const Text('الرجاء استخدام البصمة لإلغاء القفل'), + const SizedBox(height: 48), + Obx(() => controller.isAuthenticating.value + ? const CircularProgressIndicator() + : IconButton( + icon: const Icon(Icons.fingerprint, size: 64, color: Color(0xFF0F4C81)), + onPressed: () => controller.authenticateAndGoToDashboard(), + ) + ), + ], + ), + ), + ); + } +} diff --git a/musadaq-app/lib/features/auth/views/biometric_setup_view.dart b/musadaq-app/lib/features/auth/views/biometric_setup_view.dart new file mode 100644 index 0000000..6a3d5e5 --- /dev/null +++ b/musadaq-app/lib/features/auth/views/biometric_setup_view.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/biometric_controller.dart'; + +class BiometricSetupView extends StatelessWidget { + BiometricSetupView({super.key}); + + final BiometricController controller = Get.put(BiometricController()); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('حماية التطبيق')), + body: Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.fingerprint, size: 100, color: Color(0xFF0F4C81)), + const SizedBox(height: 32), + const Text( + 'تسجيل الدخول بالبصمة', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + const Text( + 'لحماية بيانات فواتيرك ومعلوماتك الحساسة، نوصي بتفعيل الدخول باستخدام البصمة أو التعرف على الوجه.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey, height: 1.5), + ), + const SizedBox(height: 48), + Obx(() { + if (!controller.isBiometricAvailable.value) { + return const Text('عذراً، ميزة البصمة غير متوفرة في جهازك.', style: TextStyle(color: Colors.red)); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + icon: const Icon(Icons.fingerprint), + label: controller.isAuthenticating.value + ? const CircularProgressIndicator(color: Colors.white) + : const Text('تفعيل ومتابعة', style: TextStyle(fontSize: 18)), + onPressed: controller.isAuthenticating.value + ? null + : () => controller.authenticateAndGoToDashboard(), + ), + const SizedBox(height: 16), + TextButton( + onPressed: () => controller.skipBiometricSetup(), + child: const Text('تخطي في الوقت الحالي', style: TextStyle(color: Colors.grey)), + ) + ], + ); + }), + ], + ), + ), + ), + ); + } +} diff --git a/scripts/migrate_phase3_mobile.php b/scripts/migrate_phase3_mobile.php index ea3c7cb..8ef8066 100644 --- a/scripts/migrate_phase3_mobile.php +++ b/scripts/migrate_phase3_mobile.php @@ -56,7 +56,7 @@ $migrations = [ ", // ─── 2. Users table: Add phone + mobile fields ───────── - 'add_users_phone' => "ALTER TABLE users ADD COLUMN phone VARCHAR(20) NULL AFTER email", + 'add_users_phone' => "ALTER TABLE users ADD COLUMN phone VARCHAR(255) NULL AFTER email", 'add_users_phone_hash' => "ALTER TABLE users ADD COLUMN phone_hash VARCHAR(64) NULL AFTER phone", 'add_users_pin_hash' => "ALTER TABLE users ADD COLUMN pin_hash VARCHAR(255) NULL AFTER password_hash", 'add_users_biometric' => "ALTER TABLE users ADD COLUMN biometric_enabled BOOLEAN DEFAULT FALSE AFTER pin_hash", diff --git a/scripts/schema.sql b/scripts/schema.sql index 76937d7..7b5b864 100644 --- a/scripts/schema.sql +++ b/scripts/schema.sql @@ -21,6 +21,7 @@ CREATE TABLE users ( name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL, + phone VARCHAR(255), role ENUM('super_admin','admin','accountant','viewer') NOT NULL, company_id CHAR(36) NULL, -- assigned company for accountant refresh_token_hash VARCHAR(255) NULL,