Update: 2026-05-06 04:02:34
This commit is contained in:
@@ -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}"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
60
musadaq-app/lib/core/utils/app_snackbar.dart
Normal file
60
musadaq-app/lib/core/utils/app_snackbar.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
18
musadaq-app/lib/core/utils/logger.dart
Normal file
18
musadaq-app/lib/core/utils/logger.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
52
musadaq-app/lib/features/auth/views/biometric_auth_view.dart
Normal file
52
musadaq-app/lib/features/auth/views/biometric_auth_view.dart
Normal 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(),
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
)
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user