Update: 2026-05-06 04:02:34

This commit is contained in:
Hamza-Ayed
2026-05-06 04:02:34 +03:00
parent 164651eb6d
commit 0dcced4142
13 changed files with 338 additions and 12 deletions

View File

@@ -1,4 +1,20 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Networking & Connectivity -->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<!-- Camera & Storage (Invoice Scanning & Picking) -->
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<!-- Audio (Voice Assistant) -->
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<!-- Security (Biometrics) -->
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
<application
android:label="musadaq_app"
android:name="${applicationName}"

View File

@@ -1,5 +1,5 @@
package com.example.musadaq_app
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.android.FlutterFragmentActivity
class MainActivity : FlutterActivity()
class MainActivity : FlutterFragmentActivity()

View File

@@ -45,5 +45,15 @@
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<!-- Permissions -->
<key>NSFaceIDUsageDescription</key>
<string>تطبيق مُصادَق يحتاج للوصول إلى تقنية التعرف على الوجه/البصمة لتسجيل الدخول بأمان وسرعة.</string>
<key>NSCameraUsageDescription</key>
<string>تطبيق مُصادَق يحتاج للوصول إلى الكاميرا لمسح الفواتير ضوئياً واستخراج البيانات منها.</string>
<key>NSMicrophoneUsageDescription</key>
<string>تطبيق مُصادَق يحتاج للوصول إلى الميكروفون لاستخدام المساعد الصوتي وتسجيل الملاحظات.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>تطبيق مُصادَق يحتاج للوصول إلى الصور لاختيار فواتير محفوظة مسبقاً في معرض الصور.</string>
</dict>
</plist>

View File

@@ -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(),

View File

@@ -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';
}

View File

@@ -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),
);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}

View File

@@ -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<void> 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<void> 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);
}
}

View File

@@ -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<BiometricAuthView> createState() => _BiometricAuthViewState();
}
class _BiometricAuthViewState extends State<BiometricAuthView> {
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(),
)
),
],
),
),
);
}
}

View File

@@ -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)),
)
],
);
}),
],
),
),
),
);
}
}

View File

@@ -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",

View File

@@ -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,