diff --git a/.env.example b/.env.example index 5f0c125..8e07ef4 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,7 @@ DB_PASSWORD= JWT_SIGNING_KEY= JWT_ACCESS_TTL_SECONDS=900 JWT_REFRESH_TTL_DAYS=30 +LEGACY_HMAC_ENABLED=false # Configure only after selecting the existing OTP provider and its sandbox. OTP_PROVIDER= diff --git a/IMPLEMENTATION_ROADMAP_AR.md b/IMPLEMENTATION_ROADMAP_AR.md index dbc5c36..849117a 100644 --- a/IMPLEMENTATION_ROADMAP_AR.md +++ b/IMPLEMENTATION_ROADMAP_AR.md @@ -114,7 +114,9 @@ UPLOAD_PRIVATE_PATH= مخطط المحتوى يشمل GIF اختياريًا لكل تمرين مع صورة poster، تعليمات عربية، معدات/عضلات مستهدفة، مستوى صعوبة، بديل، وتنبيهات السلامة. الخطط ترتبط بجلسات يومية وتمارين موصوفة بالمجموعات/التكرارات أو المدة والراحة، مع حقول مراجع تحريرية. النشر يتم فقط للمحتوى المعتمد. لم تُدرج GIFs أو وصفات تدريب سريرية جاهزة في قاعدة البيانات بعد؛ يجب مراجعة المواد من مختص قبل نشرها ولا ينبغي تحويل خطة البداية المرئية في التطبيق إلى ادعاء توصية طبية شخصية. -أضيفت endpoints تأسيسية للمصادقة: `POST /api/v1/auth/request-otp.php`, `verify-otp.php`, `refresh.php`, و`logout.php`. يحفظ التحدي بصمة HMAC قصيرة العمر مع سقف محاولات وحدود إرسال، وتخزن refresh tokens كـSHA-256 hashes مع تدوير وكشف إعادة استخدام. Access tokens بصيغة JWT HS256 قصيرة العمر؛ الصلاحيات تعاد قراءتها من DB عند الطلب. توجد واجهة إدارة إعدادات لا يصل إليها إلا `owner` أو `content_manager` مع سجل تدقيق. محول الإرسال الحالي عقد JSON عام يتطلب `OTP_PROVIDER=generic_json` ولا يرسل إطلاقًا ما لم يُفعّل صراحة؛ ليس تكاملًا مع مزود المستخدم الفعلي بعد، ويجب مواءمة endpoint/header/body/الاستجابة مع توثيق مزوده قبل الإنتاج. لا يوجد بعد واجهة Flutter للدخول ولا ترحيل منفذ على خادم حي. +أضيفت endpoints للمصادقة: `POST /api/v1/auth/request-otp.php`, `verify-otp.php`, `refresh.php`, و`logout.php`. يحفظ التحدي بصمة HMAC قصيرة العمر مع سقف محاولات وحدود إرسال، وتخزن refresh tokens كـSHA-256 hashes مع تدوير وكشف إعادة استخدام. Access tokens بصيغة JWT HS256 قصيرة العمر؛ الصلاحيات تعاد قراءتها من DB عند الطلب. رُبطت واجهة Flutter بالدخول وحفظ refresh token في secure storage، كما ترسل مزامنة التمارين Bearer إلى public endpoints. توجد واجهة إدارة إعدادات لا يصل إليها إلا `owner` أو `content_manager` مع سجل تدقيق. محول الإرسال الحالي عقد JSON عام يتطلب `OTP_PROVIDER=generic_json` ولا يرسل إطلاقًا ما لم يُفعّل صراحة؛ ليس تكاملًا مع مزود المستخدم الفعلي بعد، ويجب مواءمة endpoint/header/body/الاستجابة مع توثيق مزوده قبل الإنتاج. لم تُنفذ migrations على DB حي أو staging بعد. + +أضيفت ملفات منصة Android إلى مشروع Flutter مع صلاحيات GPS والخدمة الأمامية والإشعارات. نجح بناء APK debug وiOS Simulator محليًا، لكن هذا لا يثبت تشغيل الأجهزة أو نشر المتاجر؛ معرّف Android ما زال `com.example.fitness_tracker` تجريبيًا، ويجب تثبيت معرّف الحزمة النهائي قبل إصدار عام. القيم أعلاه أسماء توضيحية. مزود الرسائل وموفر الذكاء الاصطناعي واسم النطاق والسياسات الرقمية لم تُحسم بعد، لذا تبقى حقولها فارغة ولا يُستخدم المثال كإعداد إنتاج. diff --git a/backend/AUTHENTICATION_API.md b/backend/AUTHENTICATION_API.md index e09b874..a77ea64 100644 --- a/backend/AUTHENTICATION_API.md +++ b/backend/AUTHENTICATION_API.md @@ -33,4 +33,4 @@ OTP_HASH_KEY= ## ما لم يكتمل بعد -هذا API غير مربوط بعد بتطبيق Flutter، ونقاط التتبع القديمة ما زالت تستخدم HMAC الانتقالي. قبل الإنتاج يجب نقل التطبيق إلى OTP/refresh الآمن، ثم إيقاف بيانات HMAC القديمة أو حصرها بفترة انتقال معلومة. لم يُختبر اتصال MySQL الفعلي أو إرسال SMS؛ فحوص PHP المتاحة حتى الآن ساكنة فقط. نفّذ migration `001_phone_auth_and_sessions.sql` على قاعدة staging احتياطية أولًا، وتحقق من rate limits والتدوير والإبطال مع مزود OTP في sandbox. +تطبيق Flutter مرتبط الآن بنقاط OTP والجلسات ويخزن الرموز في secure storage؛ مزامنة التمارين ترسل Bearer وتجدد الجلسة عند الحاجة. نقاط التتبع تقبل HMAC فقط إذا ضُبط `LEGACY_HMAC_ENABLED=true` صراحة لفترة ترحيل عميل قديم، وقيمته في المثال `false`. لم يُختبر اتصال MySQL الفعلي أو إرسال SMS؛ فحوص PHP/Dart حتى الآن ساكنة وتحليلية فقط. نفّذ migrations `001_phone_auth_and_sessions.sql` و`002_workout_idempotency.sql` و`003_training_content.sql` على staging بعد backup، وتحقق من rate limits والتدوير والإبطال مع مزود OTP في sandbox قبل الإنتاج. diff --git a/backend/WorkoutValidator.php b/backend/WorkoutValidator.php index 33c2f97..352deaa 100644 --- a/backend/WorkoutValidator.php +++ b/backend/WorkoutValidator.php @@ -8,9 +8,9 @@ class WorkoutValidator { private $errors = []; private const VALID_WORKOUT_TYPES = ['running', 'walking']; - private const MIN_DISTANCE = 100; // meters + private const MIN_DISTANCE = 0; // meters; short/aborted sessions remain syncable private const MAX_DISTANCE = 100000; // 100km - private const MIN_DURATION = 60; // seconds + private const MIN_DURATION = 0; // seconds; timestamp order is validated separately private const MAX_DURATION = 36000; // 10 hours private const MIN_COORDINATES = 2; private const MAX_COORDINATES = 50000; diff --git a/backend/api_history.php b/backend/api_history.php index c980d06..07aca75 100644 --- a/backend/api_history.php +++ b/backend/api_history.php @@ -5,29 +5,37 @@ */ header('Content-Type: application/json'); -require_once 'Database.php'; -require_once 'AuthenticationHandler.php'; +header('Access-Control-Allow-Headers: Content-Type, Authorization, X-API-Key, X-Signature, X-Timestamp'); +require_once __DIR__ . '/Database.php'; +require_once __DIR__ . '/Config.php'; +require_once __DIR__ . '/AuthenticationHandler.php'; +require_once __DIR__ . '/JwtToken.php'; +require_once __DIR__ . '/ApiAuth.php'; +require_once dirname(__DIR__) . '/public/api/v1/_bootstrap.php'; try { - $api_key = $_SERVER['HTTP_X_API_KEY'] ?? null; - $signature = $_SERVER['HTTP_X_SIGNATURE'] ?? null; - $timestamp = $_SERVER['HTTP_X_TIMESTAMP'] ?? null; - - if (!$api_key || !$signature || !$timestamp) { - throw new Exception('Unauthorized', 401); - } - - $auth = new AuthenticationHandler(); $db = Database::getInstance(); - - // Verification (Using empty body for GET request signature) - $authResult = $auth->validateHmacSignature($api_key, $signature, '', $timestamp); - if (!$authResult['valid']) { - throw new Exception($authResult['error'], 401); + if (isset($_SERVER['HTTP_AUTHORIZATION']) && preg_match('/^Bearer\s+/i', $_SERVER['HTTP_AUTHORIZATION'])) { + $user_id = ApiAuth::bearerClaims()['user_id']; + } else { + AppConfig::loadEnvironment(); + if (getenv('LEGACY_HMAC_ENABLED') !== 'true') { + throw new Exception('Bearer authentication required', 401); + } + $api_key = $_SERVER['HTTP_X_API_KEY'] ?? null; + $signature = $_SERVER['HTTP_X_SIGNATURE'] ?? null; + $timestamp = $_SERVER['HTTP_X_TIMESTAMP'] ?? null; + if (!$api_key || !$signature || !$timestamp) { + throw new Exception('Unauthorized', 401); + } + $auth = new AuthenticationHandler(); + $authResult = $auth->validateHmacSignature($api_key, $signature, '', $timestamp); + if (!$authResult['valid']) { + throw new Exception($authResult['error'], 401); + } + $user_id = $authResult['user_id']; } - $user_id = $authResult['user_id']; - // Fetch workouts $stmt = $db->prepare('SELECT * FROM workouts WHERE user_id = ? ORDER BY created_at DESC LIMIT 50'); $stmt->bind_param('i', $user_id); diff --git a/backend/api_workouts.php b/backend/api_workouts.php index a0be143..e7626a2 100644 --- a/backend/api_workouts.php +++ b/backend/api_workouts.php @@ -9,7 +9,7 @@ header('Content-Type: application/json'); header('Access-Control-Allow-Methods: POST, OPTIONS'); -header('Access-Control-Allow-Headers: Content-Type, X-API-Key, X-Signature, X-Timestamp'); +header('Access-Control-Allow-Headers: Content-Type, Authorization, X-API-Key, X-Signature, X-Timestamp'); // Handle CORS preflight if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { @@ -23,39 +23,44 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') { die(json_encode(['error' => 'Method not allowed'])); } -require_once 'Database.php'; -require_once 'AuthenticationHandler.php'; -require_once 'PolylineUtility.php'; -require_once 'WorkoutValidator.php'; +require_once __DIR__ . '/Database.php'; +require_once __DIR__ . '/Config.php'; +require_once __DIR__ . '/AuthenticationHandler.php'; +require_once __DIR__ . '/JwtToken.php'; +require_once __DIR__ . '/ApiAuth.php'; +require_once __DIR__ . '/PolylineUtility.php'; +require_once __DIR__ . '/WorkoutValidator.php'; +require_once dirname(__DIR__) . '/public/api/v1/_bootstrap.php'; try { - // Get request headers - $api_key = getHeader('X-API-Key'); - $signature = getHeader('X-Signature'); - $timestamp = getHeader('X-Timestamp'); - - if (!$api_key || !$signature || !$timestamp) { - throw new Exception('Missing required authentication headers', 400); - } - // Get raw request body for signature verification $rawBody = file_get_contents('php://input'); if (empty($rawBody)) { throw new Exception('Empty request body', 400); } - // Initialize handlers - $auth = new AuthenticationHandler(); $db = Database::getInstance(); - - // Validate HMAC signature - $authResult = $auth->validateHmacSignature($api_key, $signature, $rawBody, $timestamp); - if (!$authResult['valid']) { - throw new Exception($authResult['error'], 401); + if (isset($_SERVER['HTTP_AUTHORIZATION']) && preg_match('/^Bearer\s+/i', $_SERVER['HTTP_AUTHORIZATION'])) { + $user_id = ApiAuth::bearerClaims()['user_id']; + } else { + AppConfig::loadEnvironment(); + if (getenv('LEGACY_HMAC_ENABLED') !== 'true') { + throw new Exception('Bearer authentication required', 401); + } + $api_key = getHeader('X-API-Key'); + $signature = getHeader('X-Signature'); + $timestamp = getHeader('X-Timestamp'); + if (!$api_key || !$signature || !$timestamp) { + throw new Exception('Missing required authentication headers', 401); + } + $auth = new AuthenticationHandler(); + $authResult = $auth->validateHmacSignature($api_key, $signature, $rawBody, $timestamp); + if (!$authResult['valid']) { + throw new Exception($authResult['error'], 401); + } + $user_id = $authResult['user_id']; } - $user_id = $authResult['user_id']; - // Parse and validate JSON payload $payload = json_decode($rawBody, true); if (!is_array($payload)) { diff --git a/deploy/sync-to-server.sh b/deploy/sync-to-server.sh index 07732b4..d33f08c 100755 --- a/deploy/sync-to-server.sh +++ b/deploy/sync-to-server.sh @@ -103,10 +103,13 @@ else [[ ! -e "$staging_dir/.env" && ! -L "$staging_dir/.env" ]] || die 'release must not contain a tracked .env file' ln -s "$shared_dir/.env" "$staging_dir/.env" - if command -v php >/dev/null 2>&1 && [[ -d "$staging_dir/backend" ]]; then - while IFS= read -r -d '' php_file; do - php -l "$php_file" >/dev/null || die "PHP syntax check failed: $php_file" - done < <(find "$staging_dir/backend" -type f -name '*.php' -print0) + if command -v php >/dev/null 2>&1; then + for php_dir in "$staging_dir/backend" "$staging_dir/public"; do + [[ -d "$php_dir" ]] || continue + while IFS= read -r -d '' php_file; do + php -l "$php_file" >/dev/null || die "PHP syntax check failed: $php_file" + done < <(find "$php_dir" -type f -name '*.php' -print0) + done fi mv "$staging_dir" "$release_dir" diff --git a/mobile/.metadata b/mobile/.metadata index 45dedd1..f8e191c 100644 --- a/mobile/.metadata +++ b/mobile/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "90673a4eef275d1a6692c26ac80d6d746d41a73a" + revision: "ee80f08bbf97172ec030b8751ceab557177a34a6" channel: "stable" project_type: app @@ -13,11 +13,14 @@ project_type: app migration: platforms: - platform: root - create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a - base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a + create_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 + base_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 - platform: ios create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a + - platform: android + create_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 + base_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 # User provided section diff --git a/mobile/README.md b/mobile/README.md index f7b09de..046a2e5 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -1,3 +1,16 @@ -# fitness_tracker +# SportPath mobile -A new Flutter project. +تطبيق Flutter عربي لحفظ جلسات المشي والجري محليًا، تسجيل الدخول برقم الهاتف، ومزامنة التمارين إلى PHP API. يحتوي المشروع الآن منصتي iOS وAndroid؛ `android/` يستخدم معرّف تطوير مؤقتًا `com.example.fitness_tracker` ويجب استبداله بمعرّف الحزمة الذي سيعتمده مالك المنتج قبل النشر على المتاجر. + +## عنوان خادم API + +لا يوجد عنوان إنتاج افتراضي داخل التطبيق. مرّر نطاقًا حقيقيًا يعمل عبر HTTPS وقت البناء: + +```bash +flutter build apk --dart-define=API_BASE_URL=https://fitness.example.com +flutter build ios --dart-define=API_BASE_URL=https://fitness.example.com +``` + +يستخدم التطبيق مسارات `API_BASE_URL/api/v1/auth/` للمصادقة و`API_BASE_URL/api/v1/workouts.php` لمزامنة سجل التمرين. لا تضع مفاتيح OTP أو JWT أو قواعد البيانات في `--dart-define`؛ هذه تبقى على الخادم. يلزم إعداد مزود OTP وتطبيق migrations على قاعدة staging قبل نجاح تسجيل الدخول. + +جلسة التطبيق تحتفظ بـrefresh token في التخزين الآمن للنظام وتحدّث access token تلقائيًا عند استجابة 401. بيانات GPS والتمارين تبقى محليًا في SQLite/outbox إلى حين تسجيل الدخول والاتصال. لا تحذف التطبيق أو بياناته أثناء المزامنة إن كان لديك سجل غير متزامن. diff --git a/mobile/android/.gitignore b/mobile/android/.gitignore new file mode 100644 index 0000000..be82512 --- /dev/null +++ b/mobile/android/.gitignore @@ -0,0 +1,11 @@ +/.gradle +/captures/ +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts new file mode 100644 index 0000000..8c0276a --- /dev/null +++ b/mobile/android/app/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.fitness_tracker" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.fitness_tracker" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/mobile/android/app/src/debug/AndroidManifest.xml b/mobile/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/mobile/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..986be06 --- /dev/null +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/android/app/src/main/kotlin/com/example/fitness_tracker/MainActivity.kt b/mobile/android/app/src/main/kotlin/com/example/fitness_tracker/MainActivity.kt new file mode 100644 index 0000000..0594191 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/com/example/fitness_tracker/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.fitness_tracker + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/mobile/android/app/src/main/res/drawable-v21/launch_background.xml b/mobile/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/mobile/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/mobile/android/app/src/main/res/drawable/launch_background.xml b/mobile/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/mobile/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/values-night/styles.xml b/mobile/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/mobile/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/mobile/android/app/src/main/res/values/styles.xml b/mobile/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/mobile/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/mobile/android/app/src/profile/AndroidManifest.xml b/mobile/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/mobile/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/mobile/android/build.gradle.kts b/mobile/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/mobile/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/mobile/android/gradle.properties b/mobile/android/gradle.properties new file mode 100644 index 0000000..e96108c --- /dev/null +++ b/mobile/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false diff --git a/mobile/android/gradle/wrapper/gradle-wrapper.jar b/mobile/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..13372ae Binary files /dev/null and b/mobile/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/mobile/android/gradle/wrapper/gradle-wrapper.properties b/mobile/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2d428bf --- /dev/null +++ b/mobile/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip diff --git a/mobile/android/gradlew b/mobile/android/gradlew new file mode 100755 index 0000000..9d82f78 --- /dev/null +++ b/mobile/android/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/mobile/android/gradlew.bat b/mobile/android/gradlew.bat new file mode 100644 index 0000000..aec9973 --- /dev/null +++ b/mobile/android/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/mobile/android/settings.gradle.kts b/mobile/android/settings.gradle.kts new file mode 100644 index 0000000..c21f0c5 --- /dev/null +++ b/mobile/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.0.1" apply false + id("org.jetbrains.kotlin.android") version "2.3.20" apply false +} + +include(":app") diff --git a/mobile/lib/controllers/workout_controller.dart b/mobile/lib/controllers/workout_controller.dart index d62451f..fe961fa 100644 --- a/mobile/lib/controllers/workout_controller.dart +++ b/mobile/lib/controllers/workout_controller.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:get/get.dart'; import 'package:uuid/uuid.dart'; import '../models/gps_coordinate.dart'; @@ -27,6 +28,7 @@ class WorkoutController extends GetxController { final RxDouble liveSpeed = 0.0.obs; Timer? _timer; + Future _coordinateWriteQueue = Future.value(); // ─── التحكم في الجلسة ────────────────────────────────────────── @@ -42,6 +44,7 @@ class WorkoutController extends GetxController { currentWorkout.value!.start(); liveCoordinates.clear(); + _coordinateWriteQueue = Future.value(); elapsedSeconds.value = 0; liveDistance.value = 0; liveSpeed.value = 0; @@ -75,6 +78,7 @@ class WorkoutController extends GetxController { await _gps.stopTracking(); _timer?.cancel(); + await _coordinateWriteQueue; final workout = currentWorkout.value!; workout.stop(); @@ -101,7 +105,12 @@ class WorkoutController extends GetxController { await _sync.queueWorkout(workout); currentWorkout.value = null; - Get.snackbar("تم الحفظ", "تم حفظ التمرين بنجاح وجاري المزامنة..."); + Get.snackbar( + "تم الحفظ", + workout.coordinates.length >= 2 + ? "تم حفظ التمرين محليًا وجاري المزامنة عند توفر الاتصال." + : "حُفظ النشاط على الجهاز؛ لم تتوفر نقاط GPS كافية لمزامنته.", + ); } void _handleNewCoordinate(GpsCoordinate coord) { @@ -111,7 +120,12 @@ class WorkoutController extends GetxController { liveSpeed.value = coord.speed ?? 0; // حفظ فوري في DB (للحماية من انهيار التطبيق) - _db.saveCoordinate(currentWorkout.value!.id, coord); + final workoutId = currentWorkout.value!.id; + _coordinateWriteQueue = _coordinateWriteQueue.then((_) async { + await _db.saveCoordinate(workoutId, coord); + }).catchError((Object error, StackTrace stackTrace) { + debugPrint('[TRACKING] Coordinate persistence failed: $error'); + }); // حساب المسافة التراكمية حياً if (liveCoordinates.length > 1) { diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 617ea5d..7f524d8 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -1,18 +1,21 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'services/database_service.dart'; +import 'services/auth_service.dart'; import 'services/gps_service.dart'; import 'services/sync_service.dart'; import 'services/analytics_service.dart'; import 'controllers/workout_controller.dart'; import 'screens/home_screen.dart'; +import 'screens/phone_login_screen.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); // 1. تهيئة الخدمات (Services Initialization) - // بالترتيب: قاعدة البيانات -> الـ GPS -> المزامنة -> التحليل + // بالترتيب: قاعدة البيانات -> الهوية -> GPS -> المزامنة -> التحليل await Get.putAsync(() => DatabaseService().init()); + await Get.putAsync(() => AuthService().init()); Get.put(GpsService()); await Get.putAsync(() => SyncService().init()); Get.put(AnalyticsService()); @@ -49,7 +52,9 @@ class SportPathApp extends StatelessWidget { RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), ), ), - home: const HomeScreen(), + home: Get.find().isSignedIn + ? const HomeScreen() + : const PhoneLoginScreen(), ); } } diff --git a/mobile/lib/screens/home_screen.dart b/mobile/lib/screens/home_screen.dart index ec4d384..da5e201 100644 --- a/mobile/lib/screens/home_screen.dart +++ b/mobile/lib/screens/home_screen.dart @@ -4,7 +4,9 @@ import '../controllers/workout_controller.dart'; import '../models/workout.dart'; import '../screens/history_screen.dart'; import '../services/sync_service.dart'; +import '../services/auth_service.dart'; import '../utils/formatters.dart'; +import 'phone_login_screen.dart'; const _ink = Color(0xFF17251E); const _green = Color(0xFF456C52); @@ -73,6 +75,23 @@ class HomeScreen extends StatelessWidget { const TextStyle(color: _ink, fontSize: 11)), ]), )), + PopupMenuButton( + tooltip: 'الحساب', + icon: const Icon(Icons.account_circle_outlined, + color: _green, size: 25), + onSelected: (value) async { + if (value == 'logout') { + await Get.find().logout(); + Get.offAll(() => const PhoneLoginScreen()); + } + }, + itemBuilder: (context) => const [ + PopupMenuItem( + value: 'logout', + child: Text('تسجيل الخروج'), + ), + ], + ), ]), const SizedBox(height: 34), Text(_arabicToday(), diff --git a/mobile/lib/screens/phone_login_screen.dart b/mobile/lib/screens/phone_login_screen.dart new file mode 100644 index 0000000..37333d3 --- /dev/null +++ b/mobile/lib/screens/phone_login_screen.dart @@ -0,0 +1,287 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../services/auth_service.dart'; +import '../services/sync_service.dart'; +import 'home_screen.dart'; + +const _loginInk = Color(0xFF17251E); +const _loginGreen = Color(0xFF456C52); + +class PhoneLoginScreen extends StatefulWidget { + const PhoneLoginScreen({super.key}); + + @override + State createState() => _PhoneLoginScreenState(); +} + +class _PhoneLoginScreenState extends State { + final _phone = TextEditingController(); + final _code = TextEditingController(); + final _name = TextEditingController(); + bool _registration = false; + bool _codeSent = false; + bool _busy = false; + + @override + void dispose() { + _phone.dispose(); + _code.dispose(); + _name.dispose(); + super.dispose(); + } + + Future _requestCode() async { + final phone = _phone.text.trim(); + if (!RegExp(r'^\+[1-9][0-9]{7,14}$').hasMatch(phone)) { + _showMessage('اكتب رقمك بصيغة دولية، مثل ‎+9627XXXXXXXX.'); + return; + } + setState(() => _busy = true); + try { + await Get.find().requestOtp( + phoneE164: phone, + isRegistration: _registration, + ); + if (mounted) setState(() => _codeSent = true); + _showMessage('أرسلنا رمز التحقق إذا كانت الخدمة مفعّلة.'); + } catch (error) { + _showMessage(error.toString()); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _verifyCode() async { + if (!RegExp(r'^\d{6}$').hasMatch(_code.text.trim())) { + _showMessage('أدخل رمز التحقق المكوّن من ٦ أرقام.'); + return; + } + if (_registration && _name.text.trim().isEmpty) { + _showMessage('اكتب الاسم الذي تود ظهوره في حسابك.'); + return; + } + setState(() => _busy = true); + try { + await Get.find().verifyOtp( + phoneE164: _phone.text.trim(), + code: _code.text.trim(), + isRegistration: _registration, + displayName: _name.text.trim().isEmpty ? null : _name.text.trim(), + ); + if (mounted) { + Get.offAll(() => const HomeScreen()); + Get.find().syncPendingWorkouts(retryNow: true); + } + } catch (error) { + _showMessage(error.toString()); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + void _showMessage(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(message))); + } + + @override + Widget build(BuildContext context) { + final auth = Get.find(); + return Scaffold( + backgroundColor: const Color(0xFFF5F7F2), + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 32, 24, 28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Align( + alignment: AlignmentDirectional.centerStart, + child: Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: _loginGreen, + borderRadius: BorderRadius.circular(15), + ), + alignment: Alignment.center, + child: const Text('S', + style: TextStyle( + color: Colors.white, + fontFamily: 'Georgia', + fontSize: 27, + fontStyle: FontStyle.italic)), + ), + ), + const SizedBox(height: 40), + const Text('أهلًا بك في SportPath', + style: TextStyle( + color: _loginGreen, + fontSize: 13, + fontWeight: FontWeight.w600)), + const SizedBox(height: 9), + Text( + _codeSent ? 'تحقق من رقمك.' : 'رحلتك تبدأ من هنا.', + style: const TextStyle( + color: _loginInk, + fontSize: 30, + height: 1.25, + fontWeight: FontWeight.w600, + letterSpacing: -.7), + ), + const SizedBox(height: 11), + Text( + _codeSent + ? 'أدخل الرمز المكوّن من ٦ أرقام الذي وصلك برسالة.' + : 'استخدم رقم هاتفك لحفظ تقدمك ومزامنة نشاطك بأمان.', + style: const TextStyle( + color: Color(0xFF778078), fontSize: 14, height: 1.8), + ), + const SizedBox(height: 24), + if (!_codeSent) ...[ + SegmentedButton( + segments: const [ + ButtonSegment( + value: false, label: Text('تسجيل الدخول')), + ButtonSegment(value: true, label: Text('حساب جديد')), + ], + selected: {_registration}, + onSelectionChanged: _busy + ? null + : (value) => + setState(() => _registration = value.first), + ), + const SizedBox(height: 18), + if (_registration) ...[ + _InputField( + controller: _name, + label: 'الاسم', + hint: 'كيف نناديك؟', + keyboardType: TextInputType.name), + const SizedBox(height: 12), + ], + _InputField( + controller: _phone, + label: 'رقم الهاتف', + hint: '+9627XXXXXXXX', + keyboardType: TextInputType.phone, + direction: TextDirection.ltr), + ] else ...[ + _InputField( + controller: _phone, + label: 'رقم الهاتف', + hint: '+9627XXXXXXXX', + keyboardType: TextInputType.phone, + direction: TextDirection.ltr, + enabled: false), + const SizedBox(height: 12), + _InputField( + controller: _code, + label: 'رمز التحقق', + hint: '000000', + keyboardType: TextInputType.number, + direction: TextDirection.ltr, + maxLength: 6), + const SizedBox(height: 9), + TextButton( + onPressed: _busy + ? null + : () => setState(() { + _codeSent = false; + _code.clear(); + }), + child: const Text('تغيير الرقم أو طلب رمز جديد')), + ], + const SizedBox(height: 13), + FilledButton( + onPressed: + _busy ? null : (_codeSent ? _verifyCode : _requestCode), + style: FilledButton.styleFrom( + backgroundColor: _loginGreen, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15))), + child: _busy + ? const SizedBox( + width: 21, + height: 21, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)) + : Text(_codeSent ? 'تأكيد الرمز' : 'إرسال رمز التحقق', + style: const TextStyle( + fontSize: 14, fontWeight: FontWeight.w600)), + ), + const SizedBox(height: 16), + if (!auth.isApiConfigured) + const Text( + 'نسخة التطوير تحتاج عنوان API آمنًا عبر API_BASE_URL قبل تسجيل الدخول.', + textAlign: TextAlign.center, + style: TextStyle( + color: Color(0xFF8A6254), + fontSize: 11, + height: 1.7)), + const SizedBox(height: 22), + const Text( + 'نستخدم رقمك للتحقق من الحساب فقط. لا نقرأ بيانات البصمة أو الوجه؛ المصادقة الحيوية تبقى داخل نظام جهازك.', + textAlign: TextAlign.center, + style: TextStyle( + color: Color(0xFF899189), fontSize: 10, height: 1.8)), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _InputField extends StatelessWidget { + const _InputField( + {required this.controller, + required this.label, + required this.hint, + required this.keyboardType, + this.direction, + this.enabled = true, + this.maxLength}); + final TextEditingController controller; + final String label; + final String hint; + final TextInputType keyboardType; + final TextDirection? direction; + final bool enabled; + final int? maxLength; + + @override + Widget build(BuildContext context) => TextField( + controller: controller, + enabled: enabled, + keyboardType: keyboardType, + textDirection: direction, + maxLength: maxLength, + style: const TextStyle(color: _loginInk, fontSize: 15), + decoration: InputDecoration( + labelText: label, + hintText: hint, + counterText: '', + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(15), + borderSide: BorderSide.none), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(15), + borderSide: const BorderSide(color: Color(0xFFE6EBE3))), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(15), + borderSide: const BorderSide(color: _loginGreen, width: 1.4)), + ), + ); +} diff --git a/mobile/lib/services/analytics_service.dart b/mobile/lib/services/analytics_service.dart index 1083e88..a977d86 100644 --- a/mobile/lib/services/analytics_service.dart +++ b/mobile/lib/services/analytics_service.dart @@ -7,10 +7,10 @@ import '../utils/geo_utils.dart'; /// خدمة التحليل الذكي (Smart Analytics Service) /// مسؤولة عن تقسيم المسار إلى أجزاء، كشف التوقفات، وتحليل أنماط الأداء. class AnalyticsService extends GetxService { - /// تحليل المسار وتقسيمه إلى أجزاء (Segments) /// يعتمد على تغيرات السرعة، الارتفاع، أو الاتجاه. - List analyzeSegments(List coords, WorkoutType type) { + List analyzeSegments( + List coords, WorkoutType type) { if (coords.length < 5) return []; List segments = []; @@ -20,35 +20,38 @@ class AnalyticsService extends GetxService { // 1. تحديد مرحلة الإحماء (Warm-up) // نعتبر أول 10% من النقاط أو أول 500 متر إحماء إذا كانت السرعة متدرجة int warmupEnd = (coords.length * 0.1).toInt().clamp(5, 50); - segments.add(_createSegment(coords, 0, warmupEnd, segmentCounter++, SegmentType.warmup)); + segments.add(_createSegment( + coords, 0, warmupEnd, segmentCounter++, SegmentType.warmup)); startIdx = warmupEnd; // 2. تحليل النقاط الوسطى لكشف التوقفات أو التسارعات for (int i = startIdx + 1; i < coords.length - 5; i++) { final current = coords[i]; - final prev = coords[i - 1]; - // كشف التوقف (Stop detection) // إذا كانت السرعة شبه صفرية لمسافة معينة if (current.speed != null && current.speed! < 0.3) { int stopEnd = i; - while (stopEnd < coords.length - 1 && - (coords[stopEnd].speed ?? 0) < 0.3 && - stopEnd - i < 20) { // توقف بحد أقصى 20 نقطة لتجنب المقاطع الضخمة + while (stopEnd < coords.length - 1 && + (coords[stopEnd].speed ?? 0) < 0.3 && + stopEnd - i < 20) { + // توقف بحد أقصى 20 نقطة لتجنب المقاطع الضخمة stopEnd++; } - - if (stopEnd - i > 3) { // توقف حقيقي لأكثر من 10 ثواني تقريباً + + if (stopEnd - i > 3) { + // توقف حقيقي لأكثر من 10 ثواني تقريباً // إغلاق المقطع السابق إذا كان هناك مسافة if (i > startIdx) { - segments.add(_createSegment(coords, startIdx, i, segmentCounter++, SegmentType.main)); + segments.add(_createSegment( + coords, startIdx, i, segmentCounter++, SegmentType.main)); } - segments.add(_createSegment(coords, i, stopEnd, segmentCounter++, SegmentType.stop)); + segments.add(_createSegment( + coords, i, stopEnd, segmentCounter++, SegmentType.stop)); i = stopEnd; startIdx = i; } } - + // كشف الميول (Elevation detection) // يمكن التوسع هنا لكشف uphill/downhill } @@ -56,36 +59,37 @@ class AnalyticsService extends GetxService { // 3. تحديد مرحلة التبريد (Cool-down) int cooldownStart = (coords.length * 0.9).toInt(); if (cooldownStart > startIdx) { - segments.add(_createSegment(coords, startIdx, cooldownStart, segmentCounter++, SegmentType.main)); - segments.add(_createSegment(coords, cooldownStart, coords.length - 1, segmentCounter++, SegmentType.cooldown)); + segments.add(_createSegment( + coords, startIdx, cooldownStart, segmentCounter++, SegmentType.main)); + segments.add(_createSegment(coords, cooldownStart, coords.length - 1, + segmentCounter++, SegmentType.cooldown)); } else { - segments.add(_createSegment(coords, startIdx, coords.length - 1, segmentCounter++, SegmentType.main)); + segments.add(_createSegment(coords, startIdx, coords.length - 1, + segmentCounter++, SegmentType.main)); } return segments; } - WorkoutSegment _createSegment(List coords, int start, int end, int index, SegmentType type) { + WorkoutSegment _createSegment(List coords, int start, int end, + int index, SegmentType type) { double dist = 0; double maxSpd = 0; - double sumSpd = 0; double elevChange = 0; for (int i = start + 1; i <= end; i++) { - dist += GeoUtils.haversineDistance( - coords[i-1].latitude, coords[i-1].longitude, - coords[i].latitude, coords[i].longitude - ); + dist += GeoUtils.haversineDistance(coords[i - 1].latitude, + coords[i - 1].longitude, coords[i].latitude, coords[i].longitude); double s = coords[i].speed ?? 0; if (s > maxSpd) maxSpd = s; - sumSpd += s; - - if (coords[i].altitude != null && coords[i-1].altitude != null) { - elevChange += (coords[i].altitude! - coords[i-1].altitude!); + + if (coords[i].altitude != null && coords[i - 1].altitude != null) { + elevChange += (coords[i].altitude! - coords[i - 1].altitude!); } } - int duration = coords[end].timestamp.difference(coords[start].timestamp).inSeconds; + int duration = + coords[end].timestamp.difference(coords[start].timestamp).inSeconds; return WorkoutSegment( segmentIndex: index, @@ -104,16 +108,18 @@ class AnalyticsService extends GetxService { /// مثال: "سرعتك في أول 2 كم تحسنت بنسبة 12%" String generateInsight(List history) { if (history.length < 2) return "ابدأ تمرينك الأول للحصول على تحليلات!"; - + // منطق بسيط للمقارنة بين آخر تمرينين final latest = history[0]; final previous = history[1]; - - if (latest.avgSpeedMps > previous.avgSpeedMps) { - double diff = ((latest.avgSpeedMps - previous.avgSpeedMps) / previous.avgSpeedMps) * 100; + + if (latest.avgSpeedMps > previous.avgSpeedMps && previous.avgSpeedMps > 0) { + double diff = + ((latest.avgSpeedMps - previous.avgSpeedMps) / previous.avgSpeedMps) * + 100; return "أداء رائع! سرعتك المتوسطة تحسنت بنسبة ${diff.toStringAsFixed(1)}% مقارنة بآخر مرة."; } - + return "استمر في التدريب! الاستمرارية هي مفتاح التطور."; } } diff --git a/mobile/lib/services/auth_service.dart b/mobile/lib/services/auth_service.dart new file mode 100644 index 0000000..a23d5eb --- /dev/null +++ b/mobile/lib/services/auth_service.dart @@ -0,0 +1,251 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:http/http.dart' as http; +import 'package:uuid/uuid.dart'; + +class AuthService { + AuthService({http.Client? client}) : _client = client ?? http.Client(); + + static const _apiOrigin = String.fromEnvironment('API_BASE_URL'); + static const _accessKey = 'sportpath_access_token'; + static const _refreshKey = 'sportpath_refresh_token'; + static const _deviceKey = 'sportpath_device_uuid'; + + final FlutterSecureStorage _storage = const FlutterSecureStorage(); + final http.Client _client; + String? _accessToken; + String? _refreshToken; + String? _deviceUuid; + String? _pendingChallengeId; + String? _pendingPhone; + bool? _pendingRegistration; + Future? _refreshing; + bool get isSignedIn => _refreshToken != null && _refreshToken!.isNotEmpty; + bool get isApiConfigured => _apiOrigin.startsWith('https://'); + String get apiBase => '${_apiOrigin.replaceFirst(RegExp(r'/+$'), '')}/api/v1'; + + Future init() async { + _accessToken = await _storage.read(key: _accessKey); + _refreshToken = await _storage.read(key: _refreshKey); + _deviceUuid = await _storage.read(key: _deviceKey); + _deviceUuid ??= const Uuid().v4(); + await _storage.write(key: _deviceKey, value: _deviceUuid); + return this; + } + + Future requestOtp( + {required String phoneE164, required bool isRegistration}) async { + _requireApi(); + final response = await _client + .post( + Uri.parse('$apiBase/auth/request-otp.php'), + headers: const {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'phone_e164': phoneE164, + 'purpose': isRegistration ? 'register' : 'login', + 'device_uuid': _deviceUuid, + }), + ) + .timeout(const Duration(seconds: 20)); + _throwIfNotSuccessful(response); + final body = jsonDecode(response.body) as Map; + _pendingChallengeId = body['challenge_id'] as String?; + _pendingPhone = phoneE164; + _pendingRegistration = isRegistration; + if (_pendingChallengeId == null) { + throw const AuthException('لم يصل معرّف التحقق من الخادم.'); + } + } + + Future verifyOtp({ + required String phoneE164, + required String code, + required bool isRegistration, + String? displayName, + }) async { + _requireApi(); + final challengeId = _pendingChallengeId; + if (challengeId == null || + _pendingPhone != phoneE164 || + _pendingRegistration != isRegistration) { + throw const AuthException('اطلب رمز تحقق جديدًا أولًا.'); + } + final response = await _client + .post( + Uri.parse('$apiBase/auth/verify-otp.php'), + headers: const {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'challenge_id': challengeId, + 'code': code, + 'display_name': displayName, + 'device_uuid': _deviceUuid, + 'platform': + defaultTargetPlatform == TargetPlatform.iOS ? 'ios' : 'android', + }), + ) + .timeout(const Duration(seconds: 20)); + _throwIfNotSuccessful(response); + final body = jsonDecode(response.body) as Map; + _accessToken = body['access_token'] as String?; + _refreshToken = body['refresh_token'] as String?; + if (_accessToken == null || _refreshToken == null) { + throw const AuthException('ردّ الخادم لا يحتوي بيانات جلسة صالحة.'); + } + await _storage.write(key: _accessKey, value: _accessToken); + await _storage.write(key: _refreshKey, value: _refreshToken); + _pendingChallengeId = null; + _pendingPhone = null; + _pendingRegistration = null; + } + + Future authenticatedPost(Uri uri, String body) async { + _requireApi(); + _accessToken ??= await _storage.read(key: _accessKey); + var token = _accessToken; + if (token == null && !await _refreshSession()) { + throw const AuthException('سجّل الدخول لمزامنة بياناتك.'); + } + token = _accessToken; + var response = await _client + .post(uri, headers: _bearerHeaders(token!), body: body) + .timeout(const Duration(seconds: 30)); + if (response.statusCode == 401 && await _refreshSession()) { + response = await _client + .post(uri, headers: _bearerHeaders(_accessToken!), body: body) + .timeout(const Duration(seconds: 30)); + } + return response; + } + + Future _refreshSession() async { + final activeRefresh = _refreshing; + if (activeRefresh != null) return activeRefresh; + final refreshFuture = _performRefresh(); + _refreshing = refreshFuture; + try { + return await refreshFuture; + } finally { + if (identical(_refreshing, refreshFuture)) _refreshing = null; + } + } + + Future _performRefresh() async { + final refreshToken = _refreshToken ?? await _storage.read(key: _refreshKey); + if (refreshToken == null || refreshToken.isEmpty || !isApiConfigured) { + return false; + } + try { + final response = await _client + .post( + Uri.parse('$apiBase/auth/refresh.php'), + headers: const {'Content-Type': 'application/json'}, + body: jsonEncode({'refresh_token': refreshToken}), + ) + .timeout(const Duration(seconds: 20)); + if (response.statusCode != 200) { + if (response.statusCode == 401 || response.statusCode == 403) { + await clearSession(); + } + return false; + } + final body = jsonDecode(response.body) as Map; + _accessToken = body['access_token'] as String?; + _refreshToken = body['refresh_token'] as String?; + if (_accessToken == null || _refreshToken == null) { + await clearSession(); + return false; + } + await _storage.write(key: _accessKey, value: _accessToken); + await _storage.write(key: _refreshKey, value: _refreshToken); + return true; + } catch (error) { + debugPrint('[AUTH] Refresh failed: $error'); + return false; + } + } + + Future logout() async { + final token = _accessToken ?? await _storage.read(key: _accessKey); + if (token != null && isApiConfigured) { + try { + var response = await _client + .post( + Uri.parse('$apiBase/auth/logout.php'), + headers: _bearerHeaders(token), + ) + .timeout(const Duration(seconds: 10)); + if (response.statusCode == 401 && await _refreshSession()) { + response = await _client + .post( + Uri.parse('$apiBase/auth/logout.php'), + headers: _bearerHeaders(_accessToken!), + ) + .timeout(const Duration(seconds: 10)); + } + } catch (error) { + debugPrint('[AUTH] Remote sign out failed: $error'); + } + } + await clearSession(); + } + + Future clearSession() async { + _accessToken = null; + _refreshToken = null; + await _storage.delete(key: _accessKey); + await _storage.delete(key: _refreshKey); + } + + Map _bearerHeaders(String token) => { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }; + + void _requireApi() { + if (!isApiConfigured) { + throw const AuthException( + 'عنوان API غير مضبوط. أضف API_BASE_URL عند بناء التطبيق.'); + } + } + + void _throwIfNotSuccessful(http.Response response) { + if (response.statusCode >= 200 && response.statusCode < 300) return; + String message = 'تعذر إكمال الطلب (${response.statusCode}).'; + try { + final body = jsonDecode(response.body) as Map; + switch (body['error']) { + case 'rate_limited': + message = 'طلبات كثيرة. انتظر قليلًا ثم حاول مجددًا.'; + break; + case 'otp_provider_not_configured': + message = 'خدمة الرسائل لم تُفعّل على الخادم بعد.'; + break; + case 'invalid_code': + message = 'رمز التحقق غير صحيح.'; + break; + case 'invalid_or_expired_challenge': + message = 'انتهت صلاحية رمز التحقق. اطلب رمزًا جديدًا.'; + break; + case 'verification_failed': + message = 'تعذر التحقق من الحساب أو الرمز.'; + break; + case 'account_inactive': + message = 'الحساب غير نشط. تواصل مع الدعم.'; + break; + case 'otp_delivery_failed': + message = 'تعذر إرسال رمز التحقق. حاول لاحقًا.'; + break; + } + } catch (_) {} + throw AuthException(message); + } +} + +class AuthException implements Exception { + const AuthException(this.message); + final String message; + @override + String toString() => message; +} diff --git a/mobile/lib/services/database_service.dart b/mobile/lib/services/database_service.dart index 19744eb..eebb838 100644 --- a/mobile/lib/services/database_service.dart +++ b/mobile/lib/services/database_service.dart @@ -245,7 +245,11 @@ class DatabaseService extends GetxService { } /// Atomically persist a finished workout, its segments, and its sync outbox item. - Future saveCompletedWorkout(Workout workout, String payload) async { + Future saveCompletedWorkout( + Workout workout, + String payload, { + bool queueForSync = true, + }) async { await _db.transaction((txn) async { final saved = await txn.query( 'workouts', @@ -286,6 +290,8 @@ class DatabaseService extends GetxService { await txn.insert('workout_segments', values); } + if (!queueForSync) return; + final queued = await txn.query( 'sync_queue', columns: ['id', 'status'], @@ -438,10 +444,13 @@ class DatabaseService extends GetxService { } /// Get all queued items - Future>> getPendingSyncs() async { + Future>> getPendingSyncs( + {bool includeExhausted = false}) async { return await _db.query( 'sync_queue', - where: "status IN ('queued', 'failed') AND retry_count < 5", + where: includeExhausted + ? "status IN ('queued', 'failed')" + : "status IN ('queued', 'failed') AND retry_count < 5", orderBy: 'created_at ASC', ); } diff --git a/mobile/lib/services/sync_service.dart b/mobile/lib/services/sync_service.dart index db8982f..650131b 100644 --- a/mobile/lib/services/sync_service.dart +++ b/mobile/lib/services/sync_service.dart @@ -3,17 +3,15 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:get/get.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:http/http.dart' as http; import '../models/workout.dart'; -import '../utils/hmac_utility.dart'; +import 'auth_service.dart'; import 'database_service.dart'; /// محرك المزامنة التلقائي /// يراقب الاتصال بالإنترنت ويرسل التمارين المحفوظة محلياً عند توفر الشبكة class SyncService extends GetxService { final DatabaseService _db = Get.find(); - final _storage = const FlutterSecureStorage(); + final AuthService _auth = Get.find(); final _connectivity = Connectivity(); StreamSubscription? _connectivitySub; @@ -27,10 +25,6 @@ class SyncService extends GetxService { bool get isOnline => _isOnline.value; int get pendingCount => _pendingCount.value; - static const String _apiEndpointKey = 'api_endpoint'; - static const String _apiKeyKey = 'api_key'; - static const String _apiSecretKey = 'api_secret'; - Future init() async { await _checkConnectivity(); _startConnectivityMonitor(); @@ -73,44 +67,20 @@ class SyncService extends GetxService { ); } - // ─── Credentials Management ─────────────────────────────────── - - Future saveCredentials({ - required String apiEndpoint, - required String apiKey, - required String apiSecret, - }) async { - await _storage.write(key: _apiEndpointKey, value: apiEndpoint); - await _storage.write(key: _apiKeyKey, value: apiKey); - await _storage.write(key: _apiSecretKey, value: apiSecret); - } - - Future> getCredentials() async { - return { - 'endpoint': await _storage.read(key: _apiEndpointKey), - 'apiKey': await _storage.read(key: _apiKeyKey), - 'apiSecret': await _storage.read(key: _apiSecretKey), - }; - } - - Future hasCredentials() async { - final creds = await getCredentials(); - return creds['apiKey'] != null && - creds['apiKey']!.isNotEmpty && - creds['apiSecret'] != null && - creds['apiSecret']!.isNotEmpty; - } - // ─── Sync Operations ───────────────────────────────────────── /// Queue a workout for sync Future queueWorkout(Workout workout) async { final payload = jsonEncode(workout.toApiPayload()); - await _db.saveCompletedWorkout(workout, payload); + final queueForSync = workout.coordinates.length >= 2 && + workout.routePolyline != null && + workout.routePolyline!.isNotEmpty; + await _db.saveCompletedWorkout(workout, payload, + queueForSync: queueForSync); await _updatePendingCount(); // Try immediate sync if online - if (_isOnline.value) { + if (queueForSync && _isOnline.value) { syncPendingWorkouts(); } } @@ -124,32 +94,29 @@ class SyncService extends GetxService { } /// Process all pending sync items - Future syncPendingWorkouts() async { + Future syncPendingWorkouts({bool retryNow = false}) async { if (_isSyncing.value) return; _isSyncing.value = true; try { - final creds = await getCredentials(); - if (creds['endpoint'] == null || creds['apiKey'] == null || - creds['apiSecret'] == null) { - debugPrint('[SYNC] No API credentials configured'); + if (!_auth.isSignedIn) { + debugPrint('[SYNC] Sign in to sync saved workouts'); return; } - final pendingItems = await _db.getPendingSyncs(); + final pendingItems = await _db.getPendingSyncs( + includeExhausted: retryNow, + ); if (pendingItems.isEmpty) return; debugPrint('[SYNC] Processing ${pendingItems.length} pending items'); for (final item in pendingItems) { - if (!_isRetryDue(item)) continue; + if (!retryNow && !_isRetryDue(item)) continue; await _syncSingleItem( id: item['id'] as int, workoutId: item['workout_id'] as String, payload: item['payload'] as String, - endpoint: creds['endpoint']!, - apiKey: creds['apiKey']!, - apiSecret: creds['apiSecret']!, ); } @@ -166,25 +133,13 @@ class SyncService extends GetxService { required int id, required String workoutId, required String payload, - required String endpoint, - required String apiKey, - required String apiSecret, }) async { try { - final headers = HmacUtility.buildHeaders( - apiKey: apiKey, - apiSecret: apiSecret, - payload: payload, + final response = await _auth.authenticatedPost( + Uri.parse('${_auth.apiBase}/workouts.php'), + payload, ); - final response = await http - .post( - Uri.parse('$endpoint/api/v1/workouts'), - headers: headers, - body: payload, - ) - .timeout(const Duration(seconds: 30)); - if (response.statusCode == 200 || response.statusCode == 201) { await _db.updateSyncQueueItem(id, 'sent'); await _db.updateSyncStatus( diff --git a/public/api/v1/auth/refresh.php b/public/api/v1/auth/refresh.php index 0a3c352..a07e281 100644 --- a/public/api/v1/auth/refresh.php +++ b/public/api/v1/auth/refresh.php @@ -19,6 +19,7 @@ try { $db = Database::getInstance(); $connection = $db->getConnection(); $connection->begin_transaction(); + $transactionOpen = true; $digest = hash('sha256', $refreshToken); $query = $db->prepare('SELECT s.session_uuid, s.family_uuid, s.user_id, s.device_uuid, s.replaced_by, s.revoked_at, s.expires_at, u.uuid, u.phone_e164, u.account_role, u.is_active FROM auth_sessions s JOIN users u ON u.id = s.user_id WHERE s.refresh_token_digest = ? FOR UPDATE'); $query->bind_param('s', $digest); @@ -27,6 +28,7 @@ try { $query->close(); if (!$session) { $connection->rollback(); + $transactionOpen = false; api_json(['error' => 'invalid_refresh_token'], 401); } if ($session['replaced_by'] !== null) { @@ -35,10 +37,12 @@ try { $revoke->execute(); $revoke->close(); $connection->commit(); + $transactionOpen = false; api_json(['error' => 'refresh_token_reuse_detected'], 401); } if ($session['revoked_at'] !== null || $session['expires_at'] <= gmdate('Y-m-d H:i:s') || !(bool) $session['is_active']) { $connection->rollback(); + $transactionOpen = false; api_json(['error' => 'session_expired'], 401); } @@ -54,19 +58,21 @@ try { $replace->bind_param('ss', $newSessionId, $session['session_uuid']); $replace->execute(); $replace->close(); - $connection->commit(); - $ttl = max(60, min(3600, AppConfig::integer('JWT_ACCESS_TTL_SECONDS', 900))); + $accessToken = JwtToken::issue((int) $session['user_id'], $newSessionId, $ttl); + $connection->commit(); + $transactionOpen = false; + api_json([ 'user' => ['id' => (int) $session['user_id'], 'uuid' => $session['uuid'], 'phone_e164' => $session['phone_e164'], 'account_role' => $session['account_role']], - 'access_token' => JwtToken::issue((int) $session['user_id'], $newSessionId, $ttl), + 'access_token' => $accessToken, 'token_type' => 'Bearer', 'expires_in_seconds' => $ttl, 'refresh_token' => $newRefresh, 'refresh_expires_in_days' => $refreshDays, ]); } catch (Throwable $exception) { - if (isset($connection) && $connection instanceof mysqli) { + if (!empty($transactionOpen) && isset($connection) && $connection instanceof mysqli) { try { $connection->rollback(); } catch (Throwable $ignored) {} } error_log('Session refresh failed: ' . $exception->getMessage()); diff --git a/public/api/v1/auth/request-otp.php b/public/api/v1/auth/request-otp.php index f6caae7..8b09f62 100644 --- a/public/api/v1/auth/request-otp.php +++ b/public/api/v1/auth/request-otp.php @@ -37,6 +37,7 @@ try { $db = Database::getInstance(); $connection = $db->getConnection(); $connection->begin_transaction(); + $transactionOpen = true; $rateBuckets = [ ['phone', hash_hmac('sha256', 'phone:' . $phone, $hashKey), 5, 3600], @@ -57,6 +58,7 @@ try { $check->close(); if ($count > $limit) { $connection->rollback(); + $transactionOpen = false; api_json(['error' => 'rate_limited'], 429); } } @@ -70,11 +72,12 @@ try { $insert->execute(); $insert->close(); $connection->commit(); + $transactionOpen = false; (new ConfiguredHttpOtpProvider())->send($phone, $code); api_json(['challenge_id' => $challengeUuid, 'expires_in_seconds' => 300]); } catch (Throwable $exception) { - if (isset($connection) && $connection instanceof mysqli && $connection->errno === 0) { + if (!empty($transactionOpen) && isset($connection) && $connection instanceof mysqli) { try { $connection->rollback(); } catch (Throwable $ignored) {} } error_log('OTP request failed: ' . $exception->getMessage()); diff --git a/public/api/v1/auth/verify-otp.php b/public/api/v1/auth/verify-otp.php index fe1f48d..4ed4f17 100644 --- a/public/api/v1/auth/verify-otp.php +++ b/public/api/v1/auth/verify-otp.php @@ -16,7 +16,8 @@ $platform = is_array($body) ? ($body['platform'] ?? null) : null; if (!is_string($challengeId) || !preg_match('/^[0-9a-f-]{36}$/i', $challengeId) || !is_string($code) || !preg_match('/^[0-9]{6}$/', $code)) { api_json(['error' => 'invalid_verification_payload'], 400); } -if ($displayName !== null && (!is_string($displayName) || mb_strlen($displayName) > 100)) { +$displayNameLength = is_string($displayName) ? preg_match_all('/./us', $displayName) : 0; +if ($displayName !== null && (!is_string($displayName) || $displayNameLength === false || $displayNameLength > 100)) { api_json(['error' => 'invalid_display_name'], 400); } if ($deviceUuid !== null && (!is_string($deviceUuid) || !preg_match('/^[0-9a-f-]{36}$/i', $deviceUuid))) { @@ -39,6 +40,7 @@ try { $db = Database::getInstance(); $connection = $db->getConnection(); $connection->begin_transaction(); + $transactionOpen = true; $challengeQuery = $db->prepare('SELECT challenge_uuid, phone_e164, purpose, code_digest, attempt_count, max_attempts, device_uuid FROM otp_challenges WHERE challenge_uuid = ? AND consumed_at IS NULL AND expires_at > UTC_TIMESTAMP() FOR UPDATE'); $challengeQuery->bind_param('s', $challengeId); $challengeQuery->execute(); @@ -46,10 +48,12 @@ try { $challengeQuery->close(); if (!$challenge || (int) $challenge['attempt_count'] >= (int) $challenge['max_attempts']) { $connection->rollback(); + $transactionOpen = false; api_json(['error' => 'invalid_or_expired_challenge'], 400); } if ($deviceUuid !== null && $challenge['device_uuid'] !== null && !hash_equals($challenge['device_uuid'], $deviceUuid)) { $connection->rollback(); + $transactionOpen = false; api_json(['error' => 'invalid_verification_payload'], 400); } @@ -60,6 +64,7 @@ try { $fail->execute(); $fail->close(); $connection->commit(); + $transactionOpen = false; api_json(['error' => 'invalid_code'], 400); } @@ -69,6 +74,7 @@ try { if ($consume->affected_rows !== 1) { $consume->close(); $connection->rollback(); + $transactionOpen = false; api_json(['error' => 'invalid_or_expired_challenge'], 400); } $consume->close(); @@ -79,11 +85,13 @@ try { $user = $userQuery->get_result()->fetch_assoc(); $userQuery->close(); if (!$user && $challenge['purpose'] === 'login') { - $connection->rollback(); + $connection->commit(); + $transactionOpen = false; api_json(['error' => 'verification_failed'], 400); } if ($user && !(bool) $user['is_active']) { - $connection->rollback(); + $connection->commit(); + $transactionOpen = false; api_json(['error' => 'account_inactive'], 403); } if (!$user) { @@ -122,19 +130,21 @@ try { $sessionInsert->bind_param('ssissi', $sessionId, $familyId, $userId, $resolvedDevice, $refreshDigest, $refreshDays); $sessionInsert->execute(); $sessionInsert->close(); - $connection->commit(); - $accessTtl = max(60, min(3600, AppConfig::integer('JWT_ACCESS_TTL_SECONDS', 900))); + $accessToken = JwtToken::issue($userId, $sessionId, $accessTtl); + $connection->commit(); + $transactionOpen = false; + api_json([ 'user' => ['id' => $userId, 'uuid' => $user['uuid'], 'phone_e164' => $challenge['phone_e164'], 'account_role' => $user['account_role']], - 'access_token' => JwtToken::issue($userId, $sessionId, $accessTtl), + 'access_token' => $accessToken, 'token_type' => 'Bearer', 'expires_in_seconds' => $accessTtl, 'refresh_token' => $refreshToken, 'refresh_expires_in_days' => $refreshDays, ]); } catch (Throwable $exception) { - if (isset($connection) && $connection instanceof mysqli) { + if (!empty($transactionOpen) && isset($connection) && $connection instanceof mysqli) { try { $connection->rollback(); } catch (Throwable $ignored) {} } error_log('OTP verification failed: ' . $exception->getMessage()); diff --git a/public/api/v1/history.php b/public/api/v1/history.php new file mode 100644 index 0000000..9cabc49 --- /dev/null +++ b/public/api/v1/history.php @@ -0,0 +1,3 @@ +