Add SportPath auth APIs and training content foundation
This commit is contained in:
@@ -18,6 +18,7 @@ OTP_PROVIDER=
|
|||||||
OTP_API_URL=
|
OTP_API_URL=
|
||||||
OTP_API_KEY=
|
OTP_API_KEY=
|
||||||
OTP_SENDER_ID=
|
OTP_SENDER_ID=
|
||||||
|
OTP_MESSAGE_TEMPLATE=رمز التحقق الخاص بك هو {code}
|
||||||
OTP_ENABLED=false
|
OTP_ENABLED=false
|
||||||
OTP_HASH_KEY=
|
OTP_HASH_KEY=
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
تاريخ التحديث: 4 أكتوبر 2026
|
تاريخ التحديث: 4 أكتوبر 2026
|
||||||
|
|
||||||
هذه الوثيقة تتمّم [خطة المنتج والبحث](FITNESS_PRODUCT_PLAN_AR.md)، وتحوّل طلب تسجيل الدخول والهاتف والسيرفر والنشر إلى تصميم تنفيذي. لا يوجد مستودع Git أو نطاق أو بيانات CloudPanel مهيأة بعد، لذلك سكربت النشر يجهّز طريقة العمل ولا يتصل بسيرفر الآن.
|
هذه الوثيقة تتمّم [خطة المنتج والبحث](FITNESS_PRODUCT_PLAN_AR.md)، وتحوّل طلب تسجيل الدخول والهاتف والسيرفر والنشر إلى تصميم تنفيذي. مستودع Git موجود على `main` ومتصّل بـ`origin`، لكن النطاق وبيانات CloudPanel والاتصال الفعلي بالخادم لم تجهز بعد؛ لذلك سكربت النشر لا يتصل بسيرفر الآن.
|
||||||
|
|
||||||
## المنتج الذي سنكمله
|
## المنتج الذي سنكمله
|
||||||
|
|
||||||
@@ -74,13 +74,13 @@ Git: مصدر الإصدار → SSH:2101 → نسخة إصدار → تبديل
|
|||||||
|
|
||||||
يفضل نطاق واحد ببنية `/` للموقع و`/api/v1/` للواجهات و`/admin/` للإدارة. ملفات رفع الصور خارج `public/` ولا يمكن طلبها بعنوان مباشر؛ الوصول عبر endpoint يتحقق من ملكية المستخدم. واجهة PHP العامة تبدأ من مجلد `public`، بينما إعدادات الخادم والمكتبات والنسخ والملفات الخاصة تبقى فوق document root.
|
يفضل نطاق واحد ببنية `/` للموقع و`/api/v1/` للواجهات و`/admin/` للإدارة. ملفات رفع الصور خارج `public/` ولا يمكن طلبها بعنوان مباشر؛ الوصول عبر endpoint يتحقق من ملكية المستخدم. واجهة PHP العامة تبدأ من مجلد `public`، بينما إعدادات الخادم والمكتبات والنسخ والملفات الخاصة تبقى فوق document root.
|
||||||
|
|
||||||
المستودع الحالي يضع ملفات PHP مباشرة داخل `backend/` ولا يحوي بعد front controller أو إعداد تحميل environment مكتمل أو ملفات migration واضحة. قبل ربط CloudPanel نعيد تنظيم نقطة الدخول إلى `backend/public/`، نبني bootstrap/config من environment، ونحوّل تغييرات المخطط إلى migrations مرتبة. لا نضع `backend/schema.sql` وحده كترحيل تلقائي على قاعدة إنتاج.
|
ملفات PHP الداخلية باقية داخل `backend/` خارج document root، وأضفنا `public/` كنقطة عرض للموقع وواجهات JSON أولية. يتضمن الموقع صفحة عربية RTL متجاوبة، ويقدم `/api/v1/health.php` فحصًا لا يكشف تفاصيل الاتصال، و`/api/v1/config.php` الإعدادات العامة فقط، و`/api/v1/plans.php` الخطط المنشورة وتمارينها ووسائط GIF والبدائل. أضيفت جداول مكتبة التمارين والخطط والجلسات ومهاجرة `003_training_content.sql`. فشل قاعدة البيانات يعاد كـ503 دون تسريب تفاصيلها. هذه واجهات تأسيسية؛ لا توجد بعد مصادقة مستخدم أو لوحة إدارة ولا ينبغي نشرها كمنتج مكتمل. لا نضع `backend/schema.sql` وحده كترحيل تلقائي على قاعدة إنتاج.
|
||||||
|
|
||||||
قاعدة البيانات في CloudPanel تنشأ باسم ومستخدم مخصصين من واجهة الإدارة، بصلاحيات قاعدة التطبيق فقط، واتصال محلي إن كانت PHP وMySQL على المضيف نفسه. تحفظ نسخة احتياطية دورية وتختبر استعادتها. اسم قاعدة البيانات والمستخدم وكلمة المرور الفعلية تأتي من بيئة الخادم؛ لا تضاف إلى Flutter أو Git.
|
قاعدة البيانات في CloudPanel تنشأ باسم ومستخدم مخصصين من واجهة الإدارة، بصلاحيات قاعدة التطبيق فقط، واتصال محلي إن كانت PHP وMySQL على المضيف نفسه. تحفظ نسخة احتياطية دورية وتختبر استعادتها. اسم قاعدة البيانات والمستخدم وكلمة المرور الفعلية تأتي من بيئة الخادم؛ لا تضاف إلى Flutter أو Git.
|
||||||
|
|
||||||
## إعداد Git وبيئة المشروع
|
## إعداد Git وبيئة المشروع
|
||||||
|
|
||||||
عندما ينشئ المستخدم repository:
|
إعداد Git الحالي:
|
||||||
|
|
||||||
1. نفحص `.gitignore` قبل أول commit؛ نتأكد أن ملفات الأسرار وبيانات الهاتف وملفات قواعد البيانات والبناء غير متتبعة.
|
1. نفحص `.gitignore` قبل أول commit؛ نتأكد أن ملفات الأسرار وبيانات الهاتف وملفات قواعد البيانات والبناء غير متتبعة.
|
||||||
2. ينشأ `.env.example` يحتوي أسماء الإعدادات وقيمًا فارغة/وهمية فقط. ملف `.env` الحقيقي لا يُنشأ بقيم حقيقية في المستودع.
|
2. ينشأ `.env.example` يحتوي أسماء الإعدادات وقيمًا فارغة/وهمية فقط. ملف `.env` الحقيقي لا يُنشأ بقيم حقيقية في المستودع.
|
||||||
@@ -110,7 +110,11 @@ FOOD_AI_API_KEY=
|
|||||||
UPLOAD_PRIVATE_PATH=
|
UPLOAD_PRIVATE_PATH=
|
||||||
```
|
```
|
||||||
|
|
||||||
تم إنشاء repository محليًا على `main` ودفع المحتوى الحالي إلى `origin/main`. إعداد النشر لا يعمل على خادم بعد؛ لا توجد حاليًا بيانات host/site user أو مجلد public صالح.
|
تم إنشاء repository محليًا على `main` ودفع المحتوى الحالي إلى `origin/main`. مجلد `public/` ونقطة دخوله أصبحا موجودين، لكن إعداد النشر لا يعمل على خادم بعد؛ لا توجد حاليًا بيانات host/site user أو نطاق أو قاعدة فعلية.
|
||||||
|
|
||||||
|
مخطط المحتوى يشمل 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 للدخول ولا ترحيل منفذ على خادم حي.
|
||||||
|
|
||||||
القيم أعلاه أسماء توضيحية. مزود الرسائل وموفر الذكاء الاصطناعي واسم النطاق والسياسات الرقمية لم تُحسم بعد، لذا تبقى حقولها فارغة ولا يُستخدم المثال كإعداد إنتاج.
|
القيم أعلاه أسماء توضيحية. مزود الرسائل وموفر الذكاء الاصطناعي واسم النطاق والسياسات الرقمية لم تُحسم بعد، لذا تبقى حقولها فارغة ولا يُستخدم المثال كإعداد إنتاج.
|
||||||
|
|
||||||
@@ -132,8 +136,8 @@ UPLOAD_PRIVATE_PATH=
|
|||||||
|
|
||||||
| المرحلة | النطاق | المعلم الذي يسمح بالانتقال |
|
| المرحلة | النطاق | المعلم الذي يسمح بالانتقال |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 0. مستودع وقاعدة تشغيل | إنشاء Git، حماية الأسرار، توثيق البيئات، تحديد domain وOTP provider؛ إصلاح تهيئة الخدمات والحفظ والمزامنة | نسخ محلية مستقرة، commit أولي نظيف، وبنية staging قابلة للنشر |
|
| 0. مستودع وقاعدة تشغيل | Git والحماية والوثائق ونقطة موقع أولية موجودة؛ يلزم تحديد domain وOTP provider وتهيئة CloudPanel ومراجعة migrations | إعداد staging قابل للنشر مع DB وTLS وhealth check |
|
||||||
| 1. API وهوية | PHP bootstrap، migrations، MySQL، OTP provider adapter، JWT refresh sessions، تسجيل ومصادقة وصلاحيات | OTP sandbox ينجح، إعادة المحاولة آمنة، وحدود المعدل والإبطال موثقة |
|
| 1. API وهوية ومحتوى | health/config/plans APIs تأسيسية ومخطط محتوى موجودان؛ يلزم migrations runner/versioning، OTP provider adapter، JWT refresh sessions، تسجيل ومصادقة وصلاحيات | OTP sandbox ينجح، إعادة المحاولة آمنة، حدود المعدل والإبطال موثقة، وخطط منشورة قابلة للإدارة |
|
||||||
| 2. تجربة التمرين | أسئلة الإعداد، برنامج منشور، 30 GIF، جلسات offline، إشعارات محلية | مدرب يراجع الحركات، جلسة offline محفوظة مرة واحدة، وملخص أسبوعي صحيح |
|
| 2. تجربة التمرين | أسئلة الإعداد، برنامج منشور، 30 GIF، جلسات offline، إشعارات محلية | مدرب يراجع الحركات، جلسة offline محفوظة مرة واحدة، وملخص أسبوعي صحيح |
|
||||||
| 3. الغذاء والتقدم | تحليل صورة، تصحيح الحصة، وصفات محلية، قياسات وتقارير | اختبار وجبات موثق وتكلفة نموذجية مع تسجيل يدوي بديل |
|
| 3. الغذاء والتقدم | تحليل صورة، تصحيح الحصة، وصفات محلية، قياسات وتقارير | اختبار وجبات موثق وتكلفة نموذجية مع تسجيل يدوي بديل |
|
||||||
| 4. الموقع والإدارة | تجربة ويب متجاوبة، لوحة تحرير ومراجعة، التحكم بالإعدادات العامة | الأدوار مفروضة من API والعمليات الإدارية مسجلة |
|
| 4. الموقع والإدارة | تجربة ويب متجاوبة، لوحة تحرير ومراجعة، التحكم بالإعدادات العامة | الأدوار مفروضة من API والعمليات الإدارية مسجلة |
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# مصادقة SportPath — API v1
|
||||||
|
|
||||||
|
هذه الواجهات تأسيس أولي لمصادقة رقم الهاتف. لا تشغّل إرسال OTP قبل إعداد مزود حقيقي ومراجعة عقده على بيئة sandbox. لا يخرج الرمز أو المفاتيح في response أو logs.
|
||||||
|
|
||||||
|
## إعداد الخادم
|
||||||
|
|
||||||
|
ضع القيم في ملف البيئة الخاص خارج مجلد `public/`:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
JWT_SIGNING_KEY=<random secret, at least 32 bytes>
|
||||||
|
JWT_ACCESS_TTL_SECONDS=900
|
||||||
|
JWT_REFRESH_TTL_DAYS=30
|
||||||
|
OTP_PROVIDER=generic_json
|
||||||
|
OTP_API_URL=https://provider.example/api/send
|
||||||
|
OTP_API_KEY=<server-only token>
|
||||||
|
OTP_SENDER_ID=<approved sender>
|
||||||
|
OTP_MESSAGE_TEMPLATE=رمز التحقق الخاص بك هو {code}
|
||||||
|
OTP_ENABLED=true
|
||||||
|
OTP_HASH_KEY=<independent random secret, at least 32 bytes>
|
||||||
|
```
|
||||||
|
|
||||||
|
المحول الحالي يرسل JSON بالشكل `{"to":"+...","sender":"...","message":"..."}` مع `Authorization: Bearer ...`، ويعد أي HTTP 2xx نجاحًا. هذا عقد عام مؤقت وليس افتراضًا عن أي مزود؛ عدّل `ConfiguredHttpOtpProvider` ليتوافق مع توثيق المزود الفعلي قبل تفعيل الإنتاج. يرفض endpoint العناوين غير HTTPS. في حال غياب الإعداد يبقى الطلب مغلقًا ويرجع 503.
|
||||||
|
|
||||||
|
## المسارات
|
||||||
|
|
||||||
|
- `POST /api/v1/auth/request-otp.php`: JSON `{ "phone_e164": "+962…", "purpose": "register|login", "device_uuid": "UUID" }`. يرد `challenge_id` ومدة الصلاحية. حد الإرسال الحالي 5 لكل رقم/ساعة، 20 لكل IP/ساعة، و10 لكل device/ساعة؛ المحاولات لكل تحدٍ 5 خلال 5 دقائق.
|
||||||
|
- `POST /api/v1/auth/verify-otp.php`: JSON `{ "challenge_id": "UUID", "code": "123456", "display_name": "…", "device_uuid": "UUID", "platform": "ios|android|web" }`. يستهلك التحدي مرة واحدة ويصدر JWT وrefresh token عشوائيًا. يجب حفظ refresh token في مخزن آمن على الجهاز، وعدم تسجيل أي token.
|
||||||
|
- `POST /api/v1/auth/refresh.php`: JSON `{ "refresh_token": "…" }`. تدوير الرمز يصدر refresh جديدًا؛ إعادة استخدام رمز سبق تدويره تبطل عائلة الجلسة.
|
||||||
|
- `POST /api/v1/auth/logout.php`: يتطلب `Authorization: Bearer <access-token>` ويبطل الجلسة الحالية.
|
||||||
|
- `/api/v1/admin/settings.php`: يتطلب bearer token ودور `owner` أو `content_manager`؛ GET للقراءة وPATCH للتعديل مع audit trail.
|
||||||
|
|
||||||
|
كل Access Token قصير العمر ويرتبط بجلسة حية في MySQL. الدور وحالة الحساب يعاد التحقق منهما من قاعدة البيانات لكل طلب محمي. لا تُقبل معرفات عتادية ثابتة؛ `device_uuid` معرّف تثبيت عشوائي. ترقية أول مالك تتم يدويًا على قاعدة الخادم بعد التحقق من رقم الهاتف، ولا توجد واجهة bootstrap عامة لمنح دور owner.
|
||||||
|
|
||||||
|
## ما لم يكتمل بعد
|
||||||
|
|
||||||
|
هذا API غير مربوط بعد بتطبيق Flutter، ونقاط التتبع القديمة ما زالت تستخدم HMAC الانتقالي. قبل الإنتاج يجب نقل التطبيق إلى OTP/refresh الآمن، ثم إيقاف بيانات HMAC القديمة أو حصرها بفترة انتقال معلومة. لم يُختبر اتصال MySQL الفعلي أو إرسال SMS؛ فحوص PHP المتاحة حتى الآن ساكنة فقط. نفّذ migration `001_phone_auth_and_sessions.sql` على قاعدة staging احتياطية أولًا، وتحقق من rate limits والتدوير والإبطال مع مزود OTP في sandbox.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
final class ApiAuth
|
||||||
|
{
|
||||||
|
public static function bearerClaims(): array
|
||||||
|
{
|
||||||
|
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
|
||||||
|
if (!preg_match('/^Bearer\s+([A-Za-z0-9_.-]+)$/i', $header, $matches)) {
|
||||||
|
api_json(['error' => 'unauthorized'], 401);
|
||||||
|
}
|
||||||
|
$claims = JwtToken::verify($matches[1]);
|
||||||
|
if ($claims === null) {
|
||||||
|
api_json(['error' => 'unauthorized'], 401);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$stmt = $db->prepare('SELECT u.id, u.account_role, u.is_active FROM auth_sessions s JOIN users u ON u.id = s.user_id WHERE s.session_uuid = ? AND s.user_id = ? AND s.revoked_at IS NULL AND s.replaced_by IS NULL AND s.expires_at > UTC_TIMESTAMP() AND u.is_active = 1 LIMIT 1');
|
||||||
|
$userId = (int) $claims['sub'];
|
||||||
|
$sessionId = $claims['sid'];
|
||||||
|
$stmt->bind_param('si', $sessionId, $userId);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
$user = $result->fetch_assoc();
|
||||||
|
$stmt->close();
|
||||||
|
if (!$user) {
|
||||||
|
api_json(['error' => 'unauthorized'], 401);
|
||||||
|
}
|
||||||
|
return ['user_id' => (int) $user['id'], 'role' => $user['account_role'], 'session_id' => $sessionId];
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
error_log('Bearer authorization check failed: ' . $exception->getMessage());
|
||||||
|
api_json(['error' => 'service_unavailable'], 503);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function requireRole(array $auth, array $allowedRoles): void
|
||||||
|
{
|
||||||
|
if (!in_array($auth['role'] ?? null, $allowedRoles, true)) {
|
||||||
|
api_json(['error' => 'forbidden'], 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,8 +25,7 @@ class Database {
|
|||||||
$this->connection->query("SET time_zone = '+00:00'");
|
$this->connection->query("SET time_zone = '+00:00'");
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
error_log('Database initialization failed: ' . $e->getMessage());
|
error_log('Database initialization failed: ' . $e->getMessage());
|
||||||
http_response_code(500);
|
throw new RuntimeException('Database connection failed', 0, $e);
|
||||||
die(json_encode(['error' => 'Database connection failed']));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +108,8 @@ class Database {
|
|||||||
// Prevent cloning
|
// Prevent cloning
|
||||||
final class SingletonDatabase extends Database {
|
final class SingletonDatabase extends Database {
|
||||||
private function __clone() {}
|
private function __clone() {}
|
||||||
private function __wakeup() {}
|
public function __wakeup() {
|
||||||
|
throw new LogicException('Database singleton cannot be unserialized');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
final class JwtToken
|
||||||
|
{
|
||||||
|
public static function issue(int $userId, string $sessionId, int $ttlSeconds): string
|
||||||
|
{
|
||||||
|
$key = AppConfig::required('JWT_SIGNING_KEY');
|
||||||
|
if (strlen($key) < 32) {
|
||||||
|
throw new RuntimeException('JWT_SIGNING_KEY must be at least 32 bytes');
|
||||||
|
}
|
||||||
|
$now = time();
|
||||||
|
$header = self::base64UrlEncode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
|
||||||
|
$payload = self::base64UrlEncode(json_encode([
|
||||||
|
'iss' => rtrim((string) (getenv('APP_URL') ?: ''), '/'),
|
||||||
|
'sub' => (string) $userId,
|
||||||
|
'sid' => $sessionId,
|
||||||
|
'iat' => $now,
|
||||||
|
'exp' => $now + $ttlSeconds,
|
||||||
|
'jti' => bin2hex(random_bytes(16)),
|
||||||
|
], JSON_UNESCAPED_SLASHES));
|
||||||
|
$signingInput = $header . '.' . $payload;
|
||||||
|
return $signingInput . '.' . self::base64UrlEncode(hash_hmac('sha256', $signingInput, $key, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function verify(string $token): ?array
|
||||||
|
{
|
||||||
|
$parts = explode('.', $token);
|
||||||
|
if (count($parts) !== 3) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
[$headerPart, $payloadPart, $signaturePart] = $parts;
|
||||||
|
$header = json_decode(self::base64UrlDecode($headerPart), true);
|
||||||
|
$claims = json_decode(self::base64UrlDecode($payloadPart), true);
|
||||||
|
if (!is_array($header) || ($header['alg'] ?? null) !== 'HS256' || !is_array($claims)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$key = AppConfig::required('JWT_SIGNING_KEY');
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (strlen($key) < 32) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$expected = self::base64UrlEncode(hash_hmac('sha256', $headerPart . '.' . $payloadPart, $key, true));
|
||||||
|
if (!hash_equals($expected, $signaturePart) || (int) ($claims['exp'] ?? 0) <= time()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!ctype_digit((string) ($claims['sub'] ?? '')) || !is_string($claims['sid'] ?? null)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $claims;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function base64UrlEncode(string $value): string
|
||||||
|
{
|
||||||
|
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function base64UrlDecode(string $value): string
|
||||||
|
{
|
||||||
|
$decoded = base64_decode(strtr($value, '-_', '+/'), true);
|
||||||
|
return $decoded === false ? '' : $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
interface OtpProvider
|
||||||
|
{
|
||||||
|
public function send(string $phoneE164, string $code): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal HTTP JSON adapter. Map request fields to the selected vendor contract only after confirmation. */
|
||||||
|
final class ConfiguredHttpOtpProvider implements OtpProvider
|
||||||
|
{
|
||||||
|
public function send(string $phoneE164, string $code): void
|
||||||
|
{
|
||||||
|
AppConfig::loadEnvironment();
|
||||||
|
if (getenv('OTP_ENABLED') !== 'true') {
|
||||||
|
throw new RuntimeException('OTP provider is disabled');
|
||||||
|
}
|
||||||
|
if (AppConfig::required('OTP_PROVIDER') !== 'generic_json') {
|
||||||
|
throw new RuntimeException('Configure a supported OTP provider adapter before enabling delivery');
|
||||||
|
}
|
||||||
|
$url = AppConfig::required('OTP_API_URL');
|
||||||
|
$apiKey = AppConfig::required('OTP_API_KEY');
|
||||||
|
if (!filter_var($url, FILTER_VALIDATE_URL) || parse_url($url, PHP_URL_SCHEME) !== 'https') {
|
||||||
|
throw new RuntimeException('OTP endpoint must use HTTPS');
|
||||||
|
}
|
||||||
|
if (!function_exists('curl_init')) {
|
||||||
|
throw new RuntimeException('The cURL extension is required for OTP delivery');
|
||||||
|
}
|
||||||
|
|
||||||
|
$sender = getenv('OTP_SENDER_ID') ?: '';
|
||||||
|
$message = getenv('OTP_MESSAGE_TEMPLATE') ?: 'رمز التحقق الخاص بك هو {code}';
|
||||||
|
if (strpos($message, '{code}') === false) {
|
||||||
|
throw new RuntimeException('OTP message template must include {code}');
|
||||||
|
}
|
||||||
|
$payload = json_encode([
|
||||||
|
'to' => $phoneE164,
|
||||||
|
'sender' => $sender,
|
||||||
|
'message' => str_replace('{code}', $code, $message),
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
if ($payload === false) {
|
||||||
|
throw new RuntimeException('Unable to encode OTP request');
|
||||||
|
}
|
||||||
|
$handle = curl_init($url);
|
||||||
|
curl_setopt_array($handle, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $payload,
|
||||||
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $apiKey],
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 5,
|
||||||
|
CURLOPT_TIMEOUT => 12,
|
||||||
|
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
|
||||||
|
]);
|
||||||
|
$response = curl_exec($handle);
|
||||||
|
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
|
||||||
|
$error = curl_error($handle);
|
||||||
|
curl_close($handle);
|
||||||
|
if ($response === false || $status < 200 || $status >= 300) {
|
||||||
|
error_log('OTP delivery failed; HTTP ' . $status . '; transport error: ' . $error);
|
||||||
|
throw new RuntimeException('OTP provider rejected the request');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
-- Exercise media library and versioned training-plan content.
|
||||||
|
-- Apply to an existing database after backing it up.
|
||||||
|
|
||||||
|
CREATE TABLE exercises (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
exercise_uuid CHAR(36) NOT NULL UNIQUE,
|
||||||
|
slug VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
title_ar VARCHAR(160) NOT NULL,
|
||||||
|
title_en VARCHAR(160) NULL,
|
||||||
|
instructions_ar JSON NOT NULL,
|
||||||
|
target_muscles JSON NOT NULL,
|
||||||
|
equipment JSON NOT NULL,
|
||||||
|
difficulty ENUM('beginner', 'intermediate', 'advanced') NOT NULL DEFAULT 'beginner',
|
||||||
|
movement_type ENUM('strength', 'mobility', 'cardio', 'recovery') NOT NULL,
|
||||||
|
gif_url VARCHAR(500) NULL,
|
||||||
|
gif_poster_url VARCHAR(500) NULL,
|
||||||
|
duration_seconds SMALLINT UNSIGNED NULL,
|
||||||
|
repetitions VARCHAR(80) NULL,
|
||||||
|
safety_notes_ar JSON NOT NULL,
|
||||||
|
alternative_exercise_id BIGINT UNSIGNED NULL,
|
||||||
|
is_published BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
content_revision INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_exercises_public (is_published, movement_type, difficulty),
|
||||||
|
CONSTRAINT fk_exercise_alternative FOREIGN KEY (alternative_exercise_id) REFERENCES exercises(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE training_plans (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
plan_uuid CHAR(36) NOT NULL UNIQUE,
|
||||||
|
slug VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
title_ar VARCHAR(160) NOT NULL,
|
||||||
|
summary_ar TEXT NOT NULL,
|
||||||
|
goal ENUM('general_fitness', 'weight_management', 'mobility', 'endurance') NOT NULL,
|
||||||
|
level ENUM('beginner', 'intermediate', 'advanced') NOT NULL,
|
||||||
|
weeks_duration TINYINT UNSIGNED NOT NULL,
|
||||||
|
source_notes JSON NULL,
|
||||||
|
safety_notes_ar JSON NOT NULL,
|
||||||
|
is_published BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
content_revision INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_plans_public (is_published, goal, level)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE training_plan_sessions (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
plan_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
week_number TINYINT UNSIGNED NOT NULL,
|
||||||
|
day_number TINYINT UNSIGNED NOT NULL,
|
||||||
|
title_ar VARCHAR(160) NOT NULL,
|
||||||
|
session_type ENUM('strength', 'walking', 'mobility', 'rest') NOT NULL,
|
||||||
|
duration_minutes SMALLINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
intensity ENUM('easy', 'moderate', 'vigorous') NOT NULL DEFAULT 'easy',
|
||||||
|
notes_ar TEXT NULL,
|
||||||
|
sort_order SMALLINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
UNIQUE KEY uq_plan_week_day (plan_id, week_number, day_number),
|
||||||
|
CONSTRAINT fk_plan_sessions_plan FOREIGN KEY (plan_id) REFERENCES training_plans(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_plan_sessions_order (plan_id, week_number, sort_order)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE training_session_exercises (
|
||||||
|
session_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
exercise_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
sort_order SMALLINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
sets TINYINT UNSIGNED NULL,
|
||||||
|
reps VARCHAR(60) NULL,
|
||||||
|
duration_seconds SMALLINT UNSIGNED NULL,
|
||||||
|
rest_seconds SMALLINT UNSIGNED NOT NULL DEFAULT 45,
|
||||||
|
PRIMARY KEY (session_id, exercise_id),
|
||||||
|
CONSTRAINT fk_session_exercises_session FOREIGN KEY (session_id) REFERENCES training_plan_sessions(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_session_exercises_exercise FOREIGN KEY (exercise_id) REFERENCES exercises(id) ON DELETE RESTRICT
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -51,6 +51,79 @@ CREATE TABLE app_setting_audit (
|
|||||||
CONSTRAINT fk_settings_audit_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL
|
CONSTRAINT fk_settings_audit_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Curated workout library. GIFs are optional, versioned media URLs, not uploaded inline.
|
||||||
|
CREATE TABLE exercises (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
exercise_uuid CHAR(36) NOT NULL UNIQUE,
|
||||||
|
slug VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
title_ar VARCHAR(160) NOT NULL,
|
||||||
|
title_en VARCHAR(160) NULL,
|
||||||
|
instructions_ar JSON NOT NULL,
|
||||||
|
target_muscles JSON NOT NULL,
|
||||||
|
equipment JSON NOT NULL,
|
||||||
|
difficulty ENUM('beginner', 'intermediate', 'advanced') NOT NULL DEFAULT 'beginner',
|
||||||
|
movement_type ENUM('strength', 'mobility', 'cardio', 'recovery') NOT NULL,
|
||||||
|
gif_url VARCHAR(500) NULL,
|
||||||
|
gif_poster_url VARCHAR(500) NULL,
|
||||||
|
duration_seconds SMALLINT UNSIGNED NULL,
|
||||||
|
repetitions VARCHAR(80) NULL,
|
||||||
|
safety_notes_ar JSON NOT NULL,
|
||||||
|
alternative_exercise_id BIGINT UNSIGNED NULL,
|
||||||
|
is_published BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
content_revision INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_exercises_public (is_published, movement_type, difficulty),
|
||||||
|
CONSTRAINT fk_exercise_alternative FOREIGN KEY (alternative_exercise_id) REFERENCES exercises(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE training_plans (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
plan_uuid CHAR(36) NOT NULL UNIQUE,
|
||||||
|
slug VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
title_ar VARCHAR(160) NOT NULL,
|
||||||
|
summary_ar TEXT NOT NULL,
|
||||||
|
goal ENUM('general_fitness', 'weight_management', 'mobility', 'endurance') NOT NULL,
|
||||||
|
level ENUM('beginner', 'intermediate', 'advanced') NOT NULL,
|
||||||
|
weeks_duration TINYINT UNSIGNED NOT NULL,
|
||||||
|
source_notes JSON NULL,
|
||||||
|
safety_notes_ar JSON NOT NULL,
|
||||||
|
is_published BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
content_revision INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_plans_public (is_published, goal, level)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE training_plan_sessions (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
plan_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
week_number TINYINT UNSIGNED NOT NULL,
|
||||||
|
day_number TINYINT UNSIGNED NOT NULL,
|
||||||
|
title_ar VARCHAR(160) NOT NULL,
|
||||||
|
session_type ENUM('strength', 'walking', 'mobility', 'rest') NOT NULL,
|
||||||
|
duration_minutes SMALLINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
intensity ENUM('easy', 'moderate', 'vigorous') NOT NULL DEFAULT 'easy',
|
||||||
|
notes_ar TEXT NULL,
|
||||||
|
sort_order SMALLINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
UNIQUE KEY uq_plan_week_day (plan_id, week_number, day_number),
|
||||||
|
CONSTRAINT fk_plan_sessions_plan FOREIGN KEY (plan_id) REFERENCES training_plans(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_plan_sessions_order (plan_id, week_number, sort_order)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE training_session_exercises (
|
||||||
|
session_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
exercise_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
sort_order SMALLINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
sets TINYINT UNSIGNED NULL,
|
||||||
|
reps VARCHAR(60) NULL,
|
||||||
|
duration_seconds SMALLINT UNSIGNED NULL,
|
||||||
|
rest_seconds SMALLINT UNSIGNED NOT NULL DEFAULT 45,
|
||||||
|
PRIMARY KEY (session_id, exercise_id),
|
||||||
|
CONSTRAINT fk_session_exercises_session FOREIGN KEY (session_id) REFERENCES training_plan_sessions(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_session_exercises_exercise FOREIGN KEY (exercise_id) REFERENCES exercises(id) ON DELETE RESTRICT
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- Phone OTP challenges contain keyed digests, never the plaintext code.
|
-- Phone OTP challenges contain keyed digests, never the plaintext code.
|
||||||
CREATE TABLE otp_challenges (
|
CREATE TABLE otp_challenges (
|
||||||
challenge_uuid CHAR(36) PRIMARY KEY,
|
challenge_uuid CHAR(36) PRIMARY KEY,
|
||||||
|
|||||||
+2
-2
@@ -10,10 +10,10 @@
|
|||||||
4. أضف مفتاح Git Deploy Key للقراءة فقط إلى حساب Git، واختبر أن Site User يستطيع `git ls-remote` على origin.
|
4. أضف مفتاح Git Deploy Key للقراءة فقط إلى حساب Git، واختبر أن Site User يستطيع `git ls-remote` على origin.
|
||||||
5. أضف بصمة مفتاح SSH الخاص بالخادم إلى `known_hosts` على جهاز النشر. يعمل السكربت مع `StrictHostKeyChecking=yes` ولا يتجاوز التحقق.
|
5. أضف بصمة مفتاح SSH الخاص بالخادم إلى `known_hosts` على جهاز النشر. يعمل السكربت مع `StrictHostKeyChecking=yes` ولا يتجاوز التحقق.
|
||||||
6. أنشئ `<DEPLOY_ROOT>/shared/.env` يدويًا على الخادم، مع إعداد DB ومفاتيح الخدمة. لا ترفع الملف إلى Git. السكربت يوقف النشر إذا كان الملف غير موجود.
|
6. أنشئ `<DEPLOY_ROOT>/shared/.env` يدويًا على الخادم، مع إعداد DB ومفاتيح الخدمة. لا ترفع الملف إلى Git. السكربت يوقف النشر إذا كان الملف غير موجود.
|
||||||
7. جهز التطبيق بحيث يحتوي جذر الإصدار `public/index.php`، ويحمّل إعداداته من `shared/.env`. ملفات الرفع الخاصة تحفظ في shared خارج `public/`.
|
7. يحتوي المستودع الآن `public/index.php` وصفحتي API أوليتين تحت `public/api/v1/`. أعدّ جذر الإصدار ليستخدم `public/` كـdocument root؛ ملف البيئة يقرأ من المسار المشترك خارج web root، وملفات الرفع الخاصة تحفظ في shared خارج `public/`.
|
||||||
8. ثبّت شهادة TLS ووجّه DNS إلى الخادم من CloudPanel، ثم أضف health endpoint قبل أول نشر إنتاجي.
|
8. ثبّت شهادة TLS ووجّه DNS إلى الخادم من CloudPanel، ثم أضف health endpoint قبل أول نشر إنتاجي.
|
||||||
|
|
||||||
CloudPanel ينشئ PHP site ومستخدمه، ويمكن تغيير document root إلى مجلد `public` وفق [دليل CloudPanel](https://www.cloudpanel.io/docs/v2/php/applications/other/). ملفات PHP الحالية موضوعة مباشرة داخل `backend/` ولا يوجد `public/` بعد؛ لذلك السكربت سيرفض إصدارها حتى تُنشأ نقطة دخول عامة آمنة.
|
CloudPanel ينشئ PHP site ومستخدمه، ويمكن تغيير document root إلى مجلد `public` وفق [دليل CloudPanel](https://www.cloudpanel.io/docs/v2/php/applications/other/). واجهة الموقع وفحص الصحة والإعدادات العامة موجودة الآن. تسجيل الدخول ولوحة الإدارة والتطبيق ما زالت قيد التنفيذ، فلا تعتبر نقطة الدخول الحالية إصدارًا إنتاجيًا مكتملًا.
|
||||||
|
|
||||||
## إعداد جهاز النشر
|
## إعداد جهاز النشر
|
||||||
|
|
||||||
|
|||||||
@@ -29,21 +29,21 @@ class SportPathApp extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GetMaterialApp(
|
return GetMaterialApp(
|
||||||
title: 'SportPath Tracker',
|
title: 'SportPath',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
locale: const Locale('ar', 'SA'), // دعم اللغة العربية افتراضياً
|
locale: const Locale('ar', 'JO'), // دعم اللغة العربية افتراضياً
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
fontFamily: 'Inter', // التأكد من وجود الخط في pubspec
|
fontFamily: null,
|
||||||
colorScheme: ColorScheme.fromSeed(
|
colorScheme: ColorScheme.fromSeed(
|
||||||
seedColor: const Color(0xFFFF5722), // اللون البرتقالي الأساسي
|
seedColor: const Color(0xFF456C52),
|
||||||
primary: const Color(0xFFFF5722),
|
primary: const Color(0xFF456C52),
|
||||||
secondary: const Color(0xFF263238),
|
secondary: const Color(0xFF26382D),
|
||||||
brightness: Brightness.dark, // وضع ليلي متميز
|
brightness: Brightness.light,
|
||||||
),
|
),
|
||||||
scaffoldBackgroundColor: const Color(0xFF121212),
|
scaffoldBackgroundColor: const Color(0xFFF5F7F2),
|
||||||
cardTheme: CardThemeData(
|
cardTheme: CardThemeData(
|
||||||
color: const Color(0xFF1E1E1E),
|
color: Colors.white,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape:
|
shape:
|
||||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
|||||||
+428
-207
@@ -2,8 +2,14 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import '../controllers/workout_controller.dart';
|
import '../controllers/workout_controller.dart';
|
||||||
import '../models/workout.dart';
|
import '../models/workout.dart';
|
||||||
import '../utils/formatters.dart';
|
import '../screens/history_screen.dart';
|
||||||
import '../services/sync_service.dart';
|
import '../services/sync_service.dart';
|
||||||
|
import '../utils/formatters.dart';
|
||||||
|
|
||||||
|
const _ink = Color(0xFF17251E);
|
||||||
|
const _green = Color(0xFF456C52);
|
||||||
|
const _paper = Color(0xFFF5F7F2);
|
||||||
|
const _muted = Color(0xFF778078);
|
||||||
|
|
||||||
class HomeScreen extends StatelessWidget {
|
class HomeScreen extends StatelessWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
@@ -14,237 +20,452 @@ class HomeScreen extends StatelessWidget {
|
|||||||
final syncService = Get.find<SyncService>();
|
final syncService = Get.find<SyncService>();
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: CustomScrollView(
|
backgroundColor: _paper,
|
||||||
|
body: SafeArea(
|
||||||
|
child: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// 1. شريط العنوان المخصص (Premium Header)
|
SliverPadding(
|
||||||
SliverAppBar(
|
padding: const EdgeInsets.fromLTRB(22, 14, 22, 28),
|
||||||
expandedHeight: 200,
|
sliver: SliverList.list(children: [
|
||||||
floating: false,
|
Row(children: [
|
||||||
pinned: true,
|
Container(
|
||||||
flexibleSpace: FlexibleSpaceBar(
|
width: 38,
|
||||||
title: const Text("Tracker Running",
|
height: 38,
|
||||||
style: TextStyle(fontWeight: FontWeight.w800)),
|
decoration: BoxDecoration(
|
||||||
background: Container(
|
color: _green,
|
||||||
decoration: const BoxDecoration(
|
borderRadius: BorderRadius.circular(13),
|
||||||
gradient: LinearGradient(
|
),
|
||||||
colors: [Color(0xFFFF5722), Color(0xFFE64A19)],
|
child: const Center(
|
||||||
begin: Alignment.topLeft,
|
child: Text('S',
|
||||||
end: Alignment.bottomRight,
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 23,
|
||||||
|
fontFamily: 'Georgia',
|
||||||
|
fontStyle: FontStyle.italic)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Center(
|
const SizedBox(width: 10),
|
||||||
child: Icon(Icons.directions_run,
|
const Text('SportPath',
|
||||||
size: 80, color: Colors.white.withOpacity(0.3)),
|
style: TextStyle(
|
||||||
),
|
color: _ink,
|
||||||
),
|
fontSize: 17,
|
||||||
),
|
fontWeight: FontWeight.w700,
|
||||||
actions: [
|
letterSpacing: -.4)),
|
||||||
Obx(() => IconButton(
|
const Spacer(),
|
||||||
icon: Icon(
|
Obx(() => Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10, vertical: 7),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Row(children: [
|
||||||
|
Icon(
|
||||||
syncService.isOnline
|
syncService.isOnline
|
||||||
? Icons.cloud_done
|
? Icons.cloud_done_outlined
|
||||||
: Icons.cloud_off,
|
: Icons.cloud_off_outlined,
|
||||||
color:
|
size: 16,
|
||||||
syncService.isOnline ? Colors.white : Colors.red),
|
color: syncService.isOnline
|
||||||
onPressed: () {},
|
? _green
|
||||||
)),
|
: Colors.orange.shade800),
|
||||||
],
|
const SizedBox(width: 5),
|
||||||
),
|
Text(syncService.isOnline ? 'متصل' : 'دون اتصال',
|
||||||
|
|
||||||
// 2. حالة المزامنة السريعة
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: Obx(() => syncService.pendingCount > 0
|
|
||||||
? Container(
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
color: Colors.orange.withOpacity(0.2),
|
|
||||||
child: Center(
|
|
||||||
child: Text(
|
|
||||||
"لديك ${syncService.pendingCount} تمارين بانتظار المزامنة..",
|
|
||||||
style:
|
style:
|
||||||
const TextStyle(fontSize: 12, color: Colors.orange),
|
const TextStyle(color: _ink, fontSize: 11)),
|
||||||
|
]),
|
||||||
|
)),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 34),
|
||||||
|
Text(_arabicToday(),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: _green,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: .2)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text('خطوة صغيرة،\nفرق كبير.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: _ink,
|
||||||
|
fontSize: 35,
|
||||||
|
height: 1.18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -1.2)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text('خلّينا نبدأ حركة اليوم بإيقاع يناسبك.',
|
||||||
|
style:
|
||||||
|
TextStyle(color: _muted, fontSize: 14, height: 1.65)),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
_WeeklyPlanCard(
|
||||||
|
onStartWalk: () =>
|
||||||
|
workoutCtrl.startWorkout(WorkoutType.walking)),
|
||||||
|
const SizedBox(height: 26),
|
||||||
|
Row(children: [
|
||||||
|
const Text('ابدأ الآن',
|
||||||
|
style: TextStyle(
|
||||||
|
color: _ink,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600)),
|
||||||
|
const Spacer(),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () => Get.to(() => const HistoryScreen()),
|
||||||
|
icon: const Icon(Icons.arrow_back, size: 15),
|
||||||
|
label: const Text('سجل نشاطك'),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: _green,
|
||||||
|
textStyle: const TextStyle(fontSize: 12)),
|
||||||
),
|
),
|
||||||
),
|
]),
|
||||||
)
|
const SizedBox(height: 11),
|
||||||
|
Row(children: [
|
||||||
|
Expanded(
|
||||||
|
child: _ActionCard(
|
||||||
|
title: 'مشي بالخارج',
|
||||||
|
subtitle: 'سجّل المسافة والوقت',
|
||||||
|
icon: Icons.directions_walk_rounded,
|
||||||
|
tint: const Color(0xFFE8EFE5),
|
||||||
|
onTap: () =>
|
||||||
|
workoutCtrl.startWorkout(WorkoutType.walking))),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _ActionCard(
|
||||||
|
title: 'جري خفيف',
|
||||||
|
subtitle: 'على قدر طاقتك',
|
||||||
|
icon: Icons.directions_run_rounded,
|
||||||
|
tint: const Color(0xFFF0EAE0),
|
||||||
|
onTap: () =>
|
||||||
|
workoutCtrl.startWorkout(WorkoutType.running))),
|
||||||
|
]),
|
||||||
|
Obx(() => workoutCtrl.currentWorkout.value != null
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 16),
|
||||||
|
child: _ActiveWorkoutCard(controller: workoutCtrl))
|
||||||
: const SizedBox.shrink()),
|
: const SizedBox.shrink()),
|
||||||
),
|
const SizedBox(height: 28),
|
||||||
|
const Text('عادات تدعم يومك',
|
||||||
// 3. أزرار البدء السريع (Quick Actions)
|
style: TextStyle(
|
||||||
SliverToBoxAdapter(
|
color: _ink,
|
||||||
child: Padding(
|
fontSize: 18,
|
||||||
padding: const EdgeInsets.all(20.0),
|
fontWeight: FontWeight.w600)),
|
||||||
child: Column(
|
const SizedBox(height: 12),
|
||||||
|
const _HabitCard(
|
||||||
|
icon: Icons.restaurant_outlined,
|
||||||
|
title: 'وجبتك القادمة',
|
||||||
|
detail: 'سجّلها يدويًا الآن؛ تحليل الصور قيد الإعداد.',
|
||||||
|
tag: 'تغذية'),
|
||||||
|
const SizedBox(height: 9),
|
||||||
|
const _HabitCard(
|
||||||
|
icon: Icons.water_drop_outlined,
|
||||||
|
title: 'اشرب كوب ماء',
|
||||||
|
detail: 'تذكير بسيط للعناية بنفسك.',
|
||||||
|
tag: 'عافية'),
|
||||||
|
const SizedBox(height: 25),
|
||||||
|
const Text('برنامج الأسبوع',
|
||||||
|
style: TextStyle(
|
||||||
|
color: _ink,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const _DayStrip(),
|
||||||
|
const SizedBox(height: 25),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFE8EEE5),
|
||||||
|
borderRadius: BorderRadius.circular(18)),
|
||||||
|
child: const Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text("ابدأ رحلتك اليوم",
|
Icon(Icons.favorite_border, color: _green, size: 20),
|
||||||
style:
|
SizedBox(width: 11),
|
||||||
TextStyle(fontSize: 22, fontWeight: FontWeight.bold)),
|
|
||||||
const SizedBox(height: 15),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
_buildStartCard(
|
|
||||||
title: "ركض",
|
|
||||||
icon: Icons.run_circle,
|
|
||||||
color: const Color(0xFFFF5722),
|
|
||||||
onTap: () =>
|
|
||||||
workoutCtrl.startWorkout(WorkoutType.running),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 15),
|
|
||||||
_buildStartCard(
|
|
||||||
title: "مشي",
|
|
||||||
icon: Icons.directions_walk,
|
|
||||||
color: Colors.green,
|
|
||||||
onTap: () =>
|
|
||||||
workoutCtrl.startWorkout(WorkoutType.walking),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// 4. عرض حالة التمرين الحالي (إذا كان نشطاً)
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: Obx(() => workoutCtrl.currentWorkout.value != null
|
|
||||||
? _buildActiveWorkoutCard(workoutCtrl)
|
|
||||||
: const SizedBox.shrink()),
|
|
||||||
),
|
|
||||||
|
|
||||||
// 5. قسم الإحصائيات أو التمرين الأخير
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
|
||||||
child: Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(20.0),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
const Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text("آخر تمرين",
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold)),
|
|
||||||
Text("عرض الكل",
|
|
||||||
style: TextStyle(
|
|
||||||
color: Color(0xFFFF5722), fontSize: 12)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const Divider(height: 30, color: Colors.white10),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
||||||
children: [
|
|
||||||
_buildMiniStat("المسافة", "0.0 كم"),
|
|
||||||
_buildMiniStat("الوقت", "00:00"),
|
|
||||||
_buildMiniStat("السعرات", "0"),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildStartCard(
|
|
||||||
{required String title,
|
|
||||||
required IconData icon,
|
|
||||||
required Color color,
|
|
||||||
required VoidCallback onTap}) {
|
|
||||||
return Expanded(
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: onTap,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: color.withOpacity(0.1),
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
border: Border.all(color: color.withOpacity(0.3), width: 2),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Icon(icon, size: 40, color: color),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Text(title,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 18, fontWeight: FontWeight.bold, color: color)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildActiveWorkoutCard(WorkoutController ctrl) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.all(20.0),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFFFF5722),
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
const Text("التمرين قيد التسجيل...",
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white, fontWeight: FontWeight.bold)),
|
|
||||||
const SizedBox(height: 15),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
||||||
children: [
|
|
||||||
_buildWhiteStat("المسافة",
|
|
||||||
"${Formatters.distanceKm(ctrl.liveDistance.value)} كم"),
|
|
||||||
_buildWhiteStat(
|
|
||||||
"الوقت", Formatters.time(ctrl.elapsedSeconds.value)),
|
|
||||||
_buildWhiteStat(
|
|
||||||
"السرعة", Formatters.speedKmh(ctrl.liveSpeed.value)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ElevatedButton(
|
child: Text(
|
||||||
onPressed: () => ctrl.stopWorkout(),
|
'ابدأ بهدوء، وتوقف إذا شعرت بألم أو دوخة. الخطة العامة لا تغني عن نصيحة مختص عند وجود حالة صحية.',
|
||||||
style: ElevatedButton.styleFrom(
|
style: TextStyle(
|
||||||
backgroundColor: Colors.white,
|
color: _green, fontSize: 11, height: 1.7))),
|
||||||
foregroundColor: Colors.red),
|
]),
|
||||||
child: const Text("إنهاء"),
|
|
||||||
),
|
),
|
||||||
|
]),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildMiniStat(String label, String value) {
|
String _arabicToday() {
|
||||||
return Column(
|
const weekdays = [
|
||||||
|
'الاثنين',
|
||||||
|
'الثلاثاء',
|
||||||
|
'الأربعاء',
|
||||||
|
'الخميس',
|
||||||
|
'الجمعة',
|
||||||
|
'السبت',
|
||||||
|
'الأحد'
|
||||||
|
];
|
||||||
|
const months = [
|
||||||
|
'يناير',
|
||||||
|
'فبراير',
|
||||||
|
'مارس',
|
||||||
|
'أبريل',
|
||||||
|
'مايو',
|
||||||
|
'يونيو',
|
||||||
|
'يوليو',
|
||||||
|
'أغسطس',
|
||||||
|
'سبتمبر',
|
||||||
|
'أكتوبر',
|
||||||
|
'نوفمبر',
|
||||||
|
'ديسمبر'
|
||||||
|
];
|
||||||
|
final now = DateTime.now();
|
||||||
|
final day = now.day.toString().replaceAllMapped(
|
||||||
|
RegExp(r'\d'), (match) => '٠١٢٣٤٥٦٧٨٩'[int.parse(match.group(0)!)]);
|
||||||
|
return '${weekdays[now.weekday - 1]}، $day ${months[now.month - 1]}';
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WeeklyPlanCard extends StatelessWidget {
|
||||||
|
const _WeeklyPlanCard({required this.onStartWalk});
|
||||||
|
final VoidCallback onStartWalk;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Container(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _green, borderRadius: BorderRadius.circular(22)),
|
||||||
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Row(children: [
|
||||||
|
const Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(label, style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
Text('خطة بداية متوازنة',
|
||||||
Text(value,
|
style: TextStyle(
|
||||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
color: Colors.white,
|
||||||
],
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.w600)),
|
||||||
|
SizedBox(height: 4),
|
||||||
|
Text('أسبوع تمهيدي • حركة خفيفة',
|
||||||
|
style: TextStyle(color: Color(0xFFDCE8DA), fontSize: 11)),
|
||||||
|
])),
|
||||||
|
SizedBox(
|
||||||
|
width: 76,
|
||||||
|
height: 70,
|
||||||
|
child: CustomPaint(painter: _PathPainter())),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 17),
|
||||||
|
Row(children: [
|
||||||
|
const Expanded(
|
||||||
|
child: Text('مشي مريح لمدة ٢٠ دقيقة',
|
||||||
|
style: TextStyle(color: Colors.white, fontSize: 13))),
|
||||||
|
TextButton(
|
||||||
|
onPressed: onStartWalk,
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
foregroundColor: _green,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14)),
|
||||||
|
child: const Text('ابدأ المشي',
|
||||||
|
style:
|
||||||
|
TextStyle(fontSize: 11, fontWeight: FontWeight.w600))),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
const Text('عدّل المدة أو خذ يوم راحة حسب شعورك.',
|
||||||
|
style: TextStyle(color: Color(0xFFDCE8DA), fontSize: 10)),
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PathPainter extends CustomPainter {
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final paint = Paint()
|
||||||
|
..color = const Color(0xFFB8CAA9)
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 1.5;
|
||||||
|
final path = Path()
|
||||||
|
..moveTo(4, size.height * .82)
|
||||||
|
..cubicTo(size.width * .18, size.height * .12, size.width * .48,
|
||||||
|
size.height * .98, size.width * .68, size.height * .38)
|
||||||
|
..quadraticBezierTo(size.width * .86, size.height * .02, size.width * .98,
|
||||||
|
size.height * .24);
|
||||||
|
canvas.drawPath(path, paint);
|
||||||
|
canvas.drawCircle(Offset(size.width * .68, size.height * .38), 4,
|
||||||
|
Paint()..color = Colors.white);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildWhiteStat(String label, String value) {
|
@override
|
||||||
return Column(
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ActionCard extends StatelessWidget {
|
||||||
|
const _ActionCard(
|
||||||
|
{required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.icon,
|
||||||
|
required this.tint,
|
||||||
|
required this.onTap});
|
||||||
|
final String title, subtitle;
|
||||||
|
final IconData icon;
|
||||||
|
final Color tint;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Material(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(17),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(17),
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: tint,
|
||||||
|
borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: Icon(icon, color: _green, size: 21)),
|
||||||
|
const SizedBox(height: 13),
|
||||||
|
Text(title,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: _ink,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(subtitle,
|
||||||
|
style: const TextStyle(color: _muted, fontSize: 10)),
|
||||||
|
]))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ActiveWorkoutCard extends StatelessWidget {
|
||||||
|
const _ActiveWorkoutCard({required this.controller});
|
||||||
|
final WorkoutController controller;
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Container(
|
||||||
|
padding: const EdgeInsets.all(17),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF293A30),
|
||||||
|
borderRadius: BorderRadius.circular(18)),
|
||||||
|
child: Column(children: [
|
||||||
|
const Row(children: [
|
||||||
|
Icon(Icons.fiber_manual_record, size: 9, color: Color(0xFFB6D29D)),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('النشاط جارٍ',
|
||||||
|
style:
|
||||||
|
TextStyle(color: Colors.white, fontWeight: FontWeight.w600))
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 15),
|
||||||
|
Row(mainAxisAlignment: MainAxisAlignment.spaceAround, children: [
|
||||||
|
_LiveStat('المسافة',
|
||||||
|
'${Formatters.distanceKm(controller.liveDistance.value)} كم'),
|
||||||
|
_LiveStat(
|
||||||
|
'الوقت', Formatters.time(controller.elapsedSeconds.value)),
|
||||||
|
_LiveStat('السرعة',
|
||||||
|
'${Formatters.speedKmh(controller.liveSpeed.value)} كم/س'),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 13),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: controller.stopWorkout,
|
||||||
|
style: TextButton.styleFrom(foregroundColor: Colors.white),
|
||||||
|
child: const Text('إنهاء وحفظ'))),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LiveStat extends StatelessWidget {
|
||||||
|
const _LiveStat(this.label, this.value);
|
||||||
|
final String label, value;
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Column(children: [
|
||||||
Text(label,
|
Text(label,
|
||||||
style: const TextStyle(fontSize: 12, color: Colors.white70)),
|
style: const TextStyle(color: Colors.white60, fontSize: 10)),
|
||||||
|
const SizedBox(height: 3),
|
||||||
Text(value,
|
Text(value,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600))
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HabitCard extends StatelessWidget {
|
||||||
|
const _HabitCard(
|
||||||
|
{required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.detail,
|
||||||
|
required this.tag});
|
||||||
|
final IconData icon;
|
||||||
|
final String title, detail, tag;
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white, borderRadius: BorderRadius.circular(15)),
|
||||||
|
child: Row(children: [
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFE8EFE5),
|
||||||
|
borderRadius: BorderRadius.circular(13)),
|
||||||
|
child: Icon(icon, color: _green, size: 20)),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(title,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: _ink,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(detail,
|
||||||
|
style: const TextStyle(color: _muted, fontSize: 10))
|
||||||
|
])),
|
||||||
|
Text(tag,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 18,
|
color: _green, fontSize: 10, fontWeight: FontWeight.w600)),
|
||||||
fontWeight: FontWeight.bold,
|
]),
|
||||||
color: Colors.white)),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _DayStrip extends StatelessWidget {
|
||||||
|
const _DayStrip();
|
||||||
|
static const days = [
|
||||||
|
('ح', 'راحة'),
|
||||||
|
('ن', 'مشي'),
|
||||||
|
('ث', 'قوة'),
|
||||||
|
('ر', 'مشي'),
|
||||||
|
('خ', 'راحة'),
|
||||||
|
('ج', 'نشاط'),
|
||||||
|
('س', 'راحة')
|
||||||
|
];
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Row(children: [
|
||||||
|
for (var i = 0; i < days.length; i++)
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsetsDirectional.only(
|
||||||
|
end: i == days.length - 1 ? 0 : 7),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: i == 0 ? _green : Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(13)),
|
||||||
|
child: Column(children: [
|
||||||
|
Text(days[i].$1,
|
||||||
|
style: TextStyle(
|
||||||
|
color: i == 0 ? Colors.white : _ink,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(days[i].$2,
|
||||||
|
style: TextStyle(
|
||||||
|
color: i == 0 ? Colors.white70 : _muted,
|
||||||
|
fontSize: 8))
|
||||||
|
]),
|
||||||
|
)))
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__, 4) . '/backend/Config.php';
|
||||||
|
require_once dirname(__DIR__, 4) . '/backend/Database.php';
|
||||||
|
|
||||||
|
function api_json(array $payload, int $status = 200): void
|
||||||
|
{
|
||||||
|
http_response_code($status);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
header('Referrer-Policy: no-referrer');
|
||||||
|
header('X-Frame-Options: DENY');
|
||||||
|
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function api_method(string $expected): void
|
||||||
|
{
|
||||||
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== $expected) {
|
||||||
|
header('Allow: ' . $expected);
|
||||||
|
api_json(['error' => 'method_not_allowed'], 405);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__, 4) . '/backend/JwtToken.php';
|
||||||
|
require_once dirname(__DIR__, 4) . '/backend/ApiAuth.php';
|
||||||
|
require_once dirname(__DIR__) . '/_bootstrap.php';
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||||
|
if (!in_array($method, ['GET', 'PATCH'], true)) {
|
||||||
|
header('Allow: GET, PATCH');
|
||||||
|
api_json(['error' => 'method_not_allowed'], 405);
|
||||||
|
}
|
||||||
|
$auth = ApiAuth::bearerClaims();
|
||||||
|
ApiAuth::requireRole($auth, ['owner', 'content_manager']);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db = Database::getInstance();
|
||||||
|
if ($method === 'GET') {
|
||||||
|
$result = $db->getConnection()->query('SELECT setting_key, setting_value, is_public, revision, updated_at FROM app_settings ORDER BY setting_key');
|
||||||
|
$settings = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$settings[] = [
|
||||||
|
'key' => $row['setting_key'],
|
||||||
|
'value' => json_decode($row['setting_value'], true),
|
||||||
|
'is_public' => (bool) $row['is_public'],
|
||||||
|
'revision' => (int) $row['revision'],
|
||||||
|
'updated_at' => $row['updated_at'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
api_json(['settings' => $settings]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = json_decode(file_get_contents('php://input') ?: '', true);
|
||||||
|
if (!is_array($body) || !is_array($body['settings'] ?? null) || count($body['settings']) > 100) {
|
||||||
|
api_json(['error' => 'invalid_settings_payload'], 400);
|
||||||
|
}
|
||||||
|
$connection = $db->getConnection();
|
||||||
|
$connection->begin_transaction();
|
||||||
|
$read = $db->prepare('SELECT setting_value, is_public, revision FROM app_settings WHERE setting_key = ? FOR UPDATE');
|
||||||
|
$write = $db->prepare('INSERT INTO app_settings (setting_key, setting_value, is_public, revision, updated_by) VALUES (?, ?, ?, 1, ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), is_public = VALUES(is_public), revision = revision + 1, updated_by = VALUES(updated_by)');
|
||||||
|
$audit = $db->prepare('INSERT INTO app_setting_audit (setting_key, previous_value, new_value, actor_user_id) VALUES (?, ?, ?, ?)');
|
||||||
|
foreach ($body['settings'] as $item) {
|
||||||
|
if (!is_array($item) || !is_string($item['key'] ?? null) || !preg_match('/^[a-z][a-z0-9_.-]{0,99}$/', $item['key']) || !array_key_exists('value', $item) || !is_bool($item['is_public'] ?? null)) {
|
||||||
|
$connection->rollback();
|
||||||
|
api_json(['error' => 'invalid_setting'], 400);
|
||||||
|
}
|
||||||
|
$key = $item['key'];
|
||||||
|
if (preg_match('/(secret|password|token|credential|private.?key|api.?key)/i', $key)) {
|
||||||
|
$connection->rollback();
|
||||||
|
api_json(['error' => 'secrets_must_use_server_environment'], 400);
|
||||||
|
}
|
||||||
|
$encodedValue = json_encode($item['value'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
if ($encodedValue === false || strlen($encodedValue) > 65535) {
|
||||||
|
$connection->rollback();
|
||||||
|
api_json(['error' => 'setting_value_too_large'], 400);
|
||||||
|
}
|
||||||
|
$read->bind_param('s', $key);
|
||||||
|
$read->execute();
|
||||||
|
$previous = $read->get_result()->fetch_assoc();
|
||||||
|
$isPublic = $item['is_public'] ? 1 : 0;
|
||||||
|
$actor = $auth['user_id'];
|
||||||
|
$write->bind_param('ssii', $key, $encodedValue, $isPublic, $actor);
|
||||||
|
$write->execute();
|
||||||
|
$previousValue = $previous['setting_value'] ?? null;
|
||||||
|
$audit->bind_param('sssi', $key, $previousValue, $encodedValue, $actor);
|
||||||
|
$audit->execute();
|
||||||
|
}
|
||||||
|
$read->close();
|
||||||
|
$write->close();
|
||||||
|
$audit->close();
|
||||||
|
$connection->commit();
|
||||||
|
api_json(['status' => 'updated']);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
if (isset($connection) && $connection instanceof mysqli) {
|
||||||
|
try { $connection->rollback(); } catch (Throwable $ignored) {}
|
||||||
|
}
|
||||||
|
error_log('Admin settings update failed: ' . $exception->getMessage());
|
||||||
|
api_json(['error' => 'service_unavailable'], 503);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__, 4) . '/backend/JwtToken.php';
|
||||||
|
require_once dirname(__DIR__, 4) . '/backend/ApiAuth.php';
|
||||||
|
require_once dirname(__DIR__) . '/_bootstrap.php';
|
||||||
|
api_method('POST');
|
||||||
|
$auth = ApiAuth::bearerClaims();
|
||||||
|
try {
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$stmt = $db->prepare('UPDATE auth_sessions SET revoked_at = COALESCE(revoked_at, UTC_TIMESTAMP()) WHERE session_uuid = ?');
|
||||||
|
$stmt->bind_param('s', $auth['session_id']);
|
||||||
|
$stmt->execute();
|
||||||
|
$stmt->close();
|
||||||
|
api_json(['status' => 'signed_out']);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
error_log('Session revocation failed: ' . $exception->getMessage());
|
||||||
|
api_json(['error' => 'service_unavailable'], 503);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__, 4) . '/backend/JwtToken.php';
|
||||||
|
require_once dirname(__DIR__, 4) . '/backend/ApiAuth.php';
|
||||||
|
require_once dirname(__DIR__) . '/_bootstrap.php';
|
||||||
|
api_method('POST');
|
||||||
|
$body = json_decode(file_get_contents('php://input') ?: '', true);
|
||||||
|
$refreshToken = is_array($body) ? ($body['refresh_token'] ?? null) : null;
|
||||||
|
if (!is_string($refreshToken) || !preg_match('/^[a-f0-9]{96}$/', $refreshToken)) {
|
||||||
|
api_json(['error' => 'invalid_refresh_token'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$jwtKey = AppConfig::required('JWT_SIGNING_KEY');
|
||||||
|
if (strlen($jwtKey) < 32) {
|
||||||
|
throw new RuntimeException('JWT_SIGNING_KEY must be at least 32 bytes');
|
||||||
|
}
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$connection = $db->getConnection();
|
||||||
|
$connection->begin_transaction();
|
||||||
|
$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);
|
||||||
|
$query->execute();
|
||||||
|
$session = $query->get_result()->fetch_assoc();
|
||||||
|
$query->close();
|
||||||
|
if (!$session) {
|
||||||
|
$connection->rollback();
|
||||||
|
api_json(['error' => 'invalid_refresh_token'], 401);
|
||||||
|
}
|
||||||
|
if ($session['replaced_by'] !== null) {
|
||||||
|
$revoke = $db->prepare('UPDATE auth_sessions SET revoked_at = COALESCE(revoked_at, UTC_TIMESTAMP()) WHERE family_uuid = ?');
|
||||||
|
$revoke->bind_param('s', $session['family_uuid']);
|
||||||
|
$revoke->execute();
|
||||||
|
$revoke->close();
|
||||||
|
$connection->commit();
|
||||||
|
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();
|
||||||
|
api_json(['error' => 'session_expired'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$newSessionId = sprintf('%04x%04x-%04x-4%03x-%04x-%04x%04x%04x', random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xfff), random_int(0, 0x3fff) | 0x8000, random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff));
|
||||||
|
$newRefresh = bin2hex(random_bytes(48));
|
||||||
|
$newDigest = hash('sha256', $newRefresh);
|
||||||
|
$refreshDays = max(1, min(90, AppConfig::integer('JWT_REFRESH_TTL_DAYS', 30)));
|
||||||
|
$insert = $db->prepare('INSERT INTO auth_sessions (session_uuid, family_uuid, user_id, device_uuid, refresh_token_digest, expires_at) VALUES (?, ?, ?, ?, ?, UTC_TIMESTAMP() + INTERVAL ? DAY)');
|
||||||
|
$insert->bind_param('ssissi', $newSessionId, $session['family_uuid'], $session['user_id'], $session['device_uuid'], $newDigest, $refreshDays);
|
||||||
|
$insert->execute();
|
||||||
|
$insert->close();
|
||||||
|
$replace = $db->prepare('UPDATE auth_sessions SET last_used_at = UTC_TIMESTAMP(), replaced_by = ? WHERE session_uuid = ? AND replaced_by IS NULL');
|
||||||
|
$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)));
|
||||||
|
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),
|
||||||
|
'token_type' => 'Bearer',
|
||||||
|
'expires_in_seconds' => $ttl,
|
||||||
|
'refresh_token' => $newRefresh,
|
||||||
|
'refresh_expires_in_days' => $refreshDays,
|
||||||
|
]);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
if (isset($connection) && $connection instanceof mysqli) {
|
||||||
|
try { $connection->rollback(); } catch (Throwable $ignored) {}
|
||||||
|
}
|
||||||
|
error_log('Session refresh failed: ' . $exception->getMessage());
|
||||||
|
api_json(['error' => 'service_unavailable'], 503);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__, 4) . '/backend/OtpProvider.php';
|
||||||
|
require_once dirname(__DIR__, 4) . '/backend/JwtToken.php';
|
||||||
|
require_once dirname(__DIR__, 4) . '/backend/ApiAuth.php';
|
||||||
|
require_once dirname(__DIR__) . '/_bootstrap.php';
|
||||||
|
api_method('POST');
|
||||||
|
|
||||||
|
$body = json_decode(file_get_contents('php://input') ?: '', true);
|
||||||
|
$phone = is_array($body) ? ($body['phone_e164'] ?? null) : null;
|
||||||
|
$purpose = is_array($body) ? ($body['purpose'] ?? 'login') : 'login';
|
||||||
|
$deviceUuid = is_array($body) ? ($body['device_uuid'] ?? null) : null;
|
||||||
|
if (!is_string($phone) || !preg_match('/^\+[1-9][0-9]{7,14}$/', $phone)) {
|
||||||
|
api_json(['error' => 'invalid_phone_e164'], 400);
|
||||||
|
}
|
||||||
|
if (!in_array($purpose, ['register', 'login'], true)) {
|
||||||
|
api_json(['error' => 'invalid_purpose'], 400);
|
||||||
|
}
|
||||||
|
if ($deviceUuid !== null && (!is_string($deviceUuid) || !preg_match('/^[0-9a-f-]{36}$/i', $deviceUuid))) {
|
||||||
|
api_json(['error' => 'invalid_device_uuid'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
AppConfig::loadEnvironment();
|
||||||
|
if (getenv('OTP_ENABLED') !== 'true') {
|
||||||
|
api_json(['error' => 'otp_provider_not_configured'], 503);
|
||||||
|
}
|
||||||
|
$hashKey = AppConfig::required('OTP_HASH_KEY');
|
||||||
|
if (strlen($hashKey) < 32) {
|
||||||
|
throw new RuntimeException('OTP_HASH_KEY must be at least 32 bytes');
|
||||||
|
}
|
||||||
|
$jwtKey = AppConfig::required('JWT_SIGNING_KEY');
|
||||||
|
if (strlen($jwtKey) < 32) {
|
||||||
|
throw new RuntimeException('JWT_SIGNING_KEY must be at least 32 bytes');
|
||||||
|
}
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$connection = $db->getConnection();
|
||||||
|
$connection->begin_transaction();
|
||||||
|
|
||||||
|
$rateBuckets = [
|
||||||
|
['phone', hash_hmac('sha256', 'phone:' . $phone, $hashKey), 5, 3600],
|
||||||
|
['ip', hash_hmac('sha256', 'ip:' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown'), $hashKey), 20, 3600],
|
||||||
|
];
|
||||||
|
if ($deviceUuid !== null) {
|
||||||
|
$rateBuckets[] = ['device', hash_hmac('sha256', 'device:' . $deviceUuid, $hashKey), 10, 3600];
|
||||||
|
}
|
||||||
|
foreach ($rateBuckets as [$bucketType, $digest, $limit, $windowSeconds]) {
|
||||||
|
$stmt = $db->prepare('INSERT INTO auth_rate_limit_buckets (bucket_digest, bucket_type, window_started_at, request_count) VALUES (?, ?, UTC_TIMESTAMP(), 1) ON DUPLICATE KEY UPDATE request_count = IF(window_started_at < UTC_TIMESTAMP() - INTERVAL ? SECOND, 1, request_count + 1), window_started_at = IF(window_started_at < UTC_TIMESTAMP() - INTERVAL ? SECOND, UTC_TIMESTAMP(), window_started_at)');
|
||||||
|
$stmt->bind_param('ssii', $digest, $bucketType, $windowSeconds, $windowSeconds);
|
||||||
|
$stmt->execute();
|
||||||
|
$stmt->close();
|
||||||
|
$check = $db->prepare('SELECT request_count FROM auth_rate_limit_buckets WHERE bucket_digest = ?');
|
||||||
|
$check->bind_param('s', $digest);
|
||||||
|
$check->execute();
|
||||||
|
$count = (int) $check->get_result()->fetch_assoc()['request_count'];
|
||||||
|
$check->close();
|
||||||
|
if ($count > $limit) {
|
||||||
|
$connection->rollback();
|
||||||
|
api_json(['error' => 'rate_limited'], 429);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$code = (string) random_int(100000, 999999);
|
||||||
|
$challengeUuid = sprintf('%04x%04x-%04x-4%03x-%04x-%04x%04x%04x', random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xfff), random_int(0, 0x3fff) | 0x8000, random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff));
|
||||||
|
$codeDigest = hash_hmac('sha256', $challengeUuid . '|' . $phone . '|' . $code, $hashKey);
|
||||||
|
$ipDigest = hash_hmac('sha256', 'ip:' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown'), $hashKey);
|
||||||
|
$insert = $db->prepare('INSERT INTO otp_challenges (challenge_uuid, phone_e164, purpose, code_digest, request_ip_digest, device_uuid, expires_at) VALUES (?, ?, ?, ?, ?, ?, UTC_TIMESTAMP() + INTERVAL 5 MINUTE)');
|
||||||
|
$insert->bind_param('ssssss', $challengeUuid, $phone, $purpose, $codeDigest, $ipDigest, $deviceUuid);
|
||||||
|
$insert->execute();
|
||||||
|
$insert->close();
|
||||||
|
$connection->commit();
|
||||||
|
|
||||||
|
(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) {
|
||||||
|
try { $connection->rollback(); } catch (Throwable $ignored) {}
|
||||||
|
}
|
||||||
|
error_log('OTP request failed: ' . $exception->getMessage());
|
||||||
|
api_json(['error' => 'otp_delivery_failed'], 503);
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__, 4) . '/backend/OtpProvider.php';
|
||||||
|
require_once dirname(__DIR__, 4) . '/backend/JwtToken.php';
|
||||||
|
require_once dirname(__DIR__, 4) . '/backend/ApiAuth.php';
|
||||||
|
require_once dirname(__DIR__) . '/_bootstrap.php';
|
||||||
|
api_method('POST');
|
||||||
|
|
||||||
|
$body = json_decode(file_get_contents('php://input') ?: '', true);
|
||||||
|
$challengeId = is_array($body) ? ($body['challenge_id'] ?? null) : null;
|
||||||
|
$code = is_array($body) ? ($body['code'] ?? null) : null;
|
||||||
|
$displayName = is_array($body) ? ($body['display_name'] ?? null) : null;
|
||||||
|
$deviceUuid = is_array($body) ? ($body['device_uuid'] ?? null) : null;
|
||||||
|
$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)) {
|
||||||
|
api_json(['error' => 'invalid_display_name'], 400);
|
||||||
|
}
|
||||||
|
if ($deviceUuid !== null && (!is_string($deviceUuid) || !preg_match('/^[0-9a-f-]{36}$/i', $deviceUuid))) {
|
||||||
|
api_json(['error' => 'invalid_device_uuid'], 400);
|
||||||
|
}
|
||||||
|
if ($platform !== null && !in_array($platform, ['ios', 'android', 'web'], true)) {
|
||||||
|
api_json(['error' => 'invalid_platform'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
AppConfig::loadEnvironment();
|
||||||
|
$hashKey = AppConfig::required('OTP_HASH_KEY');
|
||||||
|
if (strlen($hashKey) < 32) {
|
||||||
|
throw new RuntimeException('OTP_HASH_KEY must be at least 32 bytes');
|
||||||
|
}
|
||||||
|
$jwtKey = AppConfig::required('JWT_SIGNING_KEY');
|
||||||
|
if (strlen($jwtKey) < 32) {
|
||||||
|
throw new RuntimeException('JWT_SIGNING_KEY must be at least 32 bytes');
|
||||||
|
}
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$connection = $db->getConnection();
|
||||||
|
$connection->begin_transaction();
|
||||||
|
$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();
|
||||||
|
$challenge = $challengeQuery->get_result()->fetch_assoc();
|
||||||
|
$challengeQuery->close();
|
||||||
|
if (!$challenge || (int) $challenge['attempt_count'] >= (int) $challenge['max_attempts']) {
|
||||||
|
$connection->rollback();
|
||||||
|
api_json(['error' => 'invalid_or_expired_challenge'], 400);
|
||||||
|
}
|
||||||
|
if ($deviceUuid !== null && $challenge['device_uuid'] !== null && !hash_equals($challenge['device_uuid'], $deviceUuid)) {
|
||||||
|
$connection->rollback();
|
||||||
|
api_json(['error' => 'invalid_verification_payload'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$expectedDigest = hash_hmac('sha256', $challengeId . '|' . $challenge['phone_e164'] . '|' . $code, $hashKey);
|
||||||
|
if (!hash_equals($challenge['code_digest'], $expectedDigest)) {
|
||||||
|
$fail = $db->prepare('UPDATE otp_challenges SET attempt_count = attempt_count + 1 WHERE challenge_uuid = ?');
|
||||||
|
$fail->bind_param('s', $challengeId);
|
||||||
|
$fail->execute();
|
||||||
|
$fail->close();
|
||||||
|
$connection->commit();
|
||||||
|
api_json(['error' => 'invalid_code'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$consume = $db->prepare('UPDATE otp_challenges SET consumed_at = UTC_TIMESTAMP() WHERE challenge_uuid = ? AND consumed_at IS NULL');
|
||||||
|
$consume->bind_param('s', $challengeId);
|
||||||
|
$consume->execute();
|
||||||
|
if ($consume->affected_rows !== 1) {
|
||||||
|
$consume->close();
|
||||||
|
$connection->rollback();
|
||||||
|
api_json(['error' => 'invalid_or_expired_challenge'], 400);
|
||||||
|
}
|
||||||
|
$consume->close();
|
||||||
|
|
||||||
|
$userQuery = $db->prepare('SELECT id, uuid, account_role, is_active FROM users WHERE phone_e164 = ? LIMIT 1 FOR UPDATE');
|
||||||
|
$userQuery->bind_param('s', $challenge['phone_e164']);
|
||||||
|
$userQuery->execute();
|
||||||
|
$user = $userQuery->get_result()->fetch_assoc();
|
||||||
|
$userQuery->close();
|
||||||
|
if (!$user && $challenge['purpose'] === 'login') {
|
||||||
|
$connection->rollback();
|
||||||
|
api_json(['error' => 'verification_failed'], 400);
|
||||||
|
}
|
||||||
|
if ($user && !(bool) $user['is_active']) {
|
||||||
|
$connection->rollback();
|
||||||
|
api_json(['error' => 'account_inactive'], 403);
|
||||||
|
}
|
||||||
|
if (!$user) {
|
||||||
|
$userUuid = sprintf('%04x%04x-%04x-4%03x-%04x-%04x%04x%04x', random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xfff), random_int(0, 0x3fff) | 0x8000, random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff));
|
||||||
|
$userInsert = $db->prepare('INSERT INTO users (uuid, phone_e164, phone_verified_at, full_name, account_role, is_active) VALUES (?, ?, UTC_TIMESTAMP(), ?, \'member\', 1)');
|
||||||
|
$fullName = $displayName ?: 'مستخدم SportPath';
|
||||||
|
$userInsert->bind_param('sss', $userUuid, $challenge['phone_e164'], $fullName);
|
||||||
|
$userInsert->execute();
|
||||||
|
$userId = (int) $connection->insert_id;
|
||||||
|
$userInsert->close();
|
||||||
|
$user = ['id' => $userId, 'uuid' => $userUuid, 'account_role' => 'member'];
|
||||||
|
} else {
|
||||||
|
$userId = (int) $user['id'];
|
||||||
|
$verified = $db->prepare('UPDATE users SET phone_verified_at = COALESCE(phone_verified_at, UTC_TIMESTAMP()) WHERE id = ?');
|
||||||
|
$verified->bind_param('i', $userId);
|
||||||
|
$verified->execute();
|
||||||
|
$verified->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolvedDevice = $deviceUuid ?: ($challenge['device_uuid'] ?: null);
|
||||||
|
if ($resolvedDevice !== null) {
|
||||||
|
$devicePlatform = $platform ?: 'web';
|
||||||
|
$deviceInsert = $db->prepare('INSERT INTO user_devices (user_id, device_uuid, platform, display_name, last_seen_at) VALUES (?, ?, ?, ?, UTC_TIMESTAMP()) ON DUPLICATE KEY UPDATE platform = VALUES(platform), revoked_at = NULL, last_seen_at = UTC_TIMESTAMP()');
|
||||||
|
$deviceName = $displayName ?: null;
|
||||||
|
$deviceInsert->bind_param('isss', $userId, $resolvedDevice, $devicePlatform, $deviceName);
|
||||||
|
$deviceInsert->execute();
|
||||||
|
$deviceInsert->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
$sessionId = sprintf('%04x%04x-%04x-4%03x-%04x-%04x%04x%04x', random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xfff), random_int(0, 0x3fff) | 0x8000, random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff));
|
||||||
|
$familyId = $sessionId;
|
||||||
|
$refreshToken = bin2hex(random_bytes(48));
|
||||||
|
$refreshDigest = hash('sha256', $refreshToken);
|
||||||
|
$refreshDays = max(1, min(90, AppConfig::integer('JWT_REFRESH_TTL_DAYS', 30)));
|
||||||
|
$sessionInsert = $db->prepare('INSERT INTO auth_sessions (session_uuid, family_uuid, user_id, device_uuid, refresh_token_digest, expires_at) VALUES (?, ?, ?, ?, ?, UTC_TIMESTAMP() + INTERVAL ? DAY)');
|
||||||
|
$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)));
|
||||||
|
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),
|
||||||
|
'token_type' => 'Bearer',
|
||||||
|
'expires_in_seconds' => $accessTtl,
|
||||||
|
'refresh_token' => $refreshToken,
|
||||||
|
'refresh_expires_in_days' => $refreshDays,
|
||||||
|
]);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
if (isset($connection) && $connection instanceof mysqli) {
|
||||||
|
try { $connection->rollback(); } catch (Throwable $ignored) {}
|
||||||
|
}
|
||||||
|
error_log('OTP verification failed: ' . $exception->getMessage());
|
||||||
|
api_json(['error' => 'service_unavailable'], 503);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/_bootstrap.php';
|
||||||
|
require_once dirname(__DIR__, 3) . '/backend/JwtToken.php';
|
||||||
|
require_once dirname(__DIR__, 3) . '/backend/ApiAuth.php';
|
||||||
|
api_method('GET');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$database = Database::getInstance();
|
||||||
|
$statement = $database->prepare(
|
||||||
|
'SELECT setting_key, setting_value, revision FROM app_settings WHERE is_public = 1 ORDER BY setting_key'
|
||||||
|
);
|
||||||
|
$statement->execute();
|
||||||
|
$result = $statement->get_result();
|
||||||
|
$settings = [];
|
||||||
|
$revision = 0;
|
||||||
|
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$decoded = json_decode($row['setting_value'], true);
|
||||||
|
$settings[$row['setting_key']] = $decoded;
|
||||||
|
$revision = max($revision, (int) $row['revision']);
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Cache-Control: public, max-age=60, stale-while-revalidate=300');
|
||||||
|
api_json(['revision' => $revision, 'settings' => $settings]);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
error_log('Public configuration unavailable: ' . $exception->getMessage());
|
||||||
|
api_json(['error' => 'service_unavailable'], 503);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/_bootstrap.php';
|
||||||
|
api_method('GET');
|
||||||
|
|
||||||
|
try {
|
||||||
|
AppConfig::loadEnvironment();
|
||||||
|
Database::getInstance()->getConnection()->query('SELECT 1');
|
||||||
|
api_json(['status' => 'ok', 'database' => 'ok']);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
error_log('Health check dependency unavailable: ' . $exception->getMessage());
|
||||||
|
api_json(['status' => 'unavailable'], 503);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/_bootstrap.php';
|
||||||
|
api_method('GET');
|
||||||
|
|
||||||
|
$planUuid = $_GET['id'] ?? null;
|
||||||
|
if ($planUuid !== null && (!is_string($planUuid) || !preg_match('/^[0-9a-f-]{36}$/i', $planUuid))) {
|
||||||
|
api_json(['error' => 'invalid_plan_id'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$database = Database::getInstance();
|
||||||
|
$connection = $database->getConnection();
|
||||||
|
if ($planUuid !== null) {
|
||||||
|
$planQuery = $database->prepare(
|
||||||
|
'SELECT plan_uuid, slug, title_ar, summary_ar, goal, level, weeks_duration, source_notes, safety_notes_ar, content_revision FROM training_plans WHERE is_published = 1 AND plan_uuid = ? LIMIT 1'
|
||||||
|
);
|
||||||
|
$planQuery->bind_param('s', $planUuid);
|
||||||
|
} else {
|
||||||
|
$planQuery = $database->prepare(
|
||||||
|
'SELECT plan_uuid, slug, title_ar, summary_ar, goal, level, weeks_duration, source_notes, safety_notes_ar, content_revision FROM training_plans WHERE is_published = 1 ORDER BY title_ar'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$planQuery->execute();
|
||||||
|
$planResult = $planQuery->get_result();
|
||||||
|
$plans = [];
|
||||||
|
while ($plan = $planResult->fetch_assoc()) {
|
||||||
|
$plan['weeks_duration'] = (int) $plan['weeks_duration'];
|
||||||
|
$plan['content_revision'] = (int) $plan['content_revision'];
|
||||||
|
$plan['source_notes'] = json_decode($plan['source_notes'] ?? '[]', true) ?? [];
|
||||||
|
$plan['safety_notes_ar'] = json_decode($plan['safety_notes_ar'], true) ?? [];
|
||||||
|
$plans[$plan['plan_uuid']] = $plan;
|
||||||
|
}
|
||||||
|
$planQuery->close();
|
||||||
|
|
||||||
|
if ($planUuid !== null && !$plans) {
|
||||||
|
api_json(['error' => 'plan_not_found'], 404);
|
||||||
|
}
|
||||||
|
if (!$plans) {
|
||||||
|
header('Cache-Control: public, max-age=60, stale-while-revalidate=300');
|
||||||
|
api_json(['plans' => []]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$planUuids = array_keys($plans);
|
||||||
|
$ids = array_fill(0, count($planUuids), '?');
|
||||||
|
$sessionQuery = $database->prepare(
|
||||||
|
'SELECT s.plan_id, p.plan_uuid, s.week_number, s.day_number, s.title_ar, s.session_type, s.duration_minutes, s.intensity, s.notes_ar, e.exercise_uuid, e.slug AS exercise_slug, e.title_ar AS exercise_title_ar, e.instructions_ar, e.target_muscles, e.equipment, e.gif_url, e.gif_poster_url, e.duration_seconds AS exercise_duration_seconds, e.repetitions, e.safety_notes_ar AS exercise_safety_notes_ar, alt.exercise_uuid AS alternative_exercise_uuid, se.sort_order AS exercise_sort_order, se.sets, se.reps, se.duration_seconds, se.rest_seconds FROM training_plan_sessions s JOIN training_plans p ON p.id = s.plan_id LEFT JOIN training_session_exercises se ON se.session_id = s.id LEFT JOIN exercises e ON e.id = se.exercise_id AND e.is_published = 1 LEFT JOIN exercises alt ON alt.id = e.alternative_exercise_id AND alt.is_published = 1 WHERE p.plan_uuid IN (' . implode(',', $ids) . ') AND p.is_published = 1 ORDER BY p.title_ar, s.week_number, s.day_number, se.sort_order'
|
||||||
|
);
|
||||||
|
$bindArguments = [str_repeat('s', count($planUuids))];
|
||||||
|
foreach ($planUuids as $index => $_planUuid) {
|
||||||
|
$bindArguments[] = &$planUuids[$index];
|
||||||
|
}
|
||||||
|
call_user_func_array([$sessionQuery, 'bind_param'], $bindArguments);
|
||||||
|
$sessionQuery->execute();
|
||||||
|
$sessionResult = $sessionQuery->get_result();
|
||||||
|
|
||||||
|
foreach ($plans as &$plan) {
|
||||||
|
$plan['sessions'] = [];
|
||||||
|
}
|
||||||
|
unset($plan);
|
||||||
|
|
||||||
|
while ($row = $sessionResult->fetch_assoc()) {
|
||||||
|
$uuid = $row['plan_uuid'];
|
||||||
|
$week = (int) $row['week_number'];
|
||||||
|
$day = (int) $row['day_number'];
|
||||||
|
$key = $week . ':' . $day;
|
||||||
|
if (!isset($plans[$uuid]['sessions'][$key])) {
|
||||||
|
$plans[$uuid]['sessions'][$key] = [
|
||||||
|
'week' => $week,
|
||||||
|
'day' => $day,
|
||||||
|
'title_ar' => $row['title_ar'],
|
||||||
|
'type' => $row['session_type'],
|
||||||
|
'duration_minutes' => (int) $row['duration_minutes'],
|
||||||
|
'intensity' => $row['intensity'],
|
||||||
|
'notes_ar' => $row['notes_ar'],
|
||||||
|
'exercises' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if ($row['exercise_uuid'] !== null) {
|
||||||
|
$plans[$uuid]['sessions'][$key]['exercises'][] = [
|
||||||
|
'exercise_uuid' => $row['exercise_uuid'],
|
||||||
|
'slug' => $row['exercise_slug'],
|
||||||
|
'title_ar' => $row['exercise_title_ar'],
|
||||||
|
'instructions_ar' => json_decode($row['instructions_ar'], true) ?? [],
|
||||||
|
'target_muscles' => json_decode($row['target_muscles'], true) ?? [],
|
||||||
|
'equipment' => json_decode($row['equipment'], true) ?? [],
|
||||||
|
'gif_url' => $row['gif_url'],
|
||||||
|
'gif_poster_url' => $row['gif_poster_url'],
|
||||||
|
'duration_seconds' => $row['duration_seconds'] === null ? null : (int) $row['duration_seconds'],
|
||||||
|
'repetitions' => $row['repetitions'],
|
||||||
|
'safety_notes_ar' => json_decode($row['exercise_safety_notes_ar'] ?? '[]', true) ?? [],
|
||||||
|
'alternative_exercise_uuid' => $row['alternative_exercise_uuid'],
|
||||||
|
'prescription' => [
|
||||||
|
'sets' => $row['sets'] === null ? null : (int) $row['sets'],
|
||||||
|
'reps' => $row['reps'],
|
||||||
|
'duration_seconds' => $row['duration_seconds'] === null ? null : (int) $row['duration_seconds'],
|
||||||
|
'rest_seconds' => (int) $row['rest_seconds'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$sessionQuery->close();
|
||||||
|
|
||||||
|
foreach ($plans as &$plan) {
|
||||||
|
$plan['sessions'] = array_values($plan['sessions']);
|
||||||
|
}
|
||||||
|
unset($plan);
|
||||||
|
header('Cache-Control: public, max-age=60, stale-while-revalidate=300');
|
||||||
|
api_json(['plans' => array_values($plans)]);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
error_log('Training plans unavailable: ' . $exception->getMessage());
|
||||||
|
api_json(['error' => 'service_unavailable'], 503);
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
?><!doctype html>
|
||||||
|
<html lang="ar" dir="rtl">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="theme-color" content="#f5f7f2">
|
||||||
|
<meta name="description" content="SportPath — خطوات صغيرة، صحة أقوى. خطط حركة وغذاء تساعدك على الاستمرار.">
|
||||||
|
<title>SportPath — طريقك لعافية تدوم</title>
|
||||||
|
<link rel="stylesheet" href="/assets/app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar wrap">
|
||||||
|
<a class="brand" href="/" aria-label="SportPath الصفحة الرئيسية"><span class="brand-mark">S</span> SportPath</a>
|
||||||
|
<a class="top-link" href="#approach">عن البرنامج <span aria-hidden="true">↙</span></a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="hero wrap">
|
||||||
|
<div class="hero-copy">
|
||||||
|
<p class="eyebrow"><span class="pulse"></span> عادات صغيرة. أثر يدوم.</p>
|
||||||
|
<h1>خفّف السرعة.<br><em>وزِد العافية.</em></h1>
|
||||||
|
<p class="intro">حركة تناسب يومك، وأكل تفهمه أكثر، وخطة تتقدم معك — بدون حلول سحرية أو ضغط.</p>
|
||||||
|
<a class="button" href="#approach">اكتشف الفكرة <span aria-hidden="true">←</span></a>
|
||||||
|
<p class="note">رحلتك تبدأ بخطوة واقعية، لا بخطة مثالية.</p>
|
||||||
|
</div>
|
||||||
|
<div class="hero-art" aria-label="رسم تجريدي لمسار تقدم يومي" role="img">
|
||||||
|
<div class="art-orbit orbit-one"></div><div class="art-orbit orbit-two"></div>
|
||||||
|
<div class="art-sun"></div><div class="art-path"></div>
|
||||||
|
<div class="art-card"><span class="card-dot"></span><span>حركة اليوم</span><strong>خطوة بخطوة</strong></div>
|
||||||
|
<span class="art-caption">تقدّمك، بإيقاعك</span>
|
||||||
|
</div>
|
||||||
|
<div class="hero-index" aria-hidden="true"><span>01</span><i></i><span>03</span></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="approach" id="approach">
|
||||||
|
<div class="wrap approach-inner">
|
||||||
|
<p class="eyebrow">طريقة أهدأ للاستمرار</p>
|
||||||
|
<div class="approach-grid">
|
||||||
|
<h2>الصحة ليست تحديًا<br>لأسبوع واحد.</h2>
|
||||||
|
<p>SportPath قيد البناء ليجمع التمرين اليومي وخيارات الطعام ومتابعة التقدم في تجربة واحدة. التوصيات ستراعي مستواك ووقتك، وتبقى قابلة للتعديل — لأن أفضل خطة هي التي تقدر تكملها.</p>
|
||||||
|
</div>
|
||||||
|
<div class="principles">
|
||||||
|
<article><span>01 / الحركة</span><h3>تمارين واضحة</h3><p>شرح بصري قصير، مع بدائل تناسب المساحة والقدرة.</p></article>
|
||||||
|
<article><span>02 / التغذية</span><h3>فهم بدون أحكام</h3><p>مساعدة على تقدير الوجبة، مع مساحة لتصحيح الكمية والمكونات.</p></article>
|
||||||
|
<article><span>03 / الاستمرارية</span><h3>تقدّم واقعي</h3><p>أهداف مرنة وتذكير تختاره أنت، لا تنبيهات مزعجة.</p></article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<footer class="wrap footer"><a class="brand" href="/"><span class="brand-mark">S</span> SportPath</a><span>الصحة رحلة، وكل خطوة تُحسب.</span><span>قريبًا</span></footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user