From 98490a17e6dc6c4233d8675ab91f695f6841b02b Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Sun, 4 Oct 2026 01:44:07 +0300 Subject: [PATCH] Connect Flutter phone auth and Android platform --- .env.example | 1 + IMPLEMENTATION_ROADMAP_AR.md | 4 +- backend/AUTHENTICATION_API.md | 2 +- backend/WorkoutValidator.php | 4 +- backend/api_history.php | 44 +-- backend/api_workouts.php | 51 ++-- deploy/sync-to-server.sh | 11 +- mobile/.metadata | 9 +- mobile/README.md | 17 +- mobile/android/.gitignore | 11 + mobile/android/app/build.gradle.kts | 45 +++ .../android/app/src/debug/AndroidManifest.xml | 7 + .../android/app/src/main/AndroidManifest.xml | 51 ++++ .../example/fitness_tracker/MainActivity.kt | 5 + .../res/drawable-v21/launch_background.xml | 12 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values-night/styles.xml | 18 ++ .../app/src/main/res/values/styles.xml | 18 ++ .../app/src/profile/AndroidManifest.xml | 7 + mobile/android/build.gradle.kts | 24 ++ mobile/android/gradle.properties | 6 + .../android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 53636 bytes .../gradle/wrapper/gradle-wrapper.properties | 5 + mobile/android/gradlew | 160 ++++++++++ mobile/android/gradlew.bat | 90 ++++++ mobile/android/settings.gradle.kts | 26 ++ .../lib/controllers/workout_controller.dart | 18 +- mobile/lib/main.dart | 9 +- mobile/lib/screens/home_screen.dart | 19 ++ mobile/lib/screens/phone_login_screen.dart | 287 ++++++++++++++++++ mobile/lib/services/analytics_service.dart | 70 +++-- mobile/lib/services/auth_service.dart | 251 +++++++++++++++ mobile/lib/services/database_service.dart | 15 +- mobile/lib/services/sync_service.dart | 81 ++--- public/api/v1/auth/refresh.php | 14 +- public/api/v1/auth/request-otp.php | 5 +- public/api/v1/auth/verify-otp.php | 24 +- public/api/v1/history.php | 3 + public/api/v1/workouts.php | 3 + 44 files changed, 1271 insertions(+), 168 deletions(-) create mode 100644 mobile/android/.gitignore create mode 100644 mobile/android/app/build.gradle.kts create mode 100644 mobile/android/app/src/debug/AndroidManifest.xml create mode 100644 mobile/android/app/src/main/AndroidManifest.xml create mode 100644 mobile/android/app/src/main/kotlin/com/example/fitness_tracker/MainActivity.kt create mode 100644 mobile/android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 mobile/android/app/src/main/res/drawable/launch_background.xml create mode 100644 mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 mobile/android/app/src/main/res/values-night/styles.xml create mode 100644 mobile/android/app/src/main/res/values/styles.xml create mode 100644 mobile/android/app/src/profile/AndroidManifest.xml create mode 100644 mobile/android/build.gradle.kts create mode 100644 mobile/android/gradle.properties create mode 100644 mobile/android/gradle/wrapper/gradle-wrapper.jar create mode 100644 mobile/android/gradle/wrapper/gradle-wrapper.properties create mode 100755 mobile/android/gradlew create mode 100644 mobile/android/gradlew.bat create mode 100644 mobile/android/settings.gradle.kts create mode 100644 mobile/lib/screens/phone_login_screen.dart create mode 100644 mobile/lib/services/auth_service.dart create mode 100644 public/api/v1/history.php create mode 100644 public/api/v1/workouts.php 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 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..13372aef5e24af05341d49695ee84e5f9b594659 GIT binary patch literal 53636 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ⅅ8zCNKueM5c!$)^mKpZNR!eIlFST57ePGQcqCqedAQ3UaUEzpjM--5V4YO zY22VxQm%$2NDnwfK+jkz=i2>NjAM6&P1DdcO<*Xs1-lzdXWn#LGSxwhPH7N%D8-zCgpFWt@`LgNYI+Fh^~nSiQmwH0^>E>*O$47MqfQza@Ce z1wBw;igLc#V2@y-*~Hp?jA1)+MYYyAt|DV_8RQCrRY@sAviO}wv;3gFdO>TE(=9o? z=S(r=0oT`w24=ihA=~iFV5z$ZG74?rmYn#eanx(!Hkxcr$*^KRFJKYYB&l6$WVsJ^ z-Iz#HYmE)Da@&seqG1fXsTER#adA&OrD2-T(z}Cwby|mQf{0v*v3hq~pzF`U`jenT z=XHXeB|fa?Ws$+9ADO0rco{#~+`VM?IXg7N>M0w1fyW1iiKTA@p$y zSiAJ%-Mg{m>&S4r#Tw@?@7ck}#oFo-iZJCWc`hw_J$=rw?omE{^tc59ftd`xq?jzf zo0bFUI=$>O!45{!c4?0KsJmZ#$vuYpZLo_O^oHTmmLMm0J_a{Nn`q5tG1m=0ecv$T z5H7r0DZGl6be@aJ+;26EGw9JENj0oJ5K0=^f-yBW2I0jqVIU};NBp*gF7_KlQnhB6 z##d$H({^HXj@il`*4^kC42&3)(A|tuhs;LygA-EWFSqpe+%#?6HG6}mE215Z4mjO2 zY2^?5$<8&k`O~#~sSc5Fy`5hg5#e{kG>SAbTxCh{y32fHkNryU_c0_6h&$zbWc63T z7|r?X7_H!9XK!HfZ+r?FvBQ$x{HTGS=1VN<>Ss-7M3z|vQG|N}Frv{h-q623@Jz*@ ziXlZIpAuY^RPlu&=nO)pFhML5=ut~&zWDSsn%>mv)!P1|^M!d5AwmSPIckoY|0u9I zTDAzG*U&5SPf+@c_tE_I!~Npfi$?gX(kn=zZd|tUZ_ez(xP+)xS!8=k(<{9@<+EUx zYQgZhjn(0qA#?~Q+EA9oh_Jx5PMfE3#KIh#*cFIFQGi)-40NHbJO&%ZvL|LAqU=Rw zf?Vr4qkUcKtLr^g-6*N-tfk+v8@#Lpl~SgKyH!+m9?T8B>WDWK22;!i5&_N=%f{__ z-LHb`v-LvKqTJZCx~z|Yg;U_f)VZu~q7trb%C6fOKs#eJosw&b$nmwGwP;Bz`=zK4 z>U3;}T_ptP)w=vJaL8EhW;J#SHA;fr13f=r#{o)`dRMOs-T;lp&Toi@u^oB_^pw=P zp#8Geo2?@!h2EYHY?L;ayT}-Df0?TeUCe8Cto{W0_a>!7Gxmi5G-nIIS;X{flm2De z{SjFG%knZoVa;mtHR_`*6)KEf=dvOT3OgT7C7&-4P#4X^B%VI&_57cBbli()(%zZC?Y0b;?5!f22UleQ=9h4_LkcA!Xsqx@q{ko&tvP_V@7epFs}AIpM{g??PA>U(sk$Gum>2Eu zD{Oy{$OF%~?B6>ixQeK9I}!$O0!T3#Ir8MW)j2V*qyJ z8Bg17L`rg^B_#rkny-=<3fr}Y42+x0@q6POk$H^*p3~Dc@5uYTQ$pfaRnIT}Wxb;- zl!@kkZkS=l)&=y|21veY8yz$t-&7ecA)TR|=51BKh(@n|d$EN>18)9kSQ|GqP?aeM ztXd9C&Md$PPF*FVs*GhoHM2L@D$(Qf%%x zwQBUt!jM~GgwluBcwkgwQ!249uPkNz3u@LSYZgmpHgX|P#8!iKk^vSKZ;?)KE$92d z2U>y}VWJ0&zjrIqddM3dz-nU%>bL&KU%SA|LiiUU7Ka|c=jF|vQ1V)Jz`JZe*j<5U6~RVuBEVJoY~ z&GE+F$f>4lN=X4-|9v*5O*Os>>r87u z!_1NSV?_X&HeFR1fOFb8_P)4lybJ6?1BWK`Tv2;4t|x1<#@17UO|hLGnrB%nu)fDk zfstJ4{X4^Y<8Lj<}g2^kksSefQTMuTo?tJLCh zC~>CR#a0hADw!_Vg*5fJwV{~S(j8)~sn>Oyt(ud2$1YfGck77}xN@3U_#T`q)f9!2 zf>Ia;Gwp2_C>WokU%(z2ec8z94pZyhaK+e>3a9sj^-&*V494;p9-xk+u1Jn#N_&xs z59OI2w=PuTErv|aNcK*>3l^W*p3}fjXJjJAXtBA#%B(-0--s;1U#f8gFYW!JL+iVG zV0SSx5w8eVgE?3Sg@eQv)=x<+-JgpVixZQNaZr}3b8sVyVs$@ndkF5FYKka@b+YAh z#nq_gzlIDKEs_i}H4f)(VQ!FSB}j>5znkVD&W0bOA{UZ7h!(FXrBbtdGA|PE1db>s z$!X)WY)u#7P8>^7Pjjj-kXNBuJX3(pJVetTZRNOnR5|RT5D>xmwxhAn)9KF3J05J; z-Mfb~dc?LUGqozC2p!1VjRqUwwDBnJhOua3vCCB-%ykW_ohSe?$R#dz%@Gym-8-RA zjMa_SJSzIl8{9dV+&63e9$4;{=1}w2=l+_j_Dtt@<(SYMbV-18&%F@Zl7F_5! z@xwJ0wiDdO%{}j9PW1(t+8P7Ud79yjY>x>aZYWJL_NI?bI6Y02`;@?qPz_PRqz(7v``20`- z033Dy|4;y6di|>cz|P-z|6c&3f&g^OAt8aN0Zd&0yZ>dq2aFCsE<~Ucf$v{sL=*++ zBxFSa2lfA+Y%U@B&3D=&CBO&u`#*nNc|PCY7XO<}MnG0VR764XrHtrb5zwC*2F!Lp zE<~Vj0;z!S-|3M4DFxuQ=`ShTf28<9p!81(0hFbGNqF%0gg*orez9!qt8e%o@Yfl@ zhvY}{@3&f??}7<`p>FyU;7?VkKbh8_=csozU=|fH&szgZ{=NDCylQ>EH^x5!K3~-V z)_2Y>0uJ`Z0Pb58y`RL+&n@m9tJ)O<%q#&u#DAIt+-rRt0eSe1MTtMl@W)H$b3D)@ z*A-1bUgZI)>HdcI4&W>P4W5{-j=s5p5`cbQ+{(g0+RDnz!TR^mxSLu_y#SDVKrj8i zA^hi6>jMGM;`$9Vfb-Yf!47b)Ow`2OKtNB=z|Kxa$5O}WPo;(Dc^`q(7X8kkeFyO8 z{XOq^07=u|7*P2`m;>PIFf=i80MKUxsN{d2cX0M+REsE*20+WQ79T9&cqT>=I_U% z{=8~^Isg(Nzo~`4iQfIb_#CVCD>#5h>=-Z#5dH}WxYzn%0)GAm6L2WdUdP=0_h>7f z(jh&7%1i(ZOn+}D8$iGK4Vs{pmHl_w4Qm-46H9>4^{3dz^DZDh+dw)6Xd@CpQNK$j z{CU;-cmpK=egplZ3y3%y=sEnCJ^eYVKXzV8H2_r*fJ*%*B;a1_lOpt6)IT1IAK2eB z{rie|uDJUrbgfUE>~C>@RO|m5ex55F{=~Bb4Cucp{ok7Yf9V}QuZ`#Gc|WaqsQlK- zKaV)iMRR__&Ak2Z=IM9R9g5$WM4u{a^C-7uX*!myEym z#_#p^T!P~#Dx$%^K>Y_nj_3J*E_LwJ60-5Xu=LkJAwcP@|0;a&+|+ZX`Jbj9P5;T% z|KOc}4*#4o{U?09`9Hz`Xo-I!P=9XfIrr*MQ}y=$!qgv?_J38^bNb4kM&_OVg^_=Eu-qG5U(fw0KMgH){C8pazq~51rN97hf#20-7=aK0)N|UM H-+%o-(+5aQ literal 0 HcmV?d00001 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 @@ +