diff --git a/apps/student_app/lib/data/datasources/curriculum_baked_data.dart b/apps/student_app/lib/data/datasources/curriculum_baked_data.dart deleted file mode 100644 index 00e898d..0000000 --- a/apps/student_app/lib/data/datasources/curriculum_baked_data.dart +++ /dev/null @@ -1,404 +0,0 @@ -/// ============================================================================== -/// SAQEL EDTECH 2.0 - BAKED CURRICULUM DATA SOURCE -/// ============================================================================== -/// -/// مستودع المحتوى الوزاري المخبوز والمدمج محلياً للمواد الثلاث الأساسية للصف العاشر: -/// 1. الرياضيات (Mathematics 10) — منهاج وزارة التربية والتعليم الأردنية -/// 2. الفيزياء (Physics 10) — المتجهات والحركة والتجارب المخبرية -/// 3. اللغة الإنجليزية (English 10 — Action Pack) — المفردات والقراءة والقواعد -/// -/// يضمن تشغيل وتصفح الكتب وأوراق العمل والدروس بنسبة 100% حتى في حال غياب الاتصال. -class CurriculumBakedData { - /// Get baked curriculum document by subject, type, and file path - static String getDocument({ - required String subjectId, - required String type, - String? filePath, - String? title, - }) { - final s = subjectId.toLowerCase(); - final f = (filePath ?? '').toLowerCase(); - final t = (title ?? '').toLowerCase(); - - // 1. MATHEMATICS 10 - if (s.contains('math') || s.contains('رياضيات')) { - if (type == 'worksheet' || t.contains('ورقة عمل') || f.contains('worksheet')) { - return _math10Worksheet1; - } - if (f.contains('geogebra') || t.contains('جيوجبرا')) { - return _math10GeoGebraLab; - } - if (f.contains('intro') || t.contains('مقدمة') || t.contains('مشروع')) { - return _math10Unit1Intro; - } - if (f.contains('lesson_02') || t.contains('نظام') || t.contains('خطي')) { - return _math10Lesson2; - } - return _math10TextbookUnit1; - } - - // 2. PHYSICS 10 - if (s.contains('physic') || s.contains('فيزياء')) { - if (type == 'worksheet' || t.contains('ورقة عمل') || f.contains('worksheet')) { - return _physics10Worksheet1; - } - if (f.contains('activities') || f.contains('تجرب') || t.contains('تجارب') || t.contains('أنشطة')) { - return _physics10ExperimentsBook; - } - return _physics10TextbookUnit1; - } - - // 3. ENGLISH 10 - if (s.contains('english') || s.contains('إنجليز')) { - if (type == 'worksheet' || t.contains('worksheet') || t.contains('ورقة عمل')) { - return _english10Worksheet1; - } - if (f.contains('unit_02') || t.contains('digital mind')) { - return _english10Unit2; - } - return _english10Unit1; - } - - // Generic Default Fallback - return _defaultGeneralCurriculum; - } - - // ============================================================================ - // MATH 10 BAKED DATA - // ============================================================================ - - static const String _math10TextbookUnit1 = """ -## كتاب الرياضيات المقرر — الصف العاشر الأساسي (الفصل الدراسي الأول) -### الوحدة الأولى: الأسس وأنظمة المعادلات (Equations & Systems) -*(منهاج وزارة التربية والتعليم الأردنية المعتمد — صفحات 8 إلى 34)* - ---- - -### الدرس 1: حلُّ معادلاتٍ خاصّةٍ (Solving Special Equations) -- **فكرة الدرس:** حلُّ معادلاتٍ أُسُّ المتغيّرِ فيها عددٌ صحيحٌ موجبٌ أكبرُ من \$2\$ باستخدام التحليل وإخراج العامل المشترك أو الصورة التربيعية. -- **المصطلحات الرياضية:** الصورةُ التربيعيّةُ (Quadratic Form)، العامل المشترك الأكبر (GCF)، التحليل بالتجميع (Grouping). - -#### مسألة اليوم الحياتية (صفحة 8): -> صُندوق هدايا على شكل متوازي مستطيلات حجمه \$1152\\text{ cm}^3\$، وأبعاده بدلالة \$w\$: -> - الطول: \$(18 - w)\\text{ cm}\$ -> - العرض: \$w\\text{ cm}\$ -> - الارتفاع: \$(2w + 4)\\text{ cm}\$ -> **المطلوب:** إيجاد أبعاد الصندوق من خلال تكوين معادلة تكعيبية وحلها. - -#### البند 1: حل المعادلات بإخراج العامل المشترك الأكبر -- **قاعدة:** نجعل طرف المعادلة الأيمن صفراً، ثم نخرج العامل المشترك، ونحل المعادلة الناتجة. -- **مثال 1:** حل المعادلة: \$x^3 + 4x^2 = 5x\$ - \$\$x^3 + 4x^2 - 5x = 0 \\implies x(x^2 + 4x - 5) = 0 \\implies x(x + 5)(x - 1) = 0\$\$ - إذن جذور المعادلة هي: \$\\{-5, 0, 1\\}\$. - -- **أتحقق من فهمي (صفحة 9):** - 1. \$x^3 + 12x = 7x^2 \\implies x(x - 3)(x - 4) = 0 \\implies x \\in \\{0, 3, 4\\}\$. - 2. \$2x^3 = 50x \\implies 2x(x^2 - 25) = 0 \\implies x \\in \\{-5, 0, 5\\}\$. - ---- - -### الدرس 2: حل نظام مكوّن من معادلة خطية ومعادلة تربيعية -- **المفهوم:** نقطة تقاطع المستقيم والمنحنى تمثل حل النظام المشترك. -- **طريقة التعويض:** نجعل أحد المتغيرين موضوعاً للقانون في المعادلة الخطية ونعوضه في المعادلة التربيعية. -- **مثال محلول:** - \$\$\\begin{cases} y - x = 1 \\\\ x^2 + y^2 = 25 \\end{cases}\$\$ - من المعادلة الأولى: \$y = x + 1\$. نعوض في الثانية: - \$\$x^2 + (x + 1)^2 = 25 \\implies 2x^2 + 2x - 24 = 0 \\implies x^2 + x - 12 = 0\$\$ - \$\$(x + 4)(x - 3) = 0 \\implies x = 3 \\text{ أو } x = -4\$\$ - - عندما \$x = 3 \\implies y = 4\$ (النقطة: \$(3, 4)\$) - - عندما \$x = -4 \\implies y = -3\$ (النقطة: \$( -4, -3)\$) - ---- - -### معمل برمجية جيوجبرا (GeoGebra Lab): -- رسم الدائرة \$x^2 + y^2 = 25\$ بيانيا وملاحظة تقاطعها مع المستقيم \$y - x = 1\$ في نقطتين هندسيتين واضحتين. -"""; - - static const String _math10Lesson2 = """ -## الدرس 2: حل نظام من معادلة خطية ومعادلة تربيعية -### الأهداف والنتاجات الوزارية: -- حل نظام مكون من معادلة خطية ومعادلة تربيعية جبرياً باستخدام طريقة التعويض. -- تمثيل النظام بيانياً وتحديد عدد الحلول الممكنة (حلان، حل واحد مماس، أو لا يوجد حل حقيقي). - -### خطوات الحل النموذجية: -1. جعل أحد المتغيرين موضوعاً للقانون من المعادلة الخطية (الأسهل معامل). -2. التعويض في المعادلة التربيعية لتتحول إلى معادلة بمتغير واحد. -3. فك الأقواس والتبسيط وترتيب المعادلة على الصورة العامة \$ax^2 + bx + c = 0\$. -4. الحل بالتحليل إلى العوامل أو القانون العام. -5. إيجاد قيمة المتغير الآخر وكتابة مجموعة الحل على شكل أزواج مرتبة \$(x, y)\$. -"""; - - static const String _math10GeoGebraLab = """ -## معملُ برمجيةِ جيوجبرا: حلُّ أنظمةِ المعادلاتِ بيانياً -*(الصفحات 16 - 17 — منهاج الرياضيات المعتمد)* - -### النشاط: -\$\$\\begin{cases} x^2 + y^2 = 13 \\\\ x^2 - y = 7 \\end{cases}\$\$ - -نقاط التقاطع الأربع: -\$\$(3, 2), \\quad (-3, 2), \\quad (2, -3), \\quad (-2, -3)\$\$ - -### أتدرب (صفحة 17): -1) \$\\Delta = -144 < 0 \\implies \\varnothing\$ -2) \$\\{(2, 4), (-2, 4)\\}\$ -3) \$\\{(8.625, 7.375)\\}\$ -4) \$\\Delta = -295 < 0 \\implies \\varnothing\$ -5) \$\\left\\{\\left(\\frac{3}{\\sqrt{37}}, \\frac{18}{\\sqrt{37}}\\right), \\left(-\\frac{3}{\\sqrt{37}}, -\\frac{18}{\\sqrt{37}}\\right)\\right\\}\$ -6) \$\\Delta = -59 < 0 \\implies \\varnothing\$ - ---- -"""; - - static const String _math10Unit1Intro = """ -## مقدمة ومشروع الوحدة: أنظمة المعادلات في حياتنا -*(الوحدة الأولى — الصف العاشر الأساسي)* - -### ما أهمية هذه الوحدة؟ -تُستخدَم أنظمةُ المعادلاتِ في كثيرٍ من مجالاتِ الحياةِ. فخبراءُ الأرصادِ الجويّةِ -مثلاً- يُعبِّرون عنِ العلاقةِ بينَ درجةِ الحرارةِ، وسرعةِ الرّياحِ، والضغطِ الجويِّ، ومعدلِ الهطلِ، باستخدامِ نظامِ معادلاتٍ غيرِ خطيٍّ؛ ذلكَ أنَّ أيَّ تغيُّرٍ في أحدِ هذهِ العواملِ يؤدّي إلى تغيُّرٍ في العواملِ الأخرى. - -### تعلَّمْتُ سابقاً: -- حلَّ معادلاتٍ تربيعيّةٍ باستعمالِ التحليلِ. -- حلَّ معادلاتٍ تربيعيّةٍ باستعمالِ القانونِ العامِّ. -- حلَّ أنظمةِ معادلاتٍ تتضمَّنُ معادلتيْنِ خطيّتيْنِ بمتغيِّريْنِ. - -### سأتعلَّمُ في هذهِ الوحدةِ: -- حلَّ معادلاتٍ خاصّةٍ أُسُّ المتغيّرِ فيها عددٌ صحيحٌ موجبٌ أكبرُ من \$2\$. -- حلَّ نظامٍ مُكوَّنٍ من معادلةٍ خطيّةٍ، وأُخرى تربيعيّةٍ. -- حلَّ نظامٍ مُكوَّنٍ من معادلتيْنِ تربيعيّتيْنِ. - -### مشروع الوحدة: أنظمةُ المعادلاتِ في حياتِنا (صفحة 7) -- **فكرة المشروع:** البحثُ عن أنظمةِ معادلاتٍ في نماذجَ حياتيّةٍ. -- **المواد والأدوات:** شبكةُ الإنترنت، برمجيّةُ جيوجبرا (GeoGebra). -- **الصيغة المستخدمة في جيوجبرا:** - \$\$\\text{FitPoly}(\\{C, D, E, F, G, H, I, J, K, L\\}, n)\$\$ - ---- -"""; - - static const String _math10Worksheet1 = """ -## ورقة عمل تدريبية رقم (1): الأسس وأنظمة المعادلات -### المبحث: الرياضيات — الصف العاشر الأساسي -**الجهة المصدرة:** بنك الأسئلة المعتمد لمنصة صَقِل 2.0 بالتعاون مع معلمي الميدان - ---- - -### السؤال الأول: حل المعادلات الآتية بإخراج العامل المشترك والتحليل (4 علامات) -1. \$x^4 - 13x^2 + 36 = 0\$ (على الصورة التربيعية) - > **إرشاد الحل:** افرض \$u = x^2\$، فتصبح المعادلة \$u^2 - 13u + 36 = 0\$. - > التحليل: \$(u - 9)(u - 4) = 0 \\implies u = 9 \\text{ أو } u = 4\$. - > إذن: \$x = \\pm 3, \\quad x = \\pm 2\$. - -2. \$3x^3 + 6x^2 - 12x - 24 = 0\$ (بالتجميع) - > **إرشاد الحل:** \$3x^2(x + 2) - 12(x + 2) = 0 \\implies (x + 2)(3x^2 - 12) = 0\$. - > الحلول: \$x = -2\$ (جذر مكرر) و \$x = 2\$. - ---- - -### السؤال الثاني: حل النظام الجبري التالي وتحقق من صحة الحل (6 علامات) -\$\$\\begin{cases} y = 2x + 3 \\\\ x^2 + y = 6 \\end{cases}\$\$ - -**خطوات الإجابة:** -- نعوض \$y = 2x + 3\$ في المعادلة الثانية: - \$\$x^2 + 2x + 3 = 6 \\implies x^2 + 2x - 3 = 0\$\$ -- التحليل: \$(x + 3)(x - 1) = 0\$. - - إذا \$x = 1 \\implies y = 2(1) + 3 = 5\$ ➔ الحل الأول: \$(1, 5)\$. - - إذا \$x = -3 \\implies y = 2(-3) + 3 = -3\$ ➔ الحل الثاني: \$( -3, -3)\$. - ---- - -### السؤال الثالث: مسألة تطبيقية (تحدي التفكير الرياضي) -> سجادة مستطيلة الشكل محيطها \$28\\text{ m}\$ ومساحتها \$48\\text{ m}^2\$. جِد بعدي السجادة بتكوين نظام معادلات. -> **المعادلات:** \$2(x + y) = 28 \\implies x + y = 14\$ و \$x \\cdot y = 48\$. -> الأبعاد هي: الطول \$8\\text{ m}\$ والعرض \$6\\text{ m}\$. -"""; - - // ============================================================================ - // PHYSICS 10 BAKED DATA - // ============================================================================ - - static const String _physics10TextbookUnit1 = """ -## كتاب الفيزياء المقرر — الصف العاشر الأساسي (الفصل الدراسي الأول) -### الوحدة الأولى: المتجهات والكميات الفيزيائية (Vectors in Physics) -*(منهاج وزارة التربية والتعليم الأردنية المنقح — صفحات 10 إلى 48)* - ---- - -### الدرس 1: الكميات القياسية والكميات المتجهة -- **الكمية القياسية (Scalar Quantity):** كمية فيزيائية تُحدد بالمقدار ووحدة القياس فقط. - - *أمثلة:* الكتلة (\$\\text{kg}\$)، الزمن (\$\\text{s}\$)، المسافة (\$\\text{m}\$)، درجة الحرارة (\$\\text{K}\$)، الطاقة والشغل (\$\\text{J}\$). -- **الكمية المتجهة (Vector Quantity):** كمية فيزيائية تُحدد بالمقدار والاتجاه ونقطة التأثير معاً. - - *أمثلة:* الإزاحة (\$\\vec{d}\$)، السرعة المتجهة (\$\\vec{v}\$)، التسارع (\$\\vec{a}\$)، القوة (\$\\vec{F}\$). - -### الدرس 2: تمثيل المتجهات بيانياً وتحليلياً -- يُرسم المتجه كسهم يبدأ من نقطة الأصل، حيث يدل طول السهم على مقدار المتجه، ويشير رأس السهم إلى اتجاهه. -- **زاوية الاتجاه المرجعية (\$\\theta\$):** الزاوية التي يصنعها المتجه مع محور السينات الموجب (\$+x\$) بعكس عقارب الساعة. - -### تحليل المتجهات إلى مركبات (Vector Resolution): -لأي متجه \$\\vec{A}\$ يصنع زاوية \$\\theta\$ مع محور السينات: -1. **المركبة الأفقية (السينية):** \$A_x = A \\cos\\theta\$ -2. **المركبة الرأسية (الصادية):** \$A_y = A \\sin\\theta\$ -3. **مقدار المحصلة:** \$A = \\sqrt{A_x^2 + A_y^2}\$ -4. **اتجاه المحصلة:** \$\\tan\\theta = \\left|\\frac{A_y}{A_x}\\right|\$ - ---- - -### ضرب المتجهات (Vector Multiplication): -1. **الضرب القياسي / النقطي (Dot Product):** - \$\$\\vec{A} \\cdot \\vec{B} = |A| |B| \\cos\\phi\$\$ - - الناتج كمية قياسية (مثل حساب الشغل: \$W = \\vec{F} \\cdot \\vec{d}\$). - - ينعدم الضرب القياسي إذا كان المتجهان متعامدين (\$\\phi = 90^\\circ\$ لأن \$\\cos 90^\\circ = 0\$). - -2. **الضرب المتجهي / التقاطعي (Cross Product):** - \$\$|\\vec{A} \\times \\vec{B}| = |A| |B| \\sin\\phi\$\$ - - الناتج متجه عمودي على المستوى الذي يحوي المتجهين، ويُحدد اتجاهه باستخدام قاعدة اليد اليمنى. -"""; - - static const String _physics10Worksheet1 = """ -## ورقة عمل وتطبيقات فيزيائية: تحليل المتجهات وقوانين نيوتن -### المبحث: الفيزياء — الصف العاشر الأساسي -**إعداد وتدقيق:** نخبة معلمي الفيزياء المعتمدين في منصة صَقِل - ---- - -### المسألة الأولى: تحليل قوة مائلة -> تؤثر قوة مقدارها \$F = 100\\text{ N}\$ في صندوق خشبي بزاوية \$37^\\circ\$ فوق الأفق. -> *(علماً بأن \$\\cos 37^\\circ = 0.8\$ و \$\\sin 37^\\circ = 0.6\$)* -> **المطلوب:** -> 1. احسب المركبة الأفقية للقوة \$F_x\$. -> - **الحل:** \$F_x = F \\cos 37^\\circ = 100 \\times 0.8 = 80\\text{ N}\$ (باتجاه الشرق). -> 2. احسب المركبة الرأسية للقوة \$F_y\$. -> - **الحل:** \$F_y = F \\sin 37^\\circ = 100 \\times 0.6 = 60\\text{ N}\$ (باتجاه الأعلى). - ---- - -### المسألة الثانية: حساب الضرب القياسي والمتجهي -> متجهان: \$|A| = 6\\text{ units}\$ و \$|B| = 8\\text{ units}\$، والزاوية بينهما \$\\phi = 30^\\circ\$. -> 1. الضرب القياسي: \$\\vec{A} \\cdot \\vec{B} = 6 \\times 8 \\times \\cos 30^\\circ = 48 \\times \\frac{\\sqrt{3}}{2} \\approx 41.57\\text{ units}\$. -> 2. الضرب المتجهي: \$|\\vec{A} \\times \\vec{B}| = 6 \\times 8 \\times \\sin 30^\\circ = 48 \\times 0.5 = 24\\text{ units}\$. -"""; - - static const String _physics10ExperimentsBook = """ -## كتاب التجارب والأنشطة العلمية والعملية — فيزياء الصف العاشر -### التجربة الاستهلالية: إيجاد محصلة قوتين عملياً (طاولة القوى) - -#### هدف التجربة: -التحقق من صحة جمع المتجهات بطريقة متوازي الأضلاع وقانون جيب التمام بيانياً وحسابياً. - -#### الأدوات المستخدمة: -- طاولة القوى الدائرية المدرجة من \$0^\\circ\$ إلى \$360^\\circ\$. -- حلقات ربط خفيفة وخيوط وبكرات ملساء. -- كتل أثقال معيارية (جرامات). - -#### خطوات العمل والملاحظة: -1. تعليق كتلة \$m_1 = 150\\text{ g}\$ عند زاوية \$0^\\circ\$. -2. تعليق كتلة \$m_2 = 200\\text{ g}\$ عند زاوية \$90^\\circ\$. -3. اتزان الحلقة في المنتصف بوضع قوة موازنة ثالثة في الربع المقابل بمقدار \$250\\text{ g}\$ وزاوية \$233^\\circ\$. -4. **الاستنتاج:** محصلة قوتين متعامدتين تُحسب بفيثاغورس: - \$\$R = \\sqrt{F_1^2 + F_2^2} = \\sqrt{150^2 + 200^2} = 250\\text{ gf}\$\$ -"""; - - // ============================================================================ - // ENGLISH 10 BAKED DATA - // ============================================================================ - - static const String _english10Unit1 = """ -## Unit 01: Looking Good — Action Pack 10 -### Official Ministry of Education Curriculum (Grade 10 — Semester 1) - ---- - -### 1. Key Vocabulary & Lexical Collocations -- **Casual clothes:** Comfortable, informal garments worn everyday (jeans, polo shirts, sneakers). -- **Formal attire:** Dignified clothing worn for ceremonial or official occasions (suits, thobes). -- **Traditional costume:** Distinctive heritage garments representing national Jordanian identity. -- **First impression:** The immediate opinion someone forms upon encountering another for the first time. -- **Subconscious judgement:** Cognitive assumptions made rapidly without deliberate thought. -- **Reliability:** The quality of being trustworthy, dependable, and consistent. - ---- - -### 2. Reading Passage: The Power of First Impressions -Research conducted by behavioral psychologists confirms that humans formulate strong opinions about others within the first **seven seconds** of an encounter. These evaluations encompass trustworthiness, social competence, and authority. - -In a landmark experiment, educators who dressed professionally in tailored coats commanded greater attention and respect from students than those in untidy casual shirts. Clothing functions as a non-verbal language signaling respect for the learning environment. - ---- - -### 3. Grammar Workshop: Articles (a, an, the, and Zero Article) -1. **Indefinite Article (a / an):** - - Used before singular countable nouns mentioned for the first time. - - Example: *Hamza bought **a** new laboratory manual yesterday.* - - Example: *The teacher gave **an** insightful explanation.* - -2. **Definite Article (the):** - - Used when referring to a specific item known to both speaker and listener, or unique phenomena. - - Example: *Please open **the** physics textbook on page 24.* - - Example: ***The** sun rises in the east.* - -3. **Zero Article (Ø):** - - Used with plural countable nouns and uncountable nouns when speaking generally. - - Example: ***Ø** Science helps us understand the universe.* - - Example: ***Ø** Jordanian students excel in mathematics.* -"""; - - static const String _english10Unit2 = """ -## Unit 02: The Digital Mind — Action Pack 10 -### Artificial Intelligence, Space Exploration & Present Perfect - ---- - -### 1. Reading Text: Voyager & The Deep Cosmos -In 1977, NASA launched the twin probes **Voyager 1** and **Voyager 2**. More than four decades later, they have crossed into interstellar space, transmitting vital telemetry across billions of miles back to Earth. - -### 2. Grammar: Present Perfect vs. Past Simple -- **Present Perfect (have / has + V3):** Expresses actions occurring at an unspecified past time with continuing relevance. - - *Example: Scientists **have made** remarkable strides in artificial intelligence.* -- **Past Simple (V2):** Expresses completed past events with a specified time marker. - - *Example: Engineers **launched** the spacecraft in 1977.* -"""; - - static const String _english10Worksheet1 = """ -## Action Pack 10 — Practice Worksheet: Unit 01 (Looking Good) -**Issued by:** Saqel Adaptive Learning Platform (Department of English Language) - ---- - -### Part A: Vocabulary & Word Choice (Choose the correct word) -1. In Jordan, wearing the traditional kuffiyeh represents cultural (attire / friction / hazard). - - **Answer:** **attire** -2. Within seven seconds, people make a (subconscious / superficial / metallic) judgment about character. - - **Answer:** **subconscious** - ---- - -### Part B: Grammar — Complete with (a, an, the, or Ø for zero article) -1. Ahmad is studying ______ engineering at university. - - **Answer:** **Ø (zero article)** — Academic subjects do not take an article. -2. We had ______ unforgettable experience at Petra last weekend. - - **Answer:** **an** — Starts with a vowel sound. -3. Have you finished reading ______ physics worksheet given by Mr. Majali? - - **Answer:** **the** — Specific worksheet known to both. - ---- - -### Part C: Writing Challenge -Write three coherent sentences describing the difference between casual and formal clothing in educational settings. -"""; - - // ============================================================================ - // GENERAL FALLBACK - // ============================================================================ - - static const String _defaultGeneralCurriculum = """ -## المنهاج الوزاري المعتمد — ملخص النتاجات والأنشطة -### وزارة التربية والتعليم — المملكة الأردنية الهاشمية - ---- - -### النتاجات العامة للمبحث: -1. فهم واستيعاب المفاهيم والمصطلحات الأساسية المعتمدة في الإطار العام للمناهج. -2. تنمية مهارات التفكير العلمي وحل المسائل الرياضية والفيزيائية وفق خطوات منهجية. -3. الربط بين المعرفة النظرية والتطبيقات الحياتية والمختبرات الذكية. -4. التدرب على أنماط أسئلة الاختبارات الوزارية من خلال بنك الأسئلة التكيفي. -"""; -} diff --git a/apps/student_app/lib/data/models/socratic_checkpoint_model.dart b/apps/student_app/lib/data/models/socratic_checkpoint_model.dart index 94a6497..f4a87d9 100644 --- a/apps/student_app/lib/data/models/socratic_checkpoint_model.dart +++ b/apps/student_app/lib/data/models/socratic_checkpoint_model.dart @@ -79,6 +79,7 @@ class SocraticOptionModel { /// Model for Lesson Playback Data (Streams, Versions, Chapters, Checkpoints) class LessonPlaybackData { final int lessonId; + final String? videoVersionId; final String title; final int durationSeconds; final String videoUrl; @@ -89,6 +90,7 @@ class LessonPlaybackData { const LessonPlaybackData({ required this.lessonId, + this.videoVersionId, required this.title, required this.durationSeconds, required this.videoUrl, @@ -129,9 +131,10 @@ class LessonPlaybackData { final lastPos = (json['last_position_seconds'] ?? playback['last_position_seconds'] ?? json['progress']?['position_seconds']) as num?; return LessonPlaybackData( - lessonId: (lesson['id'] as num?)?.toInt() ?? 0, - title: lesson['title']?.toString() ?? 'الدرس التفاعلي', - durationSeconds: (lesson['duration_seconds'] as num?)?.toInt() ?? 0, + lessonId: (lesson['id'] as num?)?.toInt() ?? (json['lesson_id'] as num?)?.toInt() ?? 0, + videoVersionId: json['video_version_id']?.toString(), + title: lesson['title']?.toString() ?? json['title']?.toString() ?? 'الدرس التفاعلي', + durationSeconds: (lesson['duration_seconds'] as num?)?.toInt() ?? (json['duration_seconds'] as num?)?.toInt() ?? 0, videoUrl: vidUrl, storageType: playback['storage_type']?.toString() ?? 'api_upload', availableVersions: versions, @@ -142,6 +145,7 @@ class LessonPlaybackData { LessonPlaybackData copyWith({ int? lessonId, + String? videoVersionId, String? title, int? durationSeconds, String? videoUrl, @@ -152,6 +156,7 @@ class LessonPlaybackData { }) { return LessonPlaybackData( lessonId: lessonId ?? this.lessonId, + videoVersionId: videoVersionId ?? this.videoVersionId, title: title ?? this.title, durationSeconds: durationSeconds ?? this.durationSeconds, videoUrl: videoUrl ?? this.videoUrl, diff --git a/apps/student_app/lib/data/models/subject_model.dart b/apps/student_app/lib/data/models/subject_model.dart index 7c19fcb..73829b9 100644 --- a/apps/student_app/lib/data/models/subject_model.dart +++ b/apps/student_app/lib/data/models/subject_model.dart @@ -30,7 +30,7 @@ class SubjectModel { this.stream = 'scientific', this.totalUnits = 0, this.totalLessons = 0, - this.masteryScore = 85.0, + this.masteryScore = 0, this.units = const [], this.textbooks = const [], this.worksheets = const [], @@ -95,7 +95,7 @@ class SubjectModel { stream: json['stream']?.toString() ?? 'scientific', totalUnits: parsedUnits.isNotEmpty ? parsedUnits.length : styling.defaultUnits, totalLessons: parsedUnits.fold(0, (sum, u) => sum + u.lessons.length), - masteryScore: (json['mastery_score'] as num?)?.toDouble() ?? 88.0, + masteryScore: (json['mastery_score'] as num?)?.toDouble() ?? 0, units: parsedUnits, textbooks: parsedTextbooks, worksheets: parsedWorksheets, @@ -198,6 +198,7 @@ class CurriculumLessonItemModel { final String title; final List outcomes; final String? markdownFilePath; + final String? curriculumLessonId; final int durationSeconds; final int checkpointsCount; final bool isCompleted; @@ -208,8 +209,9 @@ class CurriculumLessonItemModel { required this.title, this.outcomes = const [], this.markdownFilePath, - this.durationSeconds = 1200, // 20 mins default - this.checkpointsCount = 3, + this.curriculumLessonId, + this.durationSeconds = 0, + this.checkpointsCount = 0, this.isCompleted = false, this.hasVideo = false, }); @@ -233,24 +235,17 @@ class CurriculumLessonItemModel { ? filePath.replaceAll(RegExp(r'[^a-zA-Z0-9_]'), '_') : 'lesson_${(json['title']?.toString() ?? 'default').hashCode.abs()}'); - // Video availability: - // 1. Live server enriched: json['has_video'] == true or json['video_url'] is present - // 2. Exact uploaded lessons in Grade 10 Math Unit 1 (Intro, Lesson 1, Lesson 2) - final bool hasVideoExplicit = (json['has_video'] == true) || - (json['video_url'] != null && json['video_url'].toString().isNotEmpty) || - (filePath != null && ( - filePath.contains('math_10/semester_1/unit_01/intro_and_project') || - filePath.contains('math_10/semester_1/unit_01/lesson_01') || - filePath.contains('math_10/semester_1/unit_01/lesson_02') - )); + // Only the server may declare a published lesson playable. + final bool hasVideoExplicit = json['has_video'] == true; return CurriculumLessonItemModel( id: stableId, title: json['title']?.toString() ?? 'درس بدون عنوان', outcomes: outs, markdownFilePath: filePath, - durationSeconds: (json['duration_seconds'] as num?)?.toInt() ?? 1200, - checkpointsCount: (json['checkpoints_count'] as num?)?.toInt() ?? 3, + curriculumLessonId: json['curriculum_lesson_id']?.toString(), + durationSeconds: (json['duration_seconds'] as num?)?.toInt() ?? 0, + checkpointsCount: (json['checkpoints_count'] as num?)?.toInt() ?? 0, isCompleted: (json['is_completed'] as bool?) ?? false, hasVideo: hasVideoExplicit, ); diff --git a/apps/student_app/lib/data/repositories/curriculum_repository.dart b/apps/student_app/lib/data/repositories/curriculum_repository.dart index 4457b43..64bae52 100644 --- a/apps/student_app/lib/data/repositories/curriculum_repository.dart +++ b/apps/student_app/lib/data/repositories/curriculum_repository.dart @@ -147,20 +147,30 @@ class CurriculumRepository { return liveSubjects; } - /// Fetch lesson playback details (streams, checkpoints, and versions) from Live API - Future getLessonPlayback(String curriculumKey, {String? subjectId, String? unitId, String? title}) async { - AppLogger.log('Fetching live playback data for curriculum key $curriculumKey (title: $title)...', tag: 'CURRICULUM_REPO'); - - final Map queryParams = {'curriculum_key': curriculumKey}; - if (title != null && title.isNotEmpty) { - queryParams['title'] = title; - } - final res = await _api.get('/api/lessons/playback', queryParams: queryParams); - if (res is Map && res['data'] != null) { - return LessonPlaybackData.fromJson(Map.from(res['data'])); - } - - throw ApiException('فشل جلب بيانات تشغيل الدرس من الخادم'); + Future> getPublishedLessonVideos(String curriculumLessonId) async { + final res = await _api.get('/api/curriculum/lessons/$curriculumLessonId/videos'); + final data = res is Map ? res['data'] : null; + final raw = data is Map ? data['items'] : null; + if (raw is! List) return const []; + return raw.whereType().map((item) => PublishedLessonVideoModel.fromJson(Map.from(item))).where((item) => item.videoVersionId.isNotEmpty).toList(); + } + + Future getVideoVersionPlayback(String videoVersionId) async { + final res = await _api.get('/api/video-versions/$videoVersionId/playback'); + if (res is Map && res['data'] is Map) return LessonPlaybackData.fromJson(Map.from(res['data'])); + throw ApiException('فشل جلب تشغيل الحصة المنشورة من الخادم'); + } + + Future startWatchSession(String videoVersionId) async { + final res = await _api.post('/api/video-versions/$videoVersionId/watch-sessions', body: const {}); + final data = res is Map ? res['data'] : null; + final source = data is Map ? data : res; + final id = source is Map ? source['watch_session_id']?.toString() : null; + if (id == null || id.isEmpty) throw ApiException('لم ينشئ الخادم جلسة مشاهدة.'); + return id; + } + Future recordWatchEvent(String sessionId, int sequenceNo, String eventType, int positionMs) async { + await _api.post('/api/watch-sessions/$sessionId/events', body: {'sequence_no': sequenceNo, 'event_type': eventType, 'position_ms': positionMs}); } Future saveProgress({required int lessonId, required int positionSeconds, required int watchedSeconds}) async { @@ -178,3 +188,16 @@ class CurriculumRepository { }); } } + +class PublishedLessonVideoModel { + final String videoVersionId; + final String submissionId; + final String teacherName; + final double rating; + final int ratingCount; + final bool isNew; + const PublishedLessonVideoModel({required this.videoVersionId, required this.submissionId, required this.teacherName, required this.rating, required this.ratingCount, required this.isNew}); + factory PublishedLessonVideoModel.fromJson(Map json) => PublishedLessonVideoModel( + videoVersionId: json['video_version_id']?.toString() ?? '', submissionId: json['submission_id']?.toString() ?? '', teacherName: json['teacher_name']?.toString() ?? 'معلم معتمد', rating: (json['rating'] as num?)?.toDouble() ?? 0, ratingCount: (json['rating_count'] as num?)?.toInt() ?? 0, isNew: json['is_new'] == true, + ); +} diff --git a/apps/student_app/lib/logic/cubits/video_playback_cubit.dart b/apps/student_app/lib/logic/cubits/video_playback_cubit.dart index 2da20e9..e14d12a 100644 --- a/apps/student_app/lib/logic/cubits/video_playback_cubit.dart +++ b/apps/student_app/lib/logic/cubits/video_playback_cubit.dart @@ -71,6 +71,9 @@ class VideoPlaybackError extends VideoPlaybackState { class VideoPlaybackCubit extends Cubit { final CurriculumRepository _repo; int _lastObservedPosition = -1; + String? _watchSessionId; + int _watchSequence = 1; + int _lastWatchEventPosition = 0; VideoPlaybackCubit({CurriculumRepository? repo}) : _repo = repo ?? CurriculumRepository(), @@ -86,19 +89,30 @@ class VideoPlaybackCubit extends Cubit { return fallbackId ?? 'lesson_default'; } - Future loadLesson(CurriculumLessonItemModel lesson, {SubjectModel? subject}) async { + Future loadLesson(CurriculumLessonItemModel lesson, {SubjectModel? subject, String? selectedVideoVersionId}) async { AppLogger.log('Loading Socratic playback for lesson: ${lesson.title}', tag: 'VIDEO_CUBIT'); _lastObservedPosition = -1; emit(VideoPlaybackLoading()); final storageKey = _getLessonStorageKey(lesson); try { - final cleanKey = (lesson.markdownFilePath ?? lesson.id).replaceAll(RegExp(r'\.md$'), ''); - var playback = await _repo.getLessonPlayback(cleanKey, subjectId: subject?.id, title: lesson.title); + final versionId = lesson.curriculumLessonId; + if (versionId == null || versionId.isEmpty) { + throw StateError('هذا الدرس غير منشور بعد ضمن حزمة المحتوى المعتمدة.'); + } + final published = await _repo.getPublishedLessonVideos(versionId); + if (published.isEmpty) throw StateError('لا توجد حصة منشورة ومصرح بها لهذا الدرس بعد.'); + // Selection is performed in the lesson screen when several teachers exist. + var playback = await _repo.getVideoVersionPlayback(selectedVideoVersionId ?? published.first.videoVersionId); if (playback.videoUrl.isEmpty) { throw StateError('لم يربط الخادم فيديو R2 بهذا الدرس بعد.'); } + _watchSessionId = null; _watchSequence = 1; _lastWatchEventPosition = 0; + if (playback.videoVersionId != null && playback.videoVersionId!.isNotEmpty) { + try { _watchSessionId = await _repo.startWatchSession(playback.videoVersionId!); } catch (_) {} + } + // Load saved resume position strictly per video int resumePos = playback.lastPositionSeconds ?? 0; Set passedIds = {}; @@ -151,6 +165,13 @@ class VideoPlaybackCubit extends Cubit { } } + Future endWatchSession() async { + final session = _watchSessionId; + if (session == null) return; + _watchSessionId = null; + try { await _repo.recordWatchEvent(session, ++_watchSequence, 'end', _lastObservedPosition.clamp(0, 1 << 31) * 1000); } catch (_) {} + } + void togglePlayPause() { final currentState = state; if (currentState is VideoPlaybackReady && currentState.activeCheckpoint == null) { @@ -197,11 +218,11 @@ class VideoPlaybackCubit extends Cubit { ); } if (currentState.playbackData.lessonId > 0) { - await _repo.saveProgress( - lessonId: currentState.playbackData.lessonId, - positionSeconds: positionSeconds, - watchedSeconds: watchedSeconds, - ); + await _repo.saveProgress(lessonId: currentState.playbackData.lessonId, positionSeconds: positionSeconds, watchedSeconds: watchedSeconds); + } + if (_watchSessionId != null && positionSeconds - _lastWatchEventPosition >= 30) { + _watchSequence++; _lastWatchEventPosition = positionSeconds; + await _repo.recordWatchEvent(_watchSessionId!, _watchSequence, 'heartbeat', positionSeconds * 1000); } } catch (e) { AppLogger.log('Progress sync deferred: $e', tag: 'VIDEO_CUBIT'); diff --git a/apps/student_app/lib/presentation/screens/curriculum/curriculum_document_viewer_screen.dart b/apps/student_app/lib/presentation/screens/curriculum/curriculum_document_viewer_screen.dart index 73bdeda..db0f260 100644 --- a/apps/student_app/lib/presentation/screens/curriculum/curriculum_document_viewer_screen.dart +++ b/apps/student_app/lib/presentation/screens/curriculum/curriculum_document_viewer_screen.dart @@ -4,7 +4,6 @@ import '../../../core/network/api_client.dart'; import '../../../core/services/local_document_storage_service.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/utils/saqel_toast.dart'; -import '../../../data/datasources/curriculum_baked_data.dart'; import '../../widgets/luxury_widgets.dart'; import '../../widgets/interactive_english_passage_view.dart'; import 'physics_interactive_lab_view.dart'; @@ -19,7 +18,7 @@ import 'english_interactive_lab_view.dart'; /// الهدف المعماري: /// استعراض الكتب المدرسية المقررة والمذكرات الوزارية وأوراق العمل محلياً وسحابياً: /// 1. دعم التخزين المحلي (Offline Storage): قراءة وتصفح دائم دون اتصال بعد الحفظ. -/// 2. محتوى وزاري مخبوز وأصيل (CurriculumBakedData) للمواد الثلاث (رياضيات 10، فيزياء 10، إنجليزي 10). +/// 2. محتوى وزاري منشور من الخادم أو نسخة محلية موثقة منه فقط. /// 3. دعم الناطق الصوتي الذكي (English TTS) المدمج لقراءة النصوص والمفردات. /// 4. ربط فوري بالمختبر التفاعلي (Virtual Lab) لمفاهيم المتجهات والفيزياء. /// 5. التحكم بحجم الخط وزر الحفظ في الجهاز. @@ -55,6 +54,7 @@ class _CurriculumDocumentViewerScreenState extends State _sections = []; + String? _loadError; bool get _isEnglish => widget.subjectTitle.contains('إنجليز') || @@ -126,20 +126,14 @@ class _CurriculumDocumentViewerScreenState extends State _isLoading = false); return; } - } catch (_) { - // Graceful fallback to rich baked curriculum data + } catch (_) {} + + if (mounted) { + setState(() { + _loadError = 'المستند المطلوب غير متاح حالياً. لا يمكن عرض بديل لدرس مختلف.'; + _isLoading = false; + }); } - - // 3. Fallback to authentic Jordanian curriculum baked content - final baked = CurriculumBakedData.getDocument( - subjectId: _effectiveSubjectId, - type: widget.documentType, - filePath: _effectiveFilePath, - title: widget.title, - ); - - _processContent(baked); - if (mounted) setState(() => _isLoading = false); } Future _toggleSaveLocal() async { @@ -266,7 +260,15 @@ class _CurriculumDocumentViewerScreenState extends State createState() => - _EnglishInteractiveLabViewState(); -} - -class _EnglishInteractiveLabViewState extends State - with SingleTickerProviderStateMixin { - late TabController _tabController; - final EnglishReadingService _readingService = EnglishReadingService(); - bool _isPlayingAudio = false; - - // Hierarchical Unit & Lesson Structure - int _selectedUnitIndex = 0; - int _selectedLessonIndex = 0; - - // Mind Map Zoom and Display Mode (0 = Digital Interactive Schema, 1 = Teacher Whiteboard) - int _mindMapDisplayMode = 0; - final TransformationController _mindMapTransformController = TransformationController(); - - // Grammar Simulator State - int _selectedGrammarTopicIndex = 0; // 0 = Present Perfect vs Past Simple, 1 = Modals, 2 = Relative Clauses - - // Phonetics & Vocabulary State - int _selectedVocabUnitIndex = 0; - int _selectedWordIndex = 0; - - final List> _curriculumUnits = [ - { - 'name': 'Unit 1: Looking Good', - 'name_ar': 'الوحدة 1: المظهر والأناقة', - 'badge': 'Action Pack 10', - 'lessons': [ - { - 'title': 'Lesson 1: Reading & Personal Appearance', - 'title_ar': 'الدرس 1: القراءة ومظهر الشخصية', - 'grammarTopic': 0, - 'vocabUnit': 0, - 'tab': 1, - }, - { - 'title': 'Lesson 2: Present Perfect vs Past Simple', - 'title_ar': 'الدرس 2: المضارع التام والماضي البسيط', - 'grammarTopic': 0, - 'vocabUnit': 0, - 'tab': 0, - }, - { - 'title': 'Lesson 3: Vocabulary & Collocations', - 'title_ar': 'الدرس 3: بنك الكلمات والمتلازمات', - 'grammarTopic': 0, - 'vocabUnit': 0, - 'tab': 2, - }, - { - 'title': 'Lesson 4: Grammar Map & Tense Timeline', - 'title_ar': 'الدرس 4: المخطط الذهني الشامل للأزمنة', - 'grammarTopic': 0, - 'vocabUnit': 0, - 'tab': 3, - }, - ], - }, - { - 'name': 'Unit 2: The Digital Mind', - 'name_ar': 'الوحدة 2: العقل الرقمي والذكاء الاصطناعي', - 'badge': 'AI & Tech', - 'lessons': [ - { - 'title': 'Lesson 1: AI & Interstellar Voyagers', - 'title_ar': 'الدرس 1: الذكاء الاصطناعي ومسبارات الفضاء', - 'grammarTopic': 0, - 'vocabUnit': 1, - 'tab': 1, - }, - { - 'title': 'Lesson 2: Algorithms & Tech Breakthroughs', - 'title_ar': 'الدرس 2: الخوارزميات والقفزات النوعية', - 'grammarTopic': 0, - 'vocabUnit': 1, - 'tab': 2, - }, - { - 'title': 'Lesson 3: Timeline & Tenses Simulation', - 'title_ar': 'الدرس 3: محاكاة الأزمنة والأفعال', - 'grammarTopic': 0, - 'vocabUnit': 1, - 'tab': 0, - }, - ], - }, - { - 'name': 'Unit 3: Active and Healthy', - 'name_ar': 'الوحدة 3: الحياة الصحية والنشاط البدني', - 'badge': 'Health & Fitness', - 'lessons': [ - { - 'title': 'Lesson 1: Mediterranean Diet & Longevity', - 'title_ar': 'الدرس 1: حمية البحر المتوسط والصحة', - 'grammarTopic': 1, - 'vocabUnit': 2, - 'tab': 1, - }, - { - 'title': 'Lesson 2: Physical Endurance & Stamina', - 'title_ar': 'الدرس 2: قوة التحمل والجلد البدني', - 'grammarTopic': 1, - 'vocabUnit': 2, - 'tab': 2, - }, - { - 'title': 'Lesson 3: Modals of Advice & Obligation', - 'title_ar': 'الدرس 3: أفعال الإلزام والنصيحة', - 'grammarTopic': 1, - 'vocabUnit': 2, - 'tab': 0, - }, - ], - }, - { - 'name': 'Unit 4: Time to Move', - 'name_ar': 'الوحدة 4: وسائل النقل والبيئة الخضراء', - 'badge': 'Transport', - 'lessons': [ - { - 'title': 'Lesson 1: Locomotives & Carbon Emissions', - 'title_ar': 'الدرس 1: القاطرات الكهربائية وحماية البيئة', - 'grammarTopic': 1, - 'vocabUnit': 3, - 'tab': 1, - }, - { - 'title': 'Lesson 2: Modals of Deduction (must/can\'t)', - 'title_ar': 'الدرس 2: استنتاج الأدلة القاطعة', - 'grammarTopic': 1, - 'vocabUnit': 3, - 'tab': 0, - }, - { - 'title': 'Lesson 3: Travel Vocabulary & Destinations', - 'title_ar': 'الدرس 3: مفردات السفر والوجهات السياحية', - 'grammarTopic': 1, - 'vocabUnit': 3, - 'tab': 2, - }, - ], - }, - { - 'name': 'Unit 5: The Next Step', - 'name_ar': 'الوحدة 5: الخطوة القادمة وسوق العمل', - 'badge': 'Future Career', - 'lessons': [ - { - 'title': 'Lesson 1: Apprenticeships & Internships', - 'title_ar': 'الدرس 1: التدريب المهني والميداني', - 'grammarTopic': 2, - 'vocabUnit': 4, - 'tab': 1, - }, - { - 'title': 'Lesson 2: Relative Clauses (who, which, where)', - 'title_ar': 'الدرس 2: جمل الوصل والضمائر النسبية', - 'grammarTopic': 2, - 'vocabUnit': 4, - 'tab': 0, - }, - { - 'title': 'Lesson 3: Career Planning & Ambitions', - 'title_ar': 'الدرس 3: التخطيط المهني والطموح المستقبلي', - 'grammarTopic': 2, - 'vocabUnit': 4, - 'tab': 2, - }, - ], - }, - ]; - - final List> _grammarTopics = [ - { - 'title': 'Present Perfect vs. Past Simple (الوحدة 1 و 2)', - 'badge': 'Tenses & Timeline', - 'concept': - 'Present Perfect connects a past action to the present, or expresses life experiences without a specified time. Past Simple describes finished actions at a definite past time.', - 'formula_present_perfect': 'Subject + have / has + Past Participle (V3)', - 'formula_past_simple': 'Subject + Past Form (V2)', - 'time_markers_present': ['already', 'yet', 'just', 'ever', 'never', 'since 2018', 'for 5 years'], - 'time_markers_past': ['yesterday', 'in 2021', 'last week', 'two days ago', 'when I was young'], - 'pairs': [ - { - 'present': 'Scientists have developed autonomous exploration drones.', - 'present_ar': 'طور العلماء طائرات استكشافية مسيّرة (والنتيجة قائمة ومستمرة الآن).', - 'past': 'Voyager 1 was launched in 1977.', - 'past_ar': 'أُطلق المسبار فوياجر 1 عام 1977 (حدث منتهٍ في زمن ماضٍ محدد).', - }, - { - 'present': 'He has lived in Amman for ten years.', - 'present_ar': 'عاش في عمّان لمدة 10 سنوات (وما زال يسكن فيها حتى اليوم).', - 'past': 'He lived in Irbid for two years when he was a child.', - 'past_ar': 'عاش في إربد لسنتين عندما كان طفلاً (وانتهى ذلك الزمن).', - }, - ], - }, - { - 'title': 'Modals of Deduction & Obligation (الوحدة 4)', - 'badge': 'Modals of Deduction', - 'concept': - 'We use "must" when we are almost 100% sure something is true. We use "can\'t" when we are sure it is impossible. We use "might / could" for possibility.', - 'formula_present_perfect': 'must / might / can\'t + Base Verb (Infinitive)', - 'formula_past_simple': 'must / might / can\'t + have + V3 (Past Deduction)', - 'time_markers_present': ['must (sure it is true)', 'can\'t (sure it is impossible)', 'might / may (possible)'], - 'time_markers_past': ['have to / must (obligation)', 'don\'t have to (lack of obligation)', 'mustn\'t (prohibition)'], - 'pairs': [ - { - 'present': 'She has been traveling all night; she must be exhausted.', - 'present_ar': 'سافرت طوال الليل؛ لا بد أنها مرهقة للغاية (استنتاج مؤكد 100%).', - 'past': 'He can\'t be the pilot; he is too young.', - 'past_ar': 'مستحيل أن يكون هو الطيار؛ فهو صغير جداً في السن (استنتاج مستحيل).', - }, - ], - }, - { - 'title': 'Defining & Non-Defining Relative Clauses (الوحدة 4 و 5)', - 'badge': 'Relative Pronouns', - 'concept': - 'Relative clauses give extra information about a noun. Use "who" for people, "which/that" for things, "where" for places, and "whose" for possession.', - 'formula_present_perfect': 'Noun + [who / which / where / whose] + Clause', - 'formula_past_simple': 'Non-defining clauses require commas: , which ... ,', - 'time_markers_present': ['who (people)', 'which / that (objects & ideas)', 'where (places)'], - 'time_markers_past': ['whose (possession)', 'when (time)', 'why (reasons)'], - 'pairs': [ - { - 'present': 'Petra is a historical city which attracts thousands of tourists every week.', - 'present_ar': 'البتراء مدينة تاريخية تجذب آلاف السياح أسبوعياً.', - 'past': 'An architect is a professional who designs eco-friendly buildings.', - 'past_ar': 'المهندس المعماري هو المحترف الذي يصمم المباني الصديقة للبيئة.', - }, - ], - }, - ]; - - final List> _vocabUnits = [ - { - 'unit': 'Unit 1: Looking Good', - 'words': [ - { - 'word': 'Appearance', - 'ipa': '/əˈpɪə.rəns/', - 'pos': 'noun', - 'ar': 'المظهر الخارجي / الهيئة', - 'def': 'The way that someone or something looks.', - 'sentence': 'First impressions are often influenced by personal appearance and body language.', - 'collocations': 'Physical appearance, personal appearance, deceptive appearance', - }, - { - 'word': 'Accidental', - 'ipa': '/ˌæk.sɪˈden.təl/', - 'pos': 'adjective', - 'ar': 'عَرَضي / غير مقصود / بالصدفة', - 'def': 'Happening by chance, not planned or intended.', - 'sentence': 'The discovery of the ancient mosaic in Madaba was completely accidental.', - 'collocations': 'Accidental discovery, accidental damage', - }, - { - 'word': 'Confident', - 'ipa': '/ˈkɒn.fɪ.dənt/', - 'pos': 'adjective', - 'ar': 'واثق من نفسه', - 'def': 'Feeling sure about your abilities or qualities.', - 'sentence': 'Wearing formal attire can make candidates feel more confident in job interviews.', - 'collocations': 'Self-confident, feel confident, highly confident', - }, - { - 'word': 'Casual', - 'ipa': '/ˈkæʒ.u.əl/', - 'pos': 'adjective', - 'ar': 'عفوي / غير رسمي (ملابس مريحة)', - 'def': 'Relaxed and unconcerned; not formal.', - 'sentence': 'On casual days, university students prefer lightweight cotton clothes.', - 'collocations': 'Casual clothes, casual wear, casual observer', - }, - ], - }, - { - 'unit': 'Unit 2: The Digital Mind', - 'words': [ - { - 'word': 'Artificial Intelligence', - 'ipa': '/ˌɑː.tɪˈfɪʃ.əl ɪnˈtel.ɪ.dʒəns/', - 'pos': 'noun phrase', - 'ar': 'الذكاء الاصطناعي', - 'def': 'The development of computer systems able to perform tasks that normally require human intelligence.', - 'sentence': 'Saqel platform uses artificial intelligence to adapt learning roadmaps for each student.', - 'collocations': 'Generative artificial intelligence, AI algorithms, AI ethics', - }, - { - 'word': 'Algorithm', - 'ipa': '/ˈæl.ɡə.rɪ.ðəm/', - 'pos': 'noun', - 'ar': 'خوارزمية حسابية', - 'def': 'A set of mathematical instructions used by computers to solve problems or make calculations.', - 'sentence': 'Search engine algorithms rank web pages based on relevance and authority.', - 'collocations': 'Complex algorithm, optimization algorithm, search algorithm', - }, - { - 'word': 'Voyager', - 'ipa': '/ˈvɔɪ.ɪ.dʒər/', - 'pos': 'noun', - 'ar': 'مستكشف الفضاء / مسافر في رحلة طويلة', - 'def': 'A person who goes on a long journey, or the legendary NASA interstellar spacecraft.', - 'sentence': 'Voyager 1 carries a golden record containing sounds and images of planet Earth.', - 'collocations': 'Space voyager, daring voyager', - }, - { - 'word': 'Breakthrough', - 'ipa': '/ˈbreɪk.θruː/', - 'pos': 'noun', - 'ar': 'إنجاز تاريخي غير مسبوق / قفزة نوعية', - 'def': 'An important discovery or event that helps improve a situation or provide an answer.', - 'sentence': 'Quantum computing represents a major technological breakthrough in modern science.', - 'collocations': 'Major breakthrough, scientific breakthrough, medical breakthrough', - }, - ], - }, - { - 'unit': 'Unit 3: Active and Healthy', - 'words': [ - { - 'word': 'Endurance', - 'ipa': '/ɪnˈdʒʊə.rəns/', - 'pos': 'noun', - 'ar': 'قوة التحمل والصبر البدني', - 'def': 'The ability to keep doing something difficult, unpleasant, or painful for a long time.', - 'sentence': 'Long-distance marathon runners need exceptional physical endurance and cardiovascular stamina.', - 'collocations': 'Physical endurance, endurance training, test of endurance', - }, - { - 'word': 'Mediterranean Diet', - 'ipa': '/ˌmed.ɪ.tərˈeɪ.ni.ən ˈdaɪ.ət/', - 'pos': 'noun phrase', - 'ar': 'حمية حوض البحر الأبيض المتوسط', - 'def': 'A diet typical of Mediterranean countries, rich in olive oil, fish, grains, and vegetables.', - 'sentence': 'Nutritionists recommend the Mediterranean diet for heart health and longevity.', - 'collocations': 'Adopt a Mediterranean diet, heart-healthy diet', - }, - { - 'word': 'Stamina', - 'ipa': '/ˈstæm.ɪ.nə/', - 'pos': 'noun', - 'ar': 'اللياقة العالية والجلد البدني', - 'def': 'The physical or mental energy that allows you to do something for a long time.', - 'sentence': 'Regular aerobic exercises build stamina and reduce fatigue.', - 'collocations': 'Build stamina, athletic stamina, mental stamina', - }, - ], - }, - { - 'unit': 'Unit 4: Time to Move', - 'words': [ - { - 'word': 'Destination', - 'ipa': '/ˌdes.tɪˈneɪ.ʃən/', - 'pos': 'noun', - 'ar': 'الوجهة السياحية / المقصد', - 'def': 'The place where someone is going or where something is being sent or taken.', - 'sentence': 'Wadi Rum is one of the most famous tourist destinations in Jordan.', - 'collocations': 'Holiday destination, popular destination, final destination', - }, - { - 'word': 'Locomotive', - 'ipa': '/ˌləʊ.kəˈməʊ.tɪv/', - 'pos': 'noun', - 'ar': 'قاطرة قطار سريعة', - 'def': 'The engine of a train that pulls it along railway tracks.', - 'sentence': 'High-speed electric locomotives produce fewer carbon emissions than traditional diesel engines.', - 'collocations': 'Steam locomotive, electric locomotive, modern locomotive', - }, - { - 'word': 'Emission', - 'ipa': '/iˈmɪʃ.ən/', - 'pos': 'noun', - 'ar': 'انبعاثات غازية وكربونية', - 'def': 'An amount of gas, heat, or light that is sent out.', - 'sentence': 'Public transport networks help reduce harmful greenhouse gas emissions in crowded cities.', - 'collocations': 'Zero emissions, carbon emissions, harmful emissions', - }, - ], - }, - { - 'unit': 'Unit 5: The Next Step', - 'words': [ - { - 'word': 'Apprenticeship', - 'ipa': '/əˈpren.tɪs.ʃɪp/', - 'pos': 'noun', - 'ar': 'برنامج التدريب المهني والعملي', - 'def': 'A period of time working for an employer in return for instruction in a trade or craft.', - 'sentence': 'Vocational students gain hands-on engineering skills through a three-year apprenticeship.', - 'collocations': 'Serve an apprenticeship, vocational apprenticeship, completed apprenticeship', - }, - { - 'word': 'Ambitious', - 'ipa': '/æmˈbɪʃ.əs/', - 'pos': 'adjective', - 'ar': 'طموح / متطلع للإنجاز العالي', - 'def': 'Having a strong wish to be successful, powerful, or rich.', - 'sentence': 'Tawjihi students have ambitious goals to specialize in modern tech disciplines.', - 'collocations': 'Ambitious young professional, highly ambitious, ambitious project', - }, - { - 'word': 'Internship', - 'ipa': '/ˈɪn.tɜːn.ʃɪp/', - 'pos': 'noun', - 'ar': 'فترة تدريب وظيفي ميداني', - 'def': 'A period of time during which a student or new graduate works to get experience in a profession.', - 'sentence': 'She completed a summer internship at a leading financial firm in Amman.', - 'collocations': 'Paid internship, summer internship, clinical internship', - }, - ], - }, - ]; - - @override - void initState() { - super.initState(); - _tabController = TabController(length: 4, vsync: this); - _readingService.initialize(); - } - - void _selectCurriculumLesson(int unitIdx, int lessonIdx) { - setState(() { - _selectedUnitIndex = unitIdx; - _selectedLessonIndex = lessonIdx; - }); - final unit = _curriculumUnits[unitIdx]; - final lessons = unit['lessons'] as List; - if (lessonIdx < lessons.length) { - final lesson = lessons[lessonIdx] as Map; - if (lesson.containsKey('grammarTopic')) { - final gTopic = lesson['grammarTopic'] as int; - if (gTopic < _grammarTopics.length) { - setState(() => _selectedGrammarTopicIndex = gTopic); - } - } - if (lesson.containsKey('vocabUnit')) { - final vUnit = lesson['vocabUnit'] as int; - if (vUnit < _vocabUnits.length) { - setState(() { - _selectedVocabUnitIndex = vUnit; - _selectedWordIndex = 0; - }); - } - } - if (lesson.containsKey('tab')) { - final targetTab = lesson['tab'] as int; - if (targetTab < _tabController.length) { - _tabController.animateTo(targetTab); - } - } - } - } - - Future _speakWord(String text) async { - if (text.isEmpty) return; - setState(() => _isPlayingAudio = true); - try { - await _readingService.speakWord(text); - } finally { - if (mounted) { - Future.delayed(const Duration(milliseconds: 700), () { - if (mounted) setState(() => _isPlayingAudio = false); - }); - } - } - } - - @override - void dispose() { - _readingService.stop(); - _mindMapTransformController.dispose(); - _tabController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final activeUnit = _curriculumUnits[_selectedUnitIndex]; - final lessons = activeUnit['lessons'] as List; - - return Directionality( - textDirection: TextDirection.rtl, - child: Column( - children: [ - // Sub-navigation bar - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: const BoxDecoration( - color: AppColors.darkSurface, - border: Border(bottom: BorderSide(color: AppColors.darkCardBorder)), - ), - child: Row( - children: [ - const Icon(CupertinoIcons.headphones, color: AppColors.saqelCyan, size: 20), - const SizedBox(width: 8), - const Text( - 'مختبر اللغة الإنجليزية (Action Pack 10) — الصوتيات والقواعد والمفردات والمخططات', - style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 13), - ), - const Spacer(), - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: const Color(0xFF1E3A8A).withAlpha(140), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFF60A5FA).withAlpha(160)), - ), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text('🇬🇧', style: TextStyle(fontSize: 12)), - SizedBox(width: 5), - Text( - 'صوت بريطاني فصيح (en-GB RP)', - style: TextStyle(color: Color(0xFF93C5FD), fontSize: 11, fontWeight: FontWeight.w700), - ), - ], - ), - ), - ], - ), - ), - - // Unit & Lesson Hierarchy Navigation Header - Container( - padding: const EdgeInsets.fromLTRB(14, 8, 14, 10), - decoration: BoxDecoration( - color: const Color(0xFF091220), - border: Border(bottom: BorderSide(color: AppColors.saqelCyan.withAlpha(50))), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Unit Switcher Row - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: _curriculumUnits.asMap().entries.map((uEntry) { - final uIdx = uEntry.key; - final uData = uEntry.value; - final isUnitSelected = _selectedUnitIndex == uIdx; - - return GestureDetector( - onTap: () => _selectCurriculumLesson(uIdx, 0), - child: Container( - margin: const EdgeInsets.only(left: 8), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: isUnitSelected - ? AppColors.saqelCyan.withAlpha(35) - : Colors.white.withAlpha(10), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: isUnitSelected - ? AppColors.saqelCyan - : Colors.white12, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - uData['name_ar'] as String, - style: TextStyle( - color: isUnitSelected ? AppColors.saqelCyan : Colors.white70, - fontSize: 12, - fontWeight: isUnitSelected ? FontWeight.w800 : FontWeight.w500, - ), - ), - const SizedBox(width: 4), - Text( - '(${uData['name']})', - style: TextStyle( - color: isUnitSelected ? AppColors.saqelCyan.withAlpha(180) : Colors.white38, - fontSize: 10, - ), - ), - ], - ), - ), - ); - }).toList(), - ), - ), - const SizedBox(height: 8), - - // Lesson Pills Row - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: lessons.asMap().entries.map((lEntry) { - final lIdx = lEntry.key; - final lData = lEntry.value as Map; - final isLessonSelected = _selectedLessonIndex == lIdx; - - return GestureDetector( - onTap: () => _selectCurriculumLesson(_selectedUnitIndex, lIdx), - child: Container( - margin: const EdgeInsets.only(left: 8), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), - decoration: BoxDecoration( - gradient: isLessonSelected - ? const LinearGradient( - colors: [AppColors.appleBlue, AppColors.saqelCyan], - ) - : null, - color: isLessonSelected ? null : const Color(0xFF0F1B2E), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isLessonSelected ? Colors.transparent : Colors.white12, - ), - ), - child: Text( - lData['title_ar'] as String, - style: TextStyle( - color: isLessonSelected ? Colors.black : Colors.white, - fontSize: 11, - fontWeight: isLessonSelected ? FontWeight.w800 : FontWeight.w500, - ), - ), - ), - ); - }).toList(), - ), - ), - ], - ), - ), - - // Main Tab selector - Container( - color: AppColors.darkSurface, - child: TabBar( - controller: _tabController, - indicatorColor: AppColors.saqelCyan, - indicatorWeight: 3, - labelColor: Colors.white, - unselectedLabelColor: AppColors.textSecondaryDark, - labelStyle: const TextStyle(fontWeight: FontWeight.w700, fontSize: 13), - tabs: const [ - Tab(icon: Icon(CupertinoIcons.wand_rays_inverse, size: 16), text: 'محاكي القواعد والأزمنة'), - Tab(icon: Icon(CupertinoIcons.waveform, size: 16), text: 'استوديو الصوتيات والنطق'), - Tab(icon: Icon(CupertinoIcons.rectangle_stack_fill, size: 16), text: 'بنك المفردات التفاعلي'), - Tab(icon: Icon(CupertinoIcons.photo_fill_on_rectangle_fill, size: 16), text: 'ملخص القواعد والمخططات 🖼️'), - ], - ), - ), - - // Tab Content - Expanded( - child: TabBarView( - controller: _tabController, - children: [ - _buildGrammarSimulatorView(), - _buildPhoneticsStudioView(), - _buildVocabularyBankView(), - _buildGrammarMindMapTab(), - ], - ), - ), - ], - ), - ); - } - - // ============================================================================ - // TAB 1: GRAMMAR & TENSES SIMULATOR - // ============================================================================ - Widget _buildGrammarSimulatorView() { - final activeTopic = _grammarTopics[_selectedGrammarTopicIndex]; - - return Row( - children: [ - // Left: Topics Menu - Container( - width: 260, - decoration: const BoxDecoration( - color: AppColors.darkSurface, - border: Border(left: BorderSide(color: AppColors.darkCardBorder)), - ), - child: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), - itemCount: _grammarTopics.length, - itemBuilder: (context, index) { - final topic = _grammarTopics[index]; - final isSelected = _selectedGrammarTopicIndex == index; - - return Container( - margin: const EdgeInsets.only(bottom: 8), - decoration: BoxDecoration( - color: isSelected ? AppColors.saqelCyan.withAlpha(20) : Colors.transparent, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isSelected ? AppColors.saqelCyan : Colors.transparent, - ), - ), - child: ListTile( - dense: true, - title: Text( - topic['title'] as String, - style: TextStyle( - color: isSelected ? AppColors.saqelCyan : Colors.white, - fontSize: 12, - fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, - ), - ), - subtitle: Text( - topic['badge'] as String, - style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 10), - ), - onTap: () => setState(() => _selectedGrammarTopicIndex = index), - ), - ); - }, - ), - ), - - // Right: Interactive Grammar Canvas - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - LuxuryCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: AppColors.saqelCyan.withAlpha(30), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - activeTopic['badge'] as String, - style: const TextStyle(color: AppColors.saqelCyan, fontSize: 11, fontWeight: FontWeight.bold), - ), - ), - const Spacer(), - IconButton( - icon: const Icon(CupertinoIcons.speaker_2, color: AppColors.saqelCyan, size: 20), - onPressed: () => _speakWord(activeTopic['concept'] as String), - tooltip: 'استمع للشرح باللغة الإنجليزية', - ), - ], - ), - const SizedBox(height: 12), - Text( - activeTopic['concept'] as String, - style: const TextStyle(color: Colors.white, fontSize: 13, height: 1.6), - ), - const Divider(color: AppColors.darkCardBorder, height: 28), - - // Grammar Formulas - Row( - children: [ - Expanded( - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.black45, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: AppColors.saqelCyan.withAlpha(50)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('صيغة الإثبات الأولى:', style: TextStyle(color: AppColors.saqelCyan, fontSize: 11, fontWeight: FontWeight.bold)), - const SizedBox(height: 6), - Text( - activeTopic['formula_present_perfect'] as String, - style: const TextStyle(color: Colors.white, fontFamily: 'Courier', fontSize: 12, fontWeight: FontWeight.w700), - ), - ], - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.black45, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: AppColors.guardianAmber.withAlpha(50)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('صيغة المقارنة المقابلة:', style: TextStyle(color: AppColors.guardianAmber, fontSize: 11, fontWeight: FontWeight.bold)), - const SizedBox(height: 6), - Text( - activeTopic['formula_past_simple'] as String, - style: const TextStyle(color: Colors.white, fontFamily: 'Courier', fontSize: 12, fontWeight: FontWeight.w700), - ), - ], - ), - ), - ), - ], - ), - ], - ), - ), - - const SizedBox(height: 18), - const Text( - 'أمثلة تفاعلية ومقارنة دلالية (Action Pack 10 Context):', - style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w700), - ), - const SizedBox(height: 10), - - // Comparison Cards - ...((activeTopic['pairs'] as List).map((pair) { - return Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.darkSurface, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.darkCardBorder), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: AppColors.saqelCyan.withAlpha(20), - borderRadius: BorderRadius.circular(6), - ), - child: const Text('المثال الأول', style: TextStyle(color: AppColors.saqelCyan, fontSize: 10, fontWeight: FontWeight.bold)), - ), - const Spacer(), - IconButton( - icon: const Icon(CupertinoIcons.volume_up, color: AppColors.saqelCyan, size: 16), - onPressed: () => _speakWord(pair['present'] as String), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), - ], - ), - const SizedBox(height: 6), - Text( - pair['present'] as String, - style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w700), - ), - Text( - pair['present_ar'] as String, - style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 11), - ), - const Divider(color: AppColors.darkCardBorder, height: 18), - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: AppColors.guardianAmber.withAlpha(20), - borderRadius: BorderRadius.circular(6), - ), - child: const Text('المثال المقابل', style: TextStyle(color: AppColors.guardianAmber, fontSize: 10, fontWeight: FontWeight.bold)), - ), - const Spacer(), - IconButton( - icon: const Icon(CupertinoIcons.volume_up, color: AppColors.guardianAmber, size: 16), - onPressed: () => _speakWord(pair['past'] as String), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), - ], - ), - const SizedBox(height: 6), - Text( - pair['past'] as String, - style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w700), - ), - Text( - pair['past_ar'] as String, - style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 11), - ), - ], - ), - ); - })), - ], - ), - ), - ), - ], - ); - } - - // ============================================================================ - // TAB 2: PHONETICS & PRONUNCIATION STUDIO (IPA & TTS) - // ============================================================================ - // ============================================================================ - // TAB 2: PHONETICS & PRONUNCIATION STUDIO (BRITISH RP & TTS) - // ============================================================================ - Widget _buildPhoneticsStudioView() { - final activeUnit = _vocabUnits[_selectedVocabUnitIndex]; - final words = activeUnit['words'] as List; - final activeWord = words[_selectedWordIndex] as Map; - - return LayoutBuilder( - builder: (context, constraints) { - final isCompact = constraints.maxWidth < 760; - - if (isCompact) { - return ListView( - padding: const EdgeInsets.all(16), - children: [ - // Unit selector dropdown - Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4), - decoration: BoxDecoration( - color: AppColors.darkSurface, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: AppColors.darkCardBorder), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: _selectedVocabUnitIndex, - isExpanded: true, - dropdownColor: AppColors.darkSurface, - items: List.generate(_vocabUnits.length, (i) { - return DropdownMenuItem( - value: i, - child: Text( - _vocabUnits[i]['unit'] as String, - style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold), - ), - ); - }), - onChanged: (val) { - if (val != null) { - setState(() { - _selectedVocabUnitIndex = val; - _selectedWordIndex = 0; - }); - } - }, - ), - ), - ), - const SizedBox(height: 12), - - // Words chips list - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: List.generate(words.length, (index) { - final item = words[index]; - final isSelected = _selectedWordIndex == index; - return Padding( - padding: const EdgeInsets.only(left: 8), - child: ChoiceChip( - label: Text(item['word'] as String), - selected: isSelected, - selectedColor: AppColors.saqelCyan, - labelStyle: TextStyle( - color: isSelected ? Colors.black : Colors.white, - fontWeight: FontWeight.w700, - fontSize: 12, - ), - backgroundColor: AppColors.darkSurface, - onSelected: (_) => setState(() => _selectedWordIndex = index), - ), - ); - }), - ), - ), - const SizedBox(height: 16), - - // Phonetics Card - _buildPhoneticsWorkbenchCard(activeWord), - ], - ); - } - - return Row( - children: [ - // Left: Units Selector & Words List - Container( - width: 280, - decoration: const BoxDecoration( - color: AppColors.darkSurface, - border: Border(left: BorderSide(color: AppColors.darkCardBorder)), - ), - child: Column( - children: [ - // Unit Dropdown - Container( - padding: const EdgeInsets.all(12), - decoration: const BoxDecoration( - border: Border(bottom: BorderSide(color: AppColors.darkCardBorder)), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: _selectedVocabUnitIndex, - isExpanded: true, - dropdownColor: AppColors.darkSurface, - items: List.generate(_vocabUnits.length, (i) { - return DropdownMenuItem( - value: i, - child: Text( - _vocabUnits[i]['unit'] as String, - style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold), - ), - ); - }), - onChanged: (val) { - if (val != null) { - setState(() { - _selectedVocabUnitIndex = val; - _selectedWordIndex = 0; - }); - } - }, - ), - ), - ), - - // Words List - Expanded( - child: ListView.builder( - padding: const EdgeInsets.all(8), - itemCount: words.length, - itemBuilder: (context, index) { - final item = words[index]; - final isSelected = _selectedWordIndex == index; - - return Container( - margin: const EdgeInsets.only(bottom: 6), - decoration: BoxDecoration( - color: isSelected ? AppColors.saqelCyan.withAlpha(25) : Colors.transparent, - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: isSelected ? AppColors.saqelCyan : Colors.transparent, - ), - ), - child: ListTile( - dense: true, - title: Text( - item['word'] as String, - style: TextStyle( - color: isSelected ? AppColors.saqelCyan : Colors.white, - fontSize: 13, - fontWeight: isSelected ? FontWeight.bold : FontWeight.w600, - ), - ), - subtitle: Text( - item['ar'] as String, - style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 11), - ), - trailing: IconButton( - icon: const Icon(CupertinoIcons.volume_up, size: 16, color: AppColors.saqelCyan), - onPressed: () => _speakWord(item['word'] as String), - ), - onTap: () => setState(() => _selectedWordIndex = index), - ), - ); - }, - ), - ), - ], - ), - ), - - // Right: Phonetics Audio Workbench - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: _buildPhoneticsWorkbenchCard(activeWord), - ), - ), - ], - ); - }, - ); - } - - Widget _buildPhoneticsWorkbenchCard(Map activeWord) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Big Pronunciation Header Card - Container( + Widget build(BuildContext context) => Container( + color: const Color(0xFF07111F), + alignment: Alignment.center, + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: DecoratedBox( + decoration: BoxDecoration(color: const Color(0xFF10243A), borderRadius: BorderRadius.circular(22), border: Border.all(color: AppColors.saqelCyan.withValues(alpha: .32))), + child: Padding( padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [Color(0xFF0B192C), Color(0xFF1E293B)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(24), - border: Border.all(color: AppColors.saqelCyan.withAlpha(90)), - boxShadow: const [ - BoxShadow(color: Colors.black54, blurRadius: 20, offset: Offset(0, 10)), - ], - ), - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: AppColors.saqelCyan.withAlpha(30), - borderRadius: BorderRadius.circular(16), - ), - child: Text( - activeWord['pos'] as String, - style: const TextStyle(color: AppColors.saqelCyan, fontSize: 12, fontWeight: FontWeight.bold), - ), - ), - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: const Color(0xFF1E3A8A).withAlpha(160), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: const Color(0xFF60A5FA).withAlpha(120)), - ), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text('🇬🇧', style: TextStyle(fontSize: 12)), - SizedBox(width: 4), - Text( - 'British RP', - style: TextStyle(color: Color(0xFF93C5FD), fontSize: 11, fontWeight: FontWeight.w700), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 14), - Text( - activeWord['word'] as String, - style: const TextStyle( - color: Colors.white, - fontSize: 32, - fontWeight: FontWeight.w900, - letterSpacing: 0.5, - ), - ), - const SizedBox(height: 6), - Text( - activeWord['ipa'] as String, - style: const TextStyle( - color: AppColors.saqelCyan, - fontSize: 19, - fontFamily: 'Courier', - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 10), - Text( - activeWord['ar'] as String, - style: const TextStyle(color: Colors.white70, fontSize: 16, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 18), - - // Audio Play Button - ElevatedButton.icon( - icon: Icon( - _isPlayingAudio ? CupertinoIcons.waveform : CupertinoIcons.volume_up, - size: 20, - ), - label: Text( - _isPlayingAudio ? 'جاري النطق البريطاني...' : 'استمع للنطق البريطاني الفصيح (British Audio Pronunciation)', - style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 13), - ), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.saqelCyan, - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - ), - onPressed: () => _speakWord(activeWord['word'] as String), - ), - ], - ), - ), - - const SizedBox(height: 20), - // Detailed Definition & Collocations - LuxuryCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('التعريف المعتمد (Official Definition):', style: TextStyle(color: AppColors.saqelCyan, fontWeight: FontWeight.bold, fontSize: 12)), - const SizedBox(height: 6), - Text(activeWord['def'] as String, style: const TextStyle(color: Colors.white, fontSize: 13, height: 1.5)), - const Divider(color: AppColors.darkCardBorder, height: 24), - - const Text('جملة المنهاج الوزاري (Textbook Context):', style: TextStyle(color: AppColors.guardianAmber, fontWeight: FontWeight.bold, fontSize: 12)), - const SizedBox(height: 6), - Row( - children: [ - Expanded( - child: Text(activeWord['sentence'] as String, style: const TextStyle(color: Colors.white, fontSize: 13, height: 1.5, fontStyle: FontStyle.italic)), - ), - IconButton( - icon: const Icon(CupertinoIcons.speaker_1, color: AppColors.guardianAmber, size: 18), - onPressed: () => _speakWord(activeWord['sentence'] as String), - tooltip: 'استمع للجملة', - ), - ], - ), - const Divider(color: AppColors.darkCardBorder, height: 24), - - const Text('المتلازمات اللفظية (Collocations):', style: TextStyle(color: Color(0xFF34C759), fontWeight: FontWeight.bold, fontSize: 12)), - const SizedBox(height: 6), - Text(activeWord['collocations'] as String, style: const TextStyle(color: Colors.white70, fontSize: 12)), - ], - ), - ), - ], - ); - } - - // ============================================================================ - // TAB 3: VOCABULARY FLASHCARDS BANK - // ============================================================================ - Widget _buildVocabularyBankView() { - final activeUnit = _vocabUnits[_selectedVocabUnitIndex]; - final words = activeUnit['words'] as List; - - return Column( - children: [ - // Unit Filter Bar - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - color: AppColors.darkSurface, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: List.generate(_vocabUnits.length, (i) { - final isSelected = _selectedVocabUnitIndex == i; - return Padding( - padding: const EdgeInsets.only(left: 8), - child: ChoiceChip( - label: Text( - _vocabUnits[i]['unit'] as String, - style: TextStyle( - color: isSelected ? Colors.black : Colors.white, - fontSize: 11, - fontWeight: FontWeight.bold, - ), - ), - selected: isSelected, - selectedColor: AppColors.saqelCyan, - backgroundColor: AppColors.darkCard, - onSelected: (_) => setState(() => _selectedVocabUnitIndex = i), - ), - ); - }), - ), - ), - ), - - // Flashcards Grid - Expanded( - child: GridView.builder( - padding: const EdgeInsets.all(20), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisSpacing: 16, - crossAxisSpacing: 16, - childAspectRatio: 1.35, - ), - itemCount: words.length, - itemBuilder: (context, index) { - final w = words[index]; - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.darkSurface, - borderRadius: BorderRadius.circular(18), - border: Border.all(color: AppColors.darkCardBorder), - boxShadow: const [ - BoxShadow(color: Colors.black26, blurRadius: 10, offset: Offset(0, 4)), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - w['word'] as String, - style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold), - ), - const SizedBox(width: 8), - Text( - w['ipa'] as String, - style: const TextStyle(color: AppColors.saqelCyan, fontSize: 11, fontFamily: 'Courier'), - ), - const Spacer(), - IconButton( - icon: const Icon(CupertinoIcons.volume_up, color: AppColors.saqelCyan, size: 18), - onPressed: () => _speakWord(w['word'] as String), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), - ], - ), - const SizedBox(height: 6), - Text( - w['ar'] as String, - style: const TextStyle(color: AppColors.guardianAmber, fontWeight: FontWeight.bold, fontSize: 13), - ), - const SizedBox(height: 8), - Expanded( - child: Text( - w['def'] as String, - style: const TextStyle(color: Colors.white70, fontSize: 11, height: 1.4), - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - ), - const Divider(color: AppColors.darkCardBorder, height: 12), - Text( - 'Collocations: ${w['collocations']}', - style: const TextStyle(color: Color(0xFF34C759), fontSize: 10, fontWeight: FontWeight.w600), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ); - }, - ), - ), - ], - ); - } - - // ============================================================================ - // TAB 4: GRAMMAR MIND MAP & DIAGRAM VIEWER (ZOOMABLE INTERACTIVE VIEWER) - // ============================================================================ - Widget _buildGrammarMindMapTab() { - return Column( - children: [ - // Mind Map Control Toolbar - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - decoration: const BoxDecoration( - color: AppColors.darkSurface, - border: Border(bottom: BorderSide(color: AppColors.darkCardBorder)), - ), - child: Row( - children: [ - // Mode switcher - Container( - decoration: BoxDecoration( - color: AppColors.darkCard, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: AppColors.darkCardBorder), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _buildModeButton( - title: 'الخريطة الرقمية التفاعلية 🗺️', - isSelected: _mindMapDisplayMode == 0, - onTap: () => setState(() => _mindMapDisplayMode = 0), - ), - _buildModeButton( - title: 'لوحة المعلم والشروحات المرفوعة 👨‍🏫', - isSelected: _mindMapDisplayMode == 1, - onTap: () => setState(() => _mindMapDisplayMode = 1), - ), - ], - ), - ), - - const SizedBox(width: 12), - - // Gesture guide pill - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: AppColors.saqelCyan.withAlpha(20), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: AppColors.saqelCyan.withAlpha(60)), - ), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(CupertinoIcons.hand_draw, color: AppColors.saqelCyan, size: 13), - SizedBox(width: 5), - Text( - 'قرص للتكبير حتى 400% (Pinch to Zoom)', - style: TextStyle(color: AppColors.saqelCyan, fontSize: 11, fontWeight: FontWeight.bold), - ), - ], - ), - ), - - const Spacer(), - - // Audio explanation button - IconButton( - icon: const Icon(CupertinoIcons.speaker_2_fill, color: AppColors.saqelCyan, size: 20), - tooltip: 'استمع لشرح صوتي بالإنجليزية لملخص الأزمنة', - onPressed: () => _speakWord( - 'English Tenses Blueprint: Past Simple expresses finished actions at a definite past time. Present Perfect connects past achievements to present results. Modals of deduction show degree of certainty.', - ), - ), - - // Reset zoom button - IconButton( - icon: const Icon(CupertinoIcons.arrow_counterclockwise, color: Colors.white70, size: 18), - tooltip: 'إعادة ضبط التكبير', - onPressed: () { - _mindMapTransformController.value = Matrix4.identity(); - }, - ), - - // Fullscreen button - IconButton( - icon: const Icon(CupertinoIcons.fullscreen, color: AppColors.appleBlue, size: 20), - tooltip: 'عرض بملء الشاشة', - onPressed: () => _showFullScreenMindMapDialog(context), - ), - ], - ), - ), - - // Zoomable Canvas Viewport - Expanded( - child: Container( - color: const Color(0xFF070B14), - child: InteractiveViewer( - transformationController: _mindMapTransformController, - minScale: 0.5, - maxScale: 4.0, - boundaryMargin: const EdgeInsets.all(80), - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: _mindMapDisplayMode == 0 - ? _buildDigitalGrammarMindMapContent() - : _buildTeacherWhiteboardContent(), - ), - ), - ), - ), - ), - ], - ); - } - - Widget _buildModeButton({ - required String title, - required bool isSelected, - required VoidCallback onTap, - }) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: isSelected ? AppColors.saqelCyan : Colors.transparent, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - title, - style: TextStyle( - color: isSelected ? Colors.black : Colors.white70, - fontSize: 11, - fontWeight: isSelected ? FontWeight.w800 : FontWeight.w500, - ), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + const Icon(CupertinoIcons.book_circle, size: 52, color: AppColors.saqelCyan), + const SizedBox(height: 16), + const Text('مختبر الإنجليزية ينتظر الحزمة المعتمدة', textAlign: TextAlign.center, style: TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.w800)), + const SizedBox(height: 10), + const Text('ستظهر المفردات والصوت والقواعد بعد ربطها بالكتاب والدرس ونسخة المنهج ومراجعتها. لا نعرض أمثلة أو نطقاً غير موثقين.', textAlign: TextAlign.center, style: TextStyle(color: Color(0xFFB9C7D8), height: 1.6)), + if (initialTopic != null && initialTopic!.isNotEmpty) ...[const SizedBox(height: 14), Text('الدرس المطلوب: $initialTopic', textAlign: TextAlign.center, style: const TextStyle(color: AppColors.saqelCyan))], + ]), ), ), - ); - } - - // ============================================================================ - // DIGITAL GRAMMAR MIND MAP CONTENT - // ============================================================================ - Widget _buildDigitalGrammarMindMapContent() { - return Container( - constraints: const BoxConstraints(maxWidth: 960), - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: const Color(0xFF0F172A), - borderRadius: BorderRadius.circular(24), - border: Border.all(color: AppColors.saqelCyan.withAlpha(80), width: 1.5), - boxShadow: [ - BoxShadow( - color: AppColors.saqelCyan.withAlpha(25), - blurRadius: 30, - spreadRadius: 2, - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Grand Diagram Header - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - AppColors.appleBlue.withAlpha(40), - AppColors.saqelCyan.withAlpha(20), - ], - ), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.saqelCyan.withAlpha(60)), - ), - child: Row( - children: [ - const Icon(CupertinoIcons.map_pin_ellipse, color: AppColors.saqelCyan, size: 26), - const SizedBox(width: 12), - const Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'خارطة الأزمنة والقواعد الوزارية الشاملة (Action Pack 10 Grammar Blueprint)', - style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 15), - ), - SizedBox(height: 3), - Text( - 'Tense Timeline • Modals of Deduction • Relative Clauses • Contextual Collocations', - style: TextStyle(color: AppColors.saqelCyan, fontSize: 11, fontFamily: 'Courier'), - ), - ], - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: Colors.black45, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.white24), - ), - child: const Text( - 'Jordanian Curriculum', - style: TextStyle(color: Colors.white70, fontSize: 10, fontWeight: FontWeight.bold), - ), - ), - ], - ), - ), - - const SizedBox(height: 24), - - // Timeline Blueprint Card - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: const Color(0xFF080E1B), - borderRadius: BorderRadius.circular(18), - border: Border.all(color: Colors.white12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Row( - children: [ - Icon(CupertinoIcons.clock, color: AppColors.saqelCyan, size: 18), - SizedBox(width: 8), - Text( - 'المحور الزمني التفاعلي للأزمنة (Tense Timeline Engine)', - style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 13), - ), - ], - ), - const SizedBox(height: 16), - - // 3 Timeline Nodes Connected - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Node 1: Past Simple - Expanded( - child: _buildTimelineNode( - title: '1. الماضي البسيط (Past Simple)', - formula: 'Subject + V2 (Past Form)', - explanation: 'حدث وقع وانتهى كلياً عند وقت محدد في الماضي.', - keywords: 'yesterday, in 1977, last week, 2 days ago', - example: 'Voyager 1 was launched in 1977.', - color: const Color(0xFFFF9500), - ), - ), - - // Connector Arrow 1 - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 40), - child: Column( - children: [ - const Icon(CupertinoIcons.arrow_right, color: AppColors.saqelCyan, size: 18), - const SizedBox(height: 4), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: AppColors.saqelCyan.withAlpha(20), - borderRadius: BorderRadius.circular(6), - ), - child: const Text( - 'أثر حاضر', - style: TextStyle(color: AppColors.saqelCyan, fontSize: 9, fontWeight: FontWeight.bold), - ), - ), - ], - ), - ), - - // Node 2: Present Perfect - Expanded( - child: _buildTimelineNode( - title: '2. المضارع التام (Present Perfect)', - formula: 'Subject + have/has + V3', - explanation: 'حدث في الماضي وأثره ونتيجته قائمة ومستمرة الآن.', - keywords: 'already, yet, just, ever, never, since, for', - example: 'Scientists have developed autonomous drones.', - color: AppColors.saqelCyan, - ), - ), - - // Connector Arrow 2 - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 40), - child: Column( - children: [ - const Icon(CupertinoIcons.arrow_right, color: Color(0xFF34C759), size: 18), - const SizedBox(height: 4), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: const Color(0xFF34C759).withAlpha(20), - borderRadius: BorderRadius.circular(6), - ), - child: const Text( - 'استمرارية', - style: TextStyle(color: Color(0xFF34C759), fontSize: 9, fontWeight: FontWeight.bold), - ), - ), - ], - ), - ), - - // Node 3: Present Perfect Continuous - Expanded( - child: _buildTimelineNode( - title: '3. التام المستمر (Present Perfect Cont.)', - formula: 'Subject + have/has + been + V-ing', - explanation: 'حدث بدأ في الماضي وما زال مستمراً حتى لحظة التكلم.', - keywords: 'for 3 hours, all day, since morning', - example: 'They have been researching space for years.', - color: const Color(0xFF34C759), - ), - ), - ], - ), - ], - ), - ), - - const SizedBox(height: 20), - - // Two Bento Cards: Modals of Deduction & Relative Clauses - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Modals of Deduction - Expanded( - child: Container( - padding: const EdgeInsets.all(18), - decoration: BoxDecoration( - color: const Color(0xFF080E1B), - borderRadius: BorderRadius.circular(18), - border: Border.all(color: Colors.white12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Row( - children: [ - Icon(CupertinoIcons.gauge, color: AppColors.guardianAmber, size: 18), - SizedBox(width: 8), - Text( - 'سلّم استنتاج الأدلة (Modals of Deduction) - الوحدة 4', - style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 12), - ), - ], - ), - const SizedBox(height: 14), - - _buildDeductionRow( - level: '100% يقين إيجابي (Sure True)', - modal: 'must + Base Verb', - example: 'She has traveled all day; she must be tired.', - color: const Color(0xFF34C759), - ), - const SizedBox(height: 10), - _buildDeductionRow( - level: '50% احتمال ممكن (Possibility)', - modal: 'might / could / may + Base Verb', - example: 'The rocket might launch tomorrow morning.', - color: AppColors.saqelCyan, - ), - const SizedBox(height: 10), - _buildDeductionRow( - level: '100% يقين مستحيل (Sure Impossible)', - modal: 'can\'t + Base Verb', - example: 'He can\'t be the pilot; he is too young.', - color: const Color(0xFFFF453A), - ), - ], - ), - ), - ), - - const SizedBox(width: 16), - - // Relative Clauses - Expanded( - child: Container( - padding: const EdgeInsets.all(18), - decoration: BoxDecoration( - color: const Color(0xFF080E1B), - borderRadius: BorderRadius.circular(18), - border: Border.all(color: Colors.white12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Row( - children: [ - Icon(CupertinoIcons.link, color: AppColors.appleBlue, size: 18), - SizedBox(width: 8), - Text( - 'جسر جمل الوصل (Relative Clauses) - الوحدة 4 و 5', - style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 12), - ), - ], - ), - const SizedBox(height: 14), - - _buildRelativeClauseRow( - pronoun: 'who / that', - target: 'للأشخاص والعاقل (People)', - example: 'The mentor who trained me was very experienced.', - ), - const SizedBox(height: 8), - _buildRelativeClauseRow( - pronoun: 'which / that', - target: 'للأشياء والأفكار وغير العاقل (Things)', - example: 'AI is a technology which revolutionizes education.', - ), - const SizedBox(height: 8), - _buildRelativeClauseRow( - pronoun: 'where', - target: 'للأماكن (Places)', - example: 'Madaba is the historic city where mosaics exist.', - ), - const SizedBox(height: 8), - _buildRelativeClauseRow( - pronoun: 'whose', - target: 'للملكية والنسب (Possession)', - example: 'The student whose project won achieved 1st place.', - ), - ], - ), - ), - ), - ], - ), - ], - ), - ); - } - - Widget _buildTimelineNode({ - required String title, - required String formula, - required String explanation, - required String keywords, - required String example, - required Color color, - }) { - return Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: const Color(0xFF10192A), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: color.withAlpha(80)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 10, - height: 10, - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - boxShadow: [ - BoxShadow(color: color.withAlpha(120), blurRadius: 6), - ], - ), - ), - const SizedBox(width: 6), - Expanded( - child: Text( - title, - style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w800), - ), - ), - ], - ), - const SizedBox(height: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Colors.black45, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - formula, - style: const TextStyle(color: Colors.white, fontFamily: 'Courier', fontSize: 10, fontWeight: FontWeight.bold), - ), - ), - const SizedBox(height: 8), - Text( - explanation, - style: const TextStyle(color: Colors.white70, fontSize: 10, height: 1.4), - ), - const SizedBox(height: 8), - Text( - 'الدلائل: $keywords', - style: const TextStyle(color: AppColors.guardianAmber, fontSize: 9, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: Text( - example, - style: const TextStyle(color: Colors.white, fontSize: 10, fontStyle: FontStyle.italic), - ), - ), - IconButton( - icon: Icon(CupertinoIcons.speaker_1, color: color, size: 14), - onPressed: () => _speakWord(example), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), - ], - ), - ], - ), - ); - } - - Widget _buildDeductionRow({ - required String level, - required String modal, - required String example, - required Color color, - }) { - return Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: const Color(0xFF10192A), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: color.withAlpha(60)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - level, - style: TextStyle(color: color, fontSize: 10, fontWeight: FontWeight.w800), - ), - const Spacer(), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.black45, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - modal, - style: const TextStyle(color: Colors.white, fontFamily: 'Courier', fontSize: 9, fontWeight: FontWeight.bold), - ), - ), - ], - ), - const SizedBox(height: 6), - Row( - children: [ - Expanded( - child: Text( - example, - style: const TextStyle(color: Colors.white70, fontSize: 10, fontStyle: FontStyle.italic), - ), - ), - IconButton( - icon: Icon(CupertinoIcons.speaker_1, color: color, size: 14), - onPressed: () => _speakWord(example), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), - ], - ), - ], - ), - ); - } - - Widget _buildRelativeClauseRow({ - required String pronoun, - required String target, - required String example, - }) { - return Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: const Color(0xFF10192A), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: AppColors.appleBlue.withAlpha(50)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: AppColors.appleBlue.withAlpha(30), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - pronoun, - style: const TextStyle(color: AppColors.appleBlue, fontFamily: 'Courier', fontSize: 10, fontWeight: FontWeight.bold), - ), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - target, - style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold), - ), - ), - ], - ), - const SizedBox(height: 6), - Row( - children: [ - Expanded( - child: Text( - example, - style: const TextStyle(color: Colors.white70, fontSize: 10, fontStyle: FontStyle.italic), - ), - ), - IconButton( - icon: const Icon(CupertinoIcons.speaker_1, color: AppColors.appleBlue, size: 14), - onPressed: () => _speakWord(example), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), - ], - ), - ], - ), - ); - } - - // ============================================================================ - // TEACHER WHITEBOARD CONTENT (BLACKBOARD STYLE) - // ============================================================================ - Widget _buildTeacherWhiteboardContent() { - return Container( - constraints: const BoxConstraints(maxWidth: 960), - padding: const EdgeInsets.all(28), - decoration: BoxDecoration( - color: const Color(0xFF0C1916), // Dark Slate Green Blackboard - borderRadius: BorderRadius.circular(24), - border: Border.all(color: const Color(0xFF2C5E4C), width: 2), - boxShadow: const [ - BoxShadow(color: Colors.black54, blurRadius: 30, spreadRadius: 4), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Blackboard Title - Row( - children: [ - const Icon(CupertinoIcons.pencil_ellipsis_rectangle, color: Color(0xFF86EFAC), size: 24), - const SizedBox(width: 10), - const Expanded( - child: Text( - 'لوحة المعلم التوضيحية — الشرح المكتوب والرسومات اليدوية (Teacher Whiteboard)', - style: TextStyle( - color: Color(0xFFE2E8F0), - fontSize: 14, - fontWeight: FontWeight.w800, - letterSpacing: 0.5, - ), - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: const Color(0xFF1B382F), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: const Color(0xFF34D399)), - ), - child: const Text( - 'لوح الشرح المباشر 4K', - style: TextStyle(color: Color(0xFF34D399), fontSize: 10, fontWeight: FontWeight.bold), - ), - ), - ], - ), - const Divider(color: Color(0xFF24483B), height: 30), - - // Hand-Drawn / Chalk Style Diagram Schema - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: const Color(0xFF07110F), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: const Color(0xFF34D399).withAlpha(80), style: BorderStyle.solid), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '📝 ملاحظات المعلم الذهبية — التفريق الحاسم بين الأزمنة المتشابهة:', - style: TextStyle(color: Color(0xFFFDE047), fontSize: 13, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 12), - _buildChalkPoint( - marker: '1.', - title: 'Present Perfect vs Past Simple:', - text: 'إذا ذُكر زمن ماضٍ محدد بدقة (مثل in 1977، yesterday، two days ago) نختار حتماً الماضي البسيط (V2). أما إذا كان الحدث غير محدد بزمن، أو له نتيجة ما زالت قائمة حتى اللحظة، نختار المضارع التام (have/has + V3).', - ), - const SizedBox(height: 10), - _buildChalkPoint( - marker: '2.', - title: 'Since vs For:', - text: 'نستخدم Since مع بداية نقطة زمنية محددة (since 2018, since morning, since last week). بينما نستخدم For مع مدة زمنية ممتدة محسوبة (for five years, for two hours, for days).', - ), - const SizedBox(height: 10), - _buildChalkPoint( - marker: '3.', - title: 'قاعدة Must vs Can\'t في الاستنتاج:', - text: 'لا يُقصد بهما هنا الأمر والمنع، بل قوة الاستنتاج العقلي! Must تعني "لا بد أنه كذلك" (يقين إيجابي)، و Can\'t تعني "مستحيل أن يكون كذلك" (يقين سلبي قاطع).', - ), - ], - ), - ), - - const SizedBox(height: 20), - - // Teacher Diagram Upload Readiness Status - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFF0F2B23), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: const Color(0xFF34D399).withAlpha(90)), - ), - child: const Row( - children: [ - Icon(CupertinoIcons.cloud_upload, color: Color(0xFF86EFAC), size: 24), - SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'حاضنة المخططات المرفوعة عالية الدقة (Cloud Diagram Readiness)', - style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold), - ), - SizedBox(height: 2), - Text( - 'يدعم النظام مزامنة رسومات المعلم واللوحات السبورية المرفوعة من لوحة التحكم لعرضها مباشرة للطلاب بتقنية التكبير السلس حتى 400%.', - style: TextStyle(color: Color(0xFF86EFAC), fontSize: 10, height: 1.4), - ), - ], - ), - ), - ], - ), - ), - ], - ), - ); - } - - Widget _buildChalkPoint({ - required String marker, - required String title, - required String text, - }) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - marker, - style: const TextStyle(color: Color(0xFF86EFAC), fontWeight: FontWeight.bold, fontSize: 12), - ), - const SizedBox(width: 8), - Expanded( - child: RichText( - text: TextSpan( - children: [ - TextSpan( - text: '$title ', - style: const TextStyle(color: Color(0xFF67E8F9), fontWeight: FontWeight.w800, fontSize: 11), - ), - TextSpan( - text: text, - style: const TextStyle(color: Color(0xFFE2E8F0), fontSize: 11, height: 1.5), - ), - ], - ), - ), - ), - ], - ); - } - - // ============================================================================ - // FULL SCREEN MIND MAP MODAL DIALOG - // ============================================================================ - void _showFullScreenMindMapDialog(BuildContext context) { - showGeneralDialog( - context: context, - barrierDismissible: true, - barrierLabel: 'MindMapFullscreen', - pageBuilder: (context, _, __) { - return Scaffold( - backgroundColor: const Color(0xFF050811), - appBar: AppBar( - backgroundColor: AppColors.darkSurface, - title: const Text( - 'خارطة القواعد والأزمنة التفاعلية — ملء الشاشة', - style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold), - ), - actions: [ - IconButton( - icon: const Icon(CupertinoIcons.speaker_2_fill, color: AppColors.saqelCyan), - onPressed: () => _speakWord( - 'Full screen interactive mind map. Pinch with two fingers to zoom up to four hundred percent.', - ), - ), - IconButton( - icon: const Icon(CupertinoIcons.xmark_circle_fill, color: Colors.white70), - onPressed: () => Navigator.of(context).pop(), - ), - ], - ), - body: Directionality( - textDirection: TextDirection.rtl, - child: InteractiveViewer( - minScale: 0.5, - maxScale: 5.0, - boundaryMargin: const EdgeInsets.all(100), - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(32), - child: _mindMapDisplayMode == 0 - ? _buildDigitalGrammarMindMapContent() - : _buildTeacherWhiteboardContent(), - ), - ), - ), - ), - ); - }, - ); - } + ), + ); } diff --git a/apps/student_app/lib/presentation/screens/curriculum/subject_hub_screen.dart b/apps/student_app/lib/presentation/screens/curriculum/subject_hub_screen.dart index e1b65a7..652c3f7 100644 --- a/apps/student_app/lib/presentation/screens/curriculum/subject_hub_screen.dart +++ b/apps/student_app/lib/presentation/screens/curriculum/subject_hub_screen.dart @@ -29,8 +29,7 @@ import 'math_interactive_lab_view.dart'; import 'english_interactive_lab_view.dart'; import '../../../data/models/exam_model.dart'; import '../../../data/repositories/app_repositories.dart'; -import '../../../core/services/local_document_storage_service.dart'; -import '../../../data/datasources/curriculum_baked_data.dart'; +import '../../../data/repositories/curriculum_repository.dart'; /// الشاشة المركزية للمادة الدراسية وبوابات الدروس والامتحانات والمصادر class SubjectHubScreen extends StatefulWidget { @@ -690,29 +689,10 @@ class _SubjectHubScreenState extends State with SingleTickerPr padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), - onPressed: () async { + onPressed: () { Navigator.of(ctx).pop(); - final docType = title.contains('كتاب') ? 'textbook' : 'worksheet'; - final content = CurriculumBakedData.getDocument( - subjectId: widget.subject.id, - type: docType, - filePath: filePath, - title: title, - ); - await LocalDocumentStorageService.saveDocumentLocally( - subjectId: widget.subject.id, - type: docType, - filePath: filePath ?? title, - title: title, - content: content, - ); - if (context.mounted) { - SaqelToast.showSuccess( - context, - 'تم حفظ المستند في الجهاز — متاح الآن للقراءة دون إنترنت 📖', - title: 'حفظ محلي مكتمل', - ); - } + SaqelToast.showInfo(context, + 'افتح المستند أولاً ثم احفظ النسخة المنشورة نفسها في الجهاز.'); }, icon: const Icon(CupertinoIcons.cloud_download, size: 18), label: const Text('حفظ في الجهاز', style: TextStyle(fontWeight: FontWeight.w700)), @@ -841,23 +821,44 @@ class _SubjectHubScreenState extends State with SingleTickerPr ); } - /// Direct Launch of Socratic Video Player or Unavailable Notice - void _showLessonVideoSelector(BuildContext context, CurriculumLessonItemModel lesson) { - if (!lesson.hasVideo) { + /// Loads the server-authoritative list: one version opens directly; several + /// versions require an explicit teacher choice. + Future _showLessonVideoSelector(BuildContext context, CurriculumLessonItemModel lesson) async { + final curriculumLessonId = lesson.curriculumLessonId; + if (!lesson.hasVideo || curriculumLessonId == null || curriculumLessonId.isEmpty) { _showNoVideoAvailableSheet(context, lesson); return; } - - // Direct authentic launch: zero mockups, zero fake teacher names - Navigator.of(context).push( - CupertinoPageRoute( - builder: (_) => SocraticVideoPlayerScreen( - lesson: lesson, - subject: widget.subject, - instructorName: 'منصة صَقِل التعليمية المعتمدة', - videoSourceLabel: 'الشرح الرقمي الرسمي المعتمد', - ), - ), - ); + try { + final videos = await CurriculumRepository().getPublishedLessonVideos(curriculumLessonId); + if (!context.mounted) return; + if (videos.isEmpty) { _showNoVideoAvailableSheet(context, lesson); return; } + PublishedLessonVideoModel? chosen; + if (videos.length == 1) { + chosen = videos.first; + } else { + chosen = await showCupertinoModalPopup( + context: context, + builder: (sheetContext) => CupertinoActionSheet( + title: const Text('اختر شرح المعلم'), + message: const Text('تُعرض الحصص المنشورة لهذا الدرس فقط، مرتبة بالتقييم الموثق.'), + actions: videos.map((video) => CupertinoActionSheetAction( + onPressed: () => Navigator.of(sheetContext).pop(video), + child: Text(video.ratingCount == 0 ? '${video.teacherName} — جديد' : '${video.teacherName} — ★ ${video.rating.toStringAsFixed(1)} (${video.ratingCount})'), + )).toList(), + cancelButton: CupertinoActionSheetAction(onPressed: () => Navigator.of(sheetContext).pop(), child: const Text('إلغاء')), + ), + ); + } + if (chosen == null || !context.mounted) return; + Navigator.of(context).push(CupertinoPageRoute(builder: (_) => SocraticVideoPlayerScreen( + lesson: lesson, subject: widget.subject, instructorName: chosen!.teacherName, + videoSourceLabel: chosen.ratingCount == 0 ? 'حصة منشورة جديدة' : 'تقييم موثق: ${chosen.rating.toStringAsFixed(1)}', + selectedVideoVersionId: chosen.videoVersionId, + ))); + } catch (e) { + if (context.mounted) SaqelToast.showError(context, 'تعذر تحميل الحصص المنشورة لهذا الدرس.'); + } } + } diff --git a/apps/student_app/lib/presentation/screens/player/socratic_video_player_screen.dart b/apps/student_app/lib/presentation/screens/player/socratic_video_player_screen.dart index 650f8f5..adc86ae 100644 --- a/apps/student_app/lib/presentation/screens/player/socratic_video_player_screen.dart +++ b/apps/student_app/lib/presentation/screens/player/socratic_video_player_screen.dart @@ -35,6 +35,7 @@ class SocraticVideoPlayerScreen extends StatefulWidget { final SubjectModel? subject; final String? instructorName; final String? videoSourceLabel; + final String? selectedVideoVersionId; const SocraticVideoPlayerScreen({ super.key, @@ -42,6 +43,7 @@ class SocraticVideoPlayerScreen extends StatefulWidget { this.subject, this.instructorName, this.videoSourceLabel, + this.selectedVideoVersionId, }); @override @@ -127,7 +129,7 @@ class _SocraticVideoPlayerScreenState extends State w @override void initState() { super.initState(); - context.read().loadLesson(widget.lesson, subject: widget.subject); + context.read().loadLesson(widget.lesson, subject: widget.subject, selectedVideoVersionId: widget.selectedVideoVersionId); // Dynamic Floating Forensic Anti-Piracy Watermark Animation _watermarkController = AnimationController( @@ -156,6 +158,7 @@ class _SocraticVideoPlayerScreenState extends State w @override void dispose() { + context.read().endWatchSession(); _tts.stop(); _playbackTicker?.cancel(); _controlsHideTimer?.cancel(); diff --git a/apps/teacher_app/lib/data/models/teacher_models.dart b/apps/teacher_app/lib/data/models/teacher_models.dart index e1eb16c..c8008db 100644 --- a/apps/teacher_app/lib/data/models/teacher_models.dart +++ b/apps/teacher_app/lib/data/models/teacher_models.dart @@ -188,9 +188,9 @@ class TeacherProfileModel { required this.schoolName, required this.gradesTaught, required this.bio, - this.rating = 4.9, - this.studentsCount = 42, - this.lessonsCount = 28, + this.rating = 0, + this.studentsCount = 0, + this.lessonsCount = 0, }); factory TeacherProfileModel.fromJson(Map json) { diff --git a/apps/teacher_app/lib/logic/cubits/teacher_monetization_cubit.dart b/apps/teacher_app/lib/logic/cubits/teacher_monetization_cubit.dart index 5b65e76..ee72d7a 100644 --- a/apps/teacher_app/lib/logic/cubits/teacher_monetization_cubit.dart +++ b/apps/teacher_app/lib/logic/cubits/teacher_monetization_cubit.dart @@ -28,6 +28,8 @@ class TeacherMonetizationState { final List payoutRequests; final bool isSubmittingPayout; final bool isLoading; + final bool financialUnavailable; + final String financialMessage; const TeacherMonetizationState({ required this.institutionalStudents, @@ -44,6 +46,8 @@ class TeacherMonetizationState { required this.payoutRequests, required this.isSubmittingPayout, this.isLoading = false, + this.financialUnavailable = false, + this.financialMessage = '', }); TeacherMonetizationState copyWith({ @@ -61,6 +65,8 @@ class TeacherMonetizationState { List? payoutRequests, bool? isSubmittingPayout, bool? isLoading, + bool? financialUnavailable, + String? financialMessage, }) { return TeacherMonetizationState( institutionalStudents: @@ -78,6 +84,8 @@ class TeacherMonetizationState { payoutRequests: payoutRequests ?? this.payoutRequests, isSubmittingPayout: isSubmittingPayout ?? this.isSubmittingPayout, isLoading: isLoading ?? this.isLoading, + financialUnavailable: financialUnavailable ?? this.financialUnavailable, + financialMessage: financialMessage ?? this.financialMessage, ); } } @@ -116,16 +124,21 @@ class TeacherMonetizationCubit extends Cubit { final mkt = audience['marketplace_students'] as Map? ?? {}; final wallet = data['wallet'] as Map? ?? {}; + final unavailable = data['financial_status'] == 'unavailable'; final instCount = (inst['count'] as num?)?.toDouble() ?? 0.0; final paidCount = (mkt['count'] as num?)?.toDouble() ?? 0.0; - final gross = - (data['gross_revenue_jod'] as num?)?.toDouble() ?? (paidCount * 20.0); - final tShare = gross * 0.55; - final dShare = gross * 0.15; - final pShare = gross * 0.30; + final split = data['revenue_split'] as Map? ?? {}; + // Never infer a wallet from subscriber counts, course prices, or old + // percentages. The backend returns null until a provider-backed ledger + // and eligible watch-minute policy are live. + final gross = (data['gross_revenue_jod'] as num?)?.toDouble() ?? 0.0; + final tShare = (split['teacher_amount_jod'] as num?)?.toDouble() ?? 0.0; + final dShare = + (split['directorate_amount_jod'] as num?)?.toDouble() ?? 0.0; + final pShare = (split['platform_amount_jod'] as num?)?.toDouble() ?? 0.0; final avail = - (wallet['available_balance_jod'] as num?)?.toDouble() ?? tShare; + (wallet['available_balance_jod'] as num?)?.toDouble() ?? 0.0; final withdrawn = (wallet['total_withdrawn_jod'] as num?)?.toDouble() ?? 0.0; final alias = wallet['cliq_payout_alias']?.toString() ?? ''; @@ -154,6 +167,8 @@ class TeacherMonetizationCubit extends Cubit { cliqAlias: alias, courses: courses, payoutRequests: payouts, + financialUnavailable: unavailable, + financialMessage: data['financial_message']?.toString() ?? '', isLoading: false, )); } catch (_) { @@ -162,15 +177,8 @@ class TeacherMonetizationCubit extends Cubit { } void updateSubscribers(double subscribers) { - final gross = subscribers * state.pricePerCourse; - emit(state.copyWith( - studentSubscribers: subscribers, - grossRevenue: gross, - teacherShare: gross * 0.55, - directorateShare: gross * 0.15, - platformShare: gross * 0.30, - availableBalance: gross * 0.55, - )); + // Presentation must not fabricate revenue from a manually changed count. + emit(state.copyWith(studentSubscribers: subscribers)); } Future submitCliqPayout({ @@ -186,14 +194,8 @@ class TeacherMonetizationCubit extends Cubit { final updatedList = List.from(state.payoutRequests) ..insert(0, req); - final newAvail = - (state.availableBalance - amountJod).clamp(0.0, double.infinity); - final newWithdrawn = state.totalWithdrawn + amountJod; - emit(state.copyWith( payoutRequests: updatedList, - availableBalance: newAvail, - totalWithdrawn: newWithdrawn, isSubmittingPayout: false, )); } catch (_) { diff --git a/apps/teacher_app/lib/presentation/screens/tabs/teacher_monetization_tab.dart b/apps/teacher_app/lib/presentation/screens/tabs/teacher_monetization_tab.dart index 6aa74e1..269f3f2 100644 --- a/apps/teacher_app/lib/presentation/screens/tabs/teacher_monetization_tab.dart +++ b/apps/teacher_app/lib/presentation/screens/tabs/teacher_monetization_tab.dart @@ -286,6 +286,40 @@ class _TeacherMonetizationTabState extends State { Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { + if (state.financialUnavailable) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: TeacherTheme.surfaceCard, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: TeacherTheme.surfaceBorder), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(CupertinoIcons.lock_circle_fill, + color: TeacherTheme.royalGold, size: 34), + const SizedBox(height: 12), + const Text('الأرباح والسحب غير متاحين حالياً', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w800)), + const SizedBox(height: 8), + Text(state.financialMessage, + textAlign: TextAlign.center, + style: const TextStyle( + color: Color(0xFF94A3B8), fontSize: 13)), + ], + ), + ), + ), + ); + } return SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( diff --git a/backend/app/Controllers/CliqPaymentController.php b/backend/app/Controllers/CliqPaymentController.php index 89c9b23..abbf684 100644 --- a/backend/app/Controllers/CliqPaymentController.php +++ b/backend/app/Controllers/CliqPaymentController.php @@ -36,7 +36,7 @@ class CliqPaymentController } $res = CliqPaymentService::initiatePayment($studentId, $courseId); - $status = ($res['status'] ?? 'error') === 'success' ? 200 : 400; + $status = ($res['status'] ?? 'error') === 'success' ? 200 : (($res['status'] ?? '') === 'unavailable' ? 503 : 400); $response->status($status)->json($res); } @@ -61,7 +61,7 @@ class CliqPaymentController } $res = CliqPaymentService::verifyPaymentReceipt($studentId, $referenceCode, $receiptBase64, $manualTxId); - $status = ($res['status'] ?? 'error') === 'success' ? 200 : 400; + $status = ($res['status'] ?? 'error') === 'success' ? 200 : (($res['status'] ?? '') === 'unavailable' ? 503 : 400); $response->status($status)->json($res); } @@ -85,7 +85,7 @@ class CliqPaymentController } $res = CliqPaymentService::requestTeacherPayout($teacherId, $amountJod, $cliqAlias); - $status = ($res['status'] ?? 'error') === 'success' ? 200 : 400; + $status = ($res['status'] ?? 'error') === 'success' ? 200 : (($res['status'] ?? '') === 'unavailable' ? 503 : 400); $response->status($status)->json($res); } @@ -111,6 +111,6 @@ class CliqPaymentController public function processPayoutQueue(Request $request, Response $response): void { $res = CliqPaymentService::processPayoutQueue(); - $response->json($res); + $response->status(($res['status'] ?? '') === 'unavailable' ? 503 : 200)->json($res); } } diff --git a/backend/app/Controllers/CurriculumController.php b/backend/app/Controllers/CurriculumController.php index f981459..42caef2 100644 --- a/backend/app/Controllers/CurriculumController.php +++ b/backend/app/Controllers/CurriculumController.php @@ -21,8 +21,11 @@ namespace App\Controllers; use App\Core\Request; use App\Core\Response; +use App\Core\Database; use App\Services\CurriculumService; use App\Services\CurriculumExtractorService; +use App\Services\PublishedContentService; +use App\Services\LearningPackageService; class CurriculumController { @@ -164,16 +167,33 @@ class CurriculumController $response->json(['status' => 'success', 'log' => $logContent]); } + /** GET /api/curriculum/lessons/{lessonId}/english-package */ + public function getPublishedEnglishPackage(Request $request, Response $response): void + { + try { + $result=LearningPackageService::publishedEnglish(trim((string)$request->getParam('lessonId','')),(int)$request->user_id,$request->getHeader('x-national-id')); + $response->status((int)$result['http_status'])->json(array_diff_key($result,['http_status'=>true])); + } catch (\Throwable $e) { error_log('English package read failed: '.$e->getMessage()); $response->status(503)->json(['status'=>'unavailable','message'=>'تعذر قراءة حزمة الإنجليزية.']); } + } + /** * Get Complete Live Tree */ public function getTree(Request $request, Response $response): void { - $response->json([ - 'status' => 'success', - 'data' => CurriculumService::getCurriculumTree() - ]); + $tree = CurriculumService::getCurriculumTree(); + try { + $published = Database::select("SELECT cl.uuid, cl.source_manifest_path, COUNT(vv.id) AS video_count FROM curriculum_lessons cl LEFT JOIN teacher_submissions ts ON ts.curriculum_lesson_id=cl.id AND ts.status='published' LEFT JOIN video_versions vv ON vv.id=ts.current_published_video_version_id AND vv.status='published' WHERE cl.source_status='approved' GROUP BY cl.id, cl.uuid, cl.source_manifest_path"); + $byPath=[]; + foreach ($published as $row) $byPath[(string)$row['source_manifest_path']]=['curriculum_lesson_id'=>$row['uuid'],'has_video'=>(int)$row['video_count']>0]; + foreach ($tree as &$grade) foreach (($grade['subjects'] ?? []) as &$subject) foreach (($subject['semesters'] ?? []) as &$semester) foreach (($semester['units'] ?? []) as &$unit) foreach (($unit['lessons'] ?? []) as &$lesson) { + $path=(string)($lesson['file'] ?? ''); + if (isset($byPath[$path])) $lesson=array_merge($lesson,$byPath[$path]); + } + unset($grade,$subject,$semester,$unit,$lesson); + } catch (\Throwable $e) { error_log('Published curriculum tree enrichment unavailable: '.$e->getMessage()); } + $response->json(['status'=>'success','data'=>$tree]); } /** @@ -423,30 +443,18 @@ class CurriculumController $type = $params['type'] ?? 'textbook'; $storage = realpath(__DIR__ . '/../../storage/curriculum'); - $resolvedFile = null; - - if (!empty($file) && !str_ends_with($file, '.pdf')) { - $cleanFile = ltrim($file, '/'); - if (file_exists("{$storage}/{$cleanFile}")) { - $resolvedFile = "{$storage}/{$cleanFile}"; - } + if (!$storage || empty($file) || !str_ends_with(strtolower($file), '.md')) { + $response->status(400)->json([ + 'status' => 'error', + 'message' => 'يجب تحديد ملف Markdown معتمد للوثيقة المطلوبة.' + ]); + return; } - // If not resolved by exact path, resolve by subject - if (!$resolvedFile && !empty($subject)) { - if (str_contains($subject, 'english') || str_contains($subject, 'إنجليز')) { - $mds = glob("{$storage}/grade_10/english_10/semester_1/*/*.md"); - if (!empty($mds)) $resolvedFile = $mds[0]; - } elseif (str_contains($subject, 'physic') || str_contains($subject, 'فيزياء')) { - $mds = glob("{$storage}/grade_10/physics_10/semester_1/*/*.md"); - if (!empty($mds)) $resolvedFile = $mds[1] ?? $mds[0]; - } elseif (str_contains($subject, 'math') || str_contains($subject, 'رياضيات')) { - $mds = glob("{$storage}/grade_10/math_10/semester_1/*/*.md"); - if (!empty($mds)) $resolvedFile = $mds[1] ?? $mds[0]; - } - } - - if ($resolvedFile && file_exists($resolvedFile)) { + $candidate = realpath($storage . '/' . ltrim($file, '/')); + $storagePrefix = rtrim($storage, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + if ($candidate && str_starts_with($candidate, $storagePrefix) && is_file($candidate)) { + $resolvedFile = $candidate; $content = file_get_contents($resolvedFile); $response->json([ 'status' => 'success', @@ -458,26 +466,87 @@ class CurriculumController return; } - // Tailored subject fallback - $content = self::getDefaultSubjectDocument($subject, $type); - $response->json([ - 'status' => 'success', - 'subject' => $subject, - 'type' => $type, - 'content' => $content + $response->status(404)->json([ + 'status' => 'error', + 'message' => 'ملف الوثيقة المعتمد غير موجود أو غير متاح حالياً.' ]); } - private static function getDefaultSubjectDocument(string $subject, string $type): string + /** + * Serves an approved asset from a published curriculum bundle by UUID only. + * GET /api/curriculum/assets/{assetId} + */ + public function getPublishedAsset(Request $request, Response $response): void { - $s = strtolower($subject); - if (str_contains($s, 'english') || str_contains($s, 'إنجليز')) { - return "## Unit 01: Looking Good — Vocabulary & Reading\n\n### 1. Key Vocabulary & Word Formation\n- **Casual clothing:** Everyday informal garments (jeans, sneakers, hoodie).\n- **Traditional attire:** Cultural heritage wear (Jordanian Thobe and Keffiyeh).\n- **Subconscious influence:** The way external appearance affects cognitive confidence.\n\n### 2. Reading Text: The Power of First Impressions\nResearch shows that within the first seven seconds of meeting someone, people make subconscious judgments about character, competence, and reliability. In a famous experiment, doctors wearing clean white coats demonstrated higher diagnostic focus.\n\n### 3. Grammar Workshop: Articles (a, an, the, zero article)\n- Use **a / an** for non-specific singular countable nouns.\n- Use **the** when the listener knows which specific thing is meant.\n- Use **zero article** with plural or uncountable nouns spoken in general terms."; + $assetId = trim((string)$request->getParam('assetId', '')); + try { + $asset = PublishedContentService::findPublishedAsset($assetId); + } catch (\Throwable $e) { + error_log('Published asset lookup failed: ' . $e->getMessage()); + $response->status(503)->json([ + 'status' => 'unavailable', + 'message' => 'فهرس الأصول المنشورة غير متاح حالياً.' + ]); + return; } - if (str_contains($s, 'physic') || str_contains($s, 'فيزياء')) { - return "## الوحدة الأولى: المتجهات والكميات الفيزيائية\n\n### 1. التمييز بين الكميات القياسية والمتجهة\n- **الكميات القياسية:** تُحدد بالمقدار ووحدة القياس فقط (الكتلة، الزمن، الطاقة، درجة الحرارة).\n- **الكميات المتجهة:** تُحدد بالمقدار والاتجاه ونقطة التأثير (القوة، السرعة المتجهة، التسارع، الإزاحة).\n\n### 2. جمع وتحليل المتجهات بيانياً وتحليلياً\n- **المركبة الأفقية:** A_x = A cos θ\n- **المركبة الرأسية:** A_y = A sin θ\n- **المحصلة:** R = √(R_x² + R_y²)\n- **زاوية الاتجاه:** tan θ = |R_y / R_x|"; + + if (!$asset) { + $response->status(404)->json([ + 'status' => 'error', + 'message' => 'الأصل المطلوب غير منشور أو غير متاح لك.' + ]); + return; } - return "## ملخص الوحدة والنتاجات التعليمية المقررة\n\n### 1. الأهداف العامة للمادة\n- استيعاب المفاهيم والمصطلحات الأساسية وفق المنهاج الوزاري المعتمد.\n- التدرب على حل النماذج والتطبيقات العملية والأسئلة الوزارية.\n- التحقق من اكتساب المهارات من خلال بنك الأسئلة التكيفي والمختبر."; + + $path = PublishedContentService::readLocalAsset($asset); + if (!$path) { + $response->status(404)->json([ + 'status' => 'error', + 'message' => 'النسخة المنشورة من الأصل غير متاحة في التخزين.' + ]); + return; + } + + if (str_starts_with((string)$asset['mime_type'], 'text/')) { + $content = file_get_contents($path); + if ($content === false) { + $response->status(503)->json(['status' => 'unavailable', 'message' => 'تعذر قراءة الأصل المنشور.']); + return; + } + $response->json([ + 'status' => 'success', + 'asset' => self::assetMetadata($asset), + 'content' => $content, + ]); + return; + } + + $response->setHeader('Content-Type', (string)$asset['mime_type']); + $response->setHeader('Content-Length', (string)$asset['byte_size']); + $response->setHeader('Content-Disposition', 'inline; filename="' . basename($path) . '"'); + $response->sendHeaders(); + readfile($path); + exit; + } + + private static function assetMetadata(array $asset): array + { + return [ + 'asset_id' => $asset['uuid'], + 'asset_type' => $asset['asset_type'], + 'mime_type' => $asset['mime_type'], + 'byte_size' => (int)$asset['byte_size'], + 'sha256' => $asset['sha256'], + 'bundle_version' => $asset['bundle_version'], + 'curriculum_lesson_id' => $asset['curriculum_lesson_uuid'], + 'identity' => [ + 'grade' => $asset['grade_key'], + 'subject' => $asset['subject_key'], + 'semester' => $asset['semester_key'], + 'unit' => $asset['unit_key'], + 'lesson' => $asset['lesson_key'], + ], + ]; } /** @@ -593,4 +662,3 @@ class CurriculumController ]); } } - diff --git a/backend/app/Controllers/ExamController.php b/backend/app/Controllers/ExamController.php index faf4377..fcfd42f 100644 --- a/backend/app/Controllers/ExamController.php +++ b/backend/app/Controllers/ExamController.php @@ -424,18 +424,7 @@ class ExamController /** Additive production migration for installations created before completed_at. */ private static function ensureAttemptSchema(): void { - try { - $column = Database::selectOne( - "SELECT COUNT(*) AS cnt FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'exam_attempts' - AND COLUMN_NAME = 'completed_at' LIMIT 1" - ); - if (empty($column['cnt'])) { - Database::query("ALTER TABLE exam_attempts ADD COLUMN completed_at TIMESTAMP NULL DEFAULT NULL AFTER ai_diagnostic_report"); - } - } catch (\Throwable $e) { - error_log('Exam schema migration note: ' . $e->getMessage()); - } + // Kept for compatibility with callers. Schema is installed by migrations. } /** @@ -484,40 +473,7 @@ class ExamController */ public static function ensureSchema(): void { - try { - Database::query( - "CREATE TABLE IF NOT EXISTS `student_question_answers` ( - `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - `attempt_id` BIGINT UNSIGNED NOT NULL, - `student_id` BIGINT UNSIGNED NOT NULL, - `question_id` BIGINT UNSIGNED NOT NULL, - `selected_option_id` BIGINT UNSIGNED NULL, - `is_correct` TINYINT(1) NOT NULL DEFAULT 0, - `points_awarded` DECIMAL(5, 2) NOT NULL DEFAULT 0.00, - `time_spent_seconds` INT UNSIGNED NOT NULL DEFAULT 0, - `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - KEY `idx_sqa_attempt` (`attempt_id`), - KEY `idx_sqa_student` (`student_id`), - KEY `idx_sqa_question` (`question_id`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" - ); - - // Ensure completed_at in exam_attempts - $colCheck = Database::selectOne( - "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS - WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'exam_attempts' AND COLUMN_NAME = 'completed_at' LIMIT 1" - ); - if (!$colCheck) { - Database::query("ALTER TABLE exam_attempts ADD COLUMN completed_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP AFTER ai_diagnostic_report"); - } - - // Safely ensure scope ENUM covers unit_comprehensive if ever passed - try { - Database::query("ALTER TABLE exams MODIFY COLUMN scope ENUM('in_video_checkpoint', 'lesson_exam', 'unit_exam', 'unit_comprehensive', 'semester_final') NOT NULL DEFAULT 'in_video_checkpoint'"); - } catch (\Throwable $ign) {} - } catch (\Throwable $e) { - error_log("ExamController ensureSchema notice: " . $e->getMessage()); - } + // Kept for compatibility with callers. Schema is installed by migrations. } /** diff --git a/backend/app/Controllers/TeacherController.php b/backend/app/Controllers/TeacherController.php index 3bfc8df..25aa885 100644 --- a/backend/app/Controllers/TeacherController.php +++ b/backend/app/Controllers/TeacherController.php @@ -13,12 +13,7 @@ class TeacherController { private static function ensureTeacherColumns(): void { - try { - Database::query("ALTER TABLE teachers ADD COLUMN grades_taught TEXT DEFAULT NULL"); - } catch (\Throwable $e) {} - try { - Database::query("ALTER TABLE teachers ADD COLUMN school_name VARCHAR(255) DEFAULT NULL"); - } catch (\Throwable $e) {} + // Compatibility hook. These columns are installed by migrations. } /** @@ -108,6 +103,7 @@ class TeacherController $teacher = Database::selectOne("SELECT * FROM teachers WHERE id = ? LIMIT 1", [$teacherId]); + $metrics = Database::selectOne('SELECT weighted_student_rating, total_reviews_count FROM teacher_performance_metrics WHERE teacher_id = ? LIMIT 1', [$userId]) ?: []; $response->json([ 'status' => 'success', 'message' => 'تم توثيق بيانات المعلم والصفوف والمباحث بنجاح!', @@ -143,14 +139,20 @@ class TeacherController [$userId] )['total']; + $metrics = Database::selectOne( + 'SELECT weighted_student_rating, total_reviews_count FROM teacher_performance_metrics WHERE teacher_id = ? LIMIT 1', + [$userId] + ) ?: []; + $response->json([ 'status' => 'success', 'data' => [ 'courses_count' => $coursesCount, 'lessons_count' => $lessonsCount, 'students_count' => $studentsCount, - 'rating' => 4.9, - 'completion_avg' => 87.5 + 'rating' => isset($metrics['weighted_student_rating']) ? (float)$metrics['weighted_student_rating'] : null, + 'rating_count' => (int)($metrics['total_reviews_count'] ?? 0), + 'completion_avg' => null ] ]); } @@ -413,6 +415,43 @@ class TeacherController return; } + // Revenue sharing is being rebuilt around provider-backed ledger entries + // and eligible watch minutes. Do not turn course prices, platform sales, + // or legacy payout rows into a teacher wallet in the meantime. + $courses = Database::select( + "SELECT c.id, c.title as course_title, c.price_jod, c.is_published, + COUNT(l.id) as lessons_total + FROM courses c + LEFT JOIN lessons l ON l.course_id = c.id + WHERE c.teacher_id = ? + GROUP BY c.id + ORDER BY c.id ASC", + [$teacherId] + ); + $response->json([ + 'status' => 'success', + 'data' => [ + 'financial_status' => 'unavailable', + 'financial_message' => 'الأرباح والسحب متوقفان حتى اكتمال دفتر الاستحقاق وربط مزود الدفع.', + 'audience_breakdown' => [ + 'institutional_students' => ['count' => 0], + 'marketplace_students' => ['count' => 0], + ], + 'course_price_jod' => null, + 'gross_revenue_jod' => null, + 'revenue_split' => null, + 'wallet' => [ + 'available_balance_jod' => null, + 'pending_clearance_jod' => null, + 'total_withdrawn_jod' => null, + 'cliq_payout_alias' => '', + 'recent_payouts' => [], + ], + 'courses' => $courses, + ], + ]); + return; + // 1. Real Student Counts from Database $totalStudents = (int)(Database::selectOne("SELECT COUNT(*) as c FROM students")['c'] ?? 0); diff --git a/backend/app/Controllers/VideoController.php b/backend/app/Controllers/VideoController.php index e8a7abc..91d11a2 100644 --- a/backend/app/Controllers/VideoController.php +++ b/backend/app/Controllers/VideoController.php @@ -24,9 +24,169 @@ use App\Core\Security; use App\Services\VideoService; use App\Services\AiVideoAnalyzerService; use App\Services\CurriculumService; +use App\Services\TeacherSubmissionService; +use App\Services\VideoReviewService; class VideoController { + /** POST /api/video-versions/{versionId}/watch-sessions */ + public function startWatchSession(Request $request, Response $response): void + { + try { + $result = \App\Services\WatchSessionService::start((int)$request->user_id, trim((string)$request->getParam('versionId','')), $request->getHeader('x-national-id')); + $response->status((int)$result['http_status'])->json(array_diff_key($result,['http_status'=>true])); + } catch (\Throwable $e) { error_log('Watch session start failed: '.$e->getMessage()); $response->status(503)->json(['status'=>'unavailable','message'=>'تعذر بدء جلسة المشاهدة.']); } + } + + /** POST /api/watch-sessions/{sessionId}/events */ + public function recordWatchEvent(Request $request, Response $response): void + { + $body=$request->getBody(); + try { + $result = \App\Services\WatchSessionService::event((int)$request->user_id,trim((string)$request->getParam('sessionId','')),(int)($body['sequence_no']??0),trim((string)($body['event_type']??'')),(int)($body['position_ms']??-1),is_array($body['payload']??null)?$body['payload']:[]); + $response->status((int)$result['http_status'])->json(array_diff_key($result,['http_status'=>true])); + } catch (\Throwable $e) { error_log('Watch event failed: '.$e->getMessage()); $response->status(503)->json(['status'=>'unavailable','message'=>'تعذر حفظ حدث المشاهدة.']); } + } + /** GET /api/video-versions/{versionId}/playback */ + public function getVideoVersionPlayback(Request $request, Response $response): void + { + $versionUuid = trim((string)$request->getParam('versionId', '')); + if (!preg_match('/^[0-9a-f-]{36}$/i', $versionUuid)) { + $response->status(400)->json(['status'=>'error','message'=>'هوية نسخة الفيديو غير صالحة.']); return; + } + try { + $row = Database::selectOne( + "SELECT vv.uuid AS video_version_id, l.id AS lesson_id, l.course_id, l.storage_type, l.video_uuid, l.hls_url, l.ai_video_url, l.bunny_video_id, l.duration_seconds, + cl.uuid AS curriculum_lesson_id, cl.title, ts.uuid AS submission_id + FROM video_versions vv + JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id AND ts.current_published_video_version_id=vv.id AND ts.status='published' + JOIN curriculum_lessons cl ON cl.id=ts.curriculum_lesson_id + JOIN lessons l ON l.id=vv.source_lesson_id AND l.encoding_status='ready' + WHERE vv.uuid=? AND vv.status='published' LIMIT 1", [$versionUuid] + ); + if (!$row) { $response->status(404)->json(['status'=>'error','message'=>'نسخة الفيديو المطلوبة غير منشورة أو سُحبت.']); return; } + $course=Database::selectOne('SELECT grade_level FROM courses WHERE id=? LIMIT 1',[$row['course_id']]); + $access=\App\Services\StudentAccessControlService::validateLessonAccess((int)$request->user_id, $request->getHeader('x-national-id'), \App\Services\StudentAccessControlService::normalizeGrade($course['grade_level'] ?? 'grade_10'), (int)$row['course_id'], (int)$row['lesson_id']); + if (empty($access['allowed'])) { $response->status(403)->json(['status'=>'forbidden','message'=>$access['message'] ?? 'غير مصرح بمشاهدة هذه الحصة.']); return; } + if ($row['storage_type']==='api_upload') $playback=['storage_type'=>'api_upload','video_url'=>$row['hls_url'] ?: '/api/videos/stream/'.$row['video_uuid'],'hls_url'=>$row['hls_url'] ?: '/api/videos/hls/'.$row['video_uuid'].'/index.m3u8']; + elseif (!empty($row['bunny_video_id'])) $playback=array_merge(['storage_type'=>'bunny_stream'],VideoService::generateBunnySignedPlayback($row['bunny_video_id'],10800)); + elseif (!empty($row['ai_video_url'])) $playback=['storage_type'=>'direct_url','video_url'=>$row['ai_video_url'],'hls_url'=>$row['ai_video_url']]; + else { $response->status(409)->json(['status'=>'error','message'=>'تخزين نسخة الفيديو غير جاهز.']); return; } + $response->json(['status'=>'success','data'=>['video_version_id'=>$row['video_version_id'],'submission_id'=>$row['submission_id'],'curriculum_lesson_id'=>$row['curriculum_lesson_id'],'lesson_id'=>(int)$row['lesson_id'],'title'=>$row['title'],'duration_seconds'=>(int)$row['duration_seconds'],'playback'=>$playback,'checkpoints'=>[]]]); + } catch (\Throwable $e) { error_log('Version playback failed: '.$e->getMessage()); $response->status(503)->json(['status'=>'unavailable','message'=>'تعذر تجهيز تشغيل نسخة الفيديو.']); } + } + + /** GET /api/curriculum/lessons/{lessonId}/videos?cursor=&limit= */ + public function listPublishedLessonVideos(Request $request, Response $response): void + { + $lessonUuid = trim((string)$request->getParam('lessonId', '')); + $cursor = max(0, (int)$request->getQuery('cursor', 0)); + $limit = min(50, max(1, (int)$request->getQuery('limit', 20))); + if (!preg_match('/^[0-9a-f-]{36}$/i', $lessonUuid)) { + $response->status(400)->json(['status' => 'error', 'message' => 'هوية الدرس غير صالحة.']); return; + } + try { + $lesson = Database::selectOne("SELECT id, uuid, title FROM curriculum_lessons WHERE uuid = ? AND source_status = 'approved' LIMIT 1", [$lessonUuid]); + if (!$lesson) { $response->status(404)->json(['status'=>'error','message'=>'الدرس غير منشور.']); return; } + $rows = Database::select( + "SELECT vv.id, vv.uuid AS video_version_id, vv.source_lesson_id, ts.uuid AS submission_id, + c.grade_level, l.course_id, t.full_name AS teacher_name, COALESCE(pm.weighted_student_rating, 0) AS rating, + COALESCE(pm.total_reviews_count, 0) AS rating_count + FROM teacher_submissions ts + JOIN video_versions vv ON vv.id = ts.current_published_video_version_id AND vv.status = 'published' + JOIN lessons l ON l.id = vv.source_lesson_id AND l.encoding_status = 'ready' + JOIN courses c ON c.id = l.course_id + JOIN teachers t ON t.id = ts.teacher_id + LEFT JOIN teacher_performance_metrics pm ON pm.teacher_id = t.id + WHERE ts.curriculum_lesson_id = ? AND ts.status = 'published' + ORDER BY (rating_count > 0) DESC, rating DESC, vv.id ASC LIMIT 101", + [$lesson['id']] + ); + // A list is also protected content: do not disclose a teacher, count, + // or version that the student cannot play. A curriculum lesson may + // legitimately have versions attached to different courses. + $accessible = []; + foreach ($rows as $row) { + $access = \App\Services\StudentAccessControlService::validateLessonAccess( + (int)$request->user_id, + $request->getHeader('x-national-id'), + \App\Services\StudentAccessControlService::normalizeGrade($row['grade_level'] ?? 'grade_10'), + (int)$row['course_id'], + (int)$row['source_lesson_id'], + false + ); + if (!empty($access['allowed'])) $accessible[] = $row; + } + // `cursor` is an offset in the stable rating order, rather than a + // database id (an id seek would skip records after rating changes). + $page = array_slice($accessible, $cursor, $limit); + $hasMore = count($accessible) > ($cursor + count($page)); + $items = array_map(static fn($r) => ['video_version_id'=>$r['video_version_id'],'submission_id'=>$r['submission_id'],'teacher_name'=>$r['teacher_name'],'rating'=>(float)$r['rating'],'rating_count'=>(int)$r['rating_count'],'is_new'=>(int)$r['rating_count']===0], $page); + $response->json(['status'=>'success','data'=>['curriculum_lesson_id'=>$lesson['uuid'],'title'=>$lesson['title'],'available_count'=>count($accessible),'items'=>$items,'next_cursor'=>$hasMore ? $cursor + count($page) : null]]); + } catch (\Throwable $e) { error_log('Lesson videos list failed: '.$e->getMessage()); $response->status(503)->json(['status'=>'unavailable','message'=>'تعذر تحميل حصص هذا الدرس.']); } + } + /** POST /api/teacher/submissions/preflight */ + public function preflightSubmission(Request $request, Response $response): void + { + $body = $request->getBody(); + $lessonId = trim((string)($body['curriculum_lesson_id'] ?? '')); + $replacement = !empty($body['replacement']); + $submissionId = isset($body['submission_id']) ? trim((string)$body['submission_id']) : null; + $key = trim((string)$request->getHeader('idempotency-key', '')); + try { + $result = TeacherSubmissionService::preflight((int)$request->user_id, $lessonId, $key, $replacement, $submissionId); + $response->status((int)$result['http_status'])->json(array_diff_key($result, ['http_status' => true])); + } catch (\Throwable $e) { + error_log('Teacher submission preflight failed: ' . $e->getMessage()); + $response->status(503)->json(['status' => 'unavailable', 'message' => 'تعذر إنشاء نسخة الحصة حالياً.']); + } + } + + /** POST /api/admin/video-review/publish; super-admin only route. */ + public function publishReviewedVersion(Request $request, Response $response): void + { + $body = $request->getBody(); + $jobId = trim((string)($body['review_job_id'] ?? '')); + $expected = isset($body['expected_current_version_id']) && $body['expected_current_version_id'] !== '' + ? trim((string)$body['expected_current_version_id']) : null; + try { + $result = VideoReviewService::publishApproved($jobId, $expected); + $response->status((int)$result['http_status'])->json(array_diff_key($result, ['http_status' => true])); + } catch (\Throwable $e) { + error_log('Video publication failed: ' . $e->getMessage()); + $response->status(503)->json(['status' => 'unavailable', 'message' => 'تعذر نشر نسخة الفيديو حالياً.']); + } + } + + /** POST /api/admin/video-review/decision; super-admin only route. */ + public function recordVideoReviewDecision(Request $request, Response $response): void + { + $body=$request->getBody(); + try { + $result=VideoReviewService::recordHumanReview(trim((string)($body['review_job_id']??'')),(int)$request->user_id,trim((string)($body['recommendation']??'')),trim((string)($body['decision']??'')),is_array($body['report']??null)?$body['report']:[]); + $response->status((int)$result['http_status'])->json(array_diff_key($result,['http_status'=>true])); + } catch(\Throwable $e){error_log('Video review decision failed: '.$e->getMessage());$response->status(503)->json(['status'=>'unavailable','message'=>'تعذر حفظ قرار المراجعة.']);} + } + + /** POST /api/admin/video-review/evidence; super-admin only route. */ + public function submitVideoReviewEvidence(Request $request, Response $response): void + { + $body = $request->getBody(); + try { + $result = VideoReviewService::submitEvidence( + trim((string)($body['review_job_id'] ?? '')), + (int)$request->user_id, + trim((string)($body['transcript_asset_id'] ?? '')), + trim((string)($body['visual_evidence_asset_id'] ?? '')), + is_array($body['coverage'] ?? null) ? $body['coverage'] : [] + ); + $response->status((int)$result['http_status'])->json(array_diff_key($result, ['http_status' => true])); + } catch (\Throwable $e) { + error_log('Video review evidence failed: ' . $e->getMessage()); + $response->status(503)->json(['status'=>'unavailable','message'=>'تعذر حفظ أدلة فحص الفيديو.']); + } + } + /** * رفع فيديو الشرح مباشرة عبر واجهة البرمجة وتوليد HLS وبدء التحليل السقراطي بالذكاء الاصطناعي * POST /api/teacher/videos/upload-direct @@ -54,6 +214,7 @@ class VideoController $title = trim((string)($request->getBody()['title'] ?? $_POST['title'] ?? '')); $seqOrder = (int)($request->getBody()['sequence_order'] ?? $_POST['sequence_order'] ?? 1); $curriculumKey = trim((string)($request->getBody()['curriculum_key'] ?? $_POST['curriculum_key'] ?? '')); + $videoVersionId = trim((string)($request->getBody()['video_version_id'] ?? $_POST['video_version_id'] ?? '')); if (empty($title)) { $response->status(400)->json([ @@ -62,6 +223,26 @@ class VideoController ]); return; } + if ($videoVersionId === '') { + $response->status(409)->json([ + 'status' => 'submission_preflight_required', + 'message' => 'أنشئ نسخة حصة عبر preflight قبل رفع الفيديو.', + ]); + return; + } + try { + if (!TeacherSubmissionService::reserveUpload((int)$request->user_id, $videoVersionId)) { + $response->status(409)->json([ + 'status' => 'video_version_unavailable', + 'message' => 'نسخة الفيديو استُخدمت أو لم تعد متاحة للرفع. أنشئ نسخة مرشحة جديدة.', + ]); + return; + } + } catch (\Throwable $e) { + error_log('Video version reservation failed: ' . $e->getMessage()); + $response->status(503)->json(['status' => 'unavailable', 'message' => 'تعذر حجز نسخة الفيديو للرفع.']); + return; + } // Resolve a teacher-owned course when the app did not explicitly select one. $course = null; @@ -177,6 +358,12 @@ class VideoController if ($auditId) { Database::query('UPDATE video_upload_audits SET lesson_id = ? WHERE id = ?', [$lessonId, $auditId]); } + TeacherSubmissionService::attachUploadedLesson( + (int)$request->user_id, + $videoVersionId, + $lessonId, + hash_file('sha256', (string)$_FILES['video']['tmp_name']) + ); // Immediately send HTTP 201 response to Flutter client and close connection $responsePayload = [ @@ -210,10 +397,8 @@ class VideoController $uploadResult['mime_type'] ); - // 2. Autonomous Zero-Touch AI Analysis & Socratic Checkpoint Generation - AiVideoAnalyzerService::processLessonAutonomously($lessonId); - - // 3. Mark encoding as ready + // 2. Storage readiness is not publication. The evidence-bound + // review job controls any later release of this version. Database::query("UPDATE lessons SET encoding_status = 'ready' WHERE id = ?", [$lessonId]); } catch (\Throwable $bgError) { error_log("Background sync/analysis error for lesson {$lessonId}: " . $bgError->getMessage()); @@ -672,17 +857,8 @@ class VideoController } } - // If lesson has no checkpoints or missing questions, generate them autonomously - $existingCount = Database::selectOne("SELECT COUNT(*) as cnt FROM exams WHERE lesson_id = ? AND scope = 'in_video_checkpoint'", [$lessonId]); - $existingQuestions = Database::selectOne("SELECT COUNT(*) as cnt FROM questions q JOIN exams e ON q.exam_id = e.id WHERE e.lesson_id = ?", [$lessonId]); - if (empty($existingCount['cnt']) || empty($existingQuestions['cnt'])) { - try { - AiVideoAnalyzerService::processLessonAutonomously($lessonId); - $lesson = Database::selectOne("SELECT * FROM lessons WHERE id = ? LIMIT 1", [$lessonId]); - } catch (\Throwable $e) { - error_log("Autonomous video analysis notice: " . $e->getMessage()); - } - } + // Playback is read-only. Checkpoints are published only by the review + // workflow after it verifies the video transcript and lesson Markdown. // Fetch attached in-video Socratic Checkpoints with Questions and Options $exams = Database::select( @@ -696,17 +872,21 @@ class VideoController $checkpoints = []; foreach ($exams as $ex) { $q = Database::selectOne("SELECT id, question_text, explanation_text FROM questions WHERE exam_id = ? LIMIT 1", [$ex['exam_id']]); + if (!$q) { + continue; + } $opts = []; - if ($q) { - $opts = Database::select("SELECT id, option_text, is_correct, feedback_text FROM question_options WHERE question_id = ?", [$q['id']]); + $opts = Database::select("SELECT id, option_text, is_correct, feedback_text FROM question_options WHERE question_id = ?", [$q['id']]); + if (count($opts) < 2) { + continue; } $checkpoints[] = [ 'exam_id' => (int)$ex['exam_id'], - 'question_id' => $q ? (int)$q['id'] : 0, + 'question_id' => (int)$q['id'], 'timestamp_seconds' => (int)$ex['timestamp_seconds'], 'rewind_on_fail_seconds' => (int)$ex['rewind_on_fail_seconds'], - 'question_text' => $q['question_text'] ?? 'سؤال فحص فهم الفكرة:', + 'question_text' => $q['question_text'], 'explanation' => $q['explanation_text'] ?? '', 'options' => array_map(function ($o) { return [ diff --git a/backend/app/Services/AiVideoAnalyzerService.php b/backend/app/Services/AiVideoAnalyzerService.php index 749f43e..b9e9d06 100644 --- a/backend/app/Services/AiVideoAnalyzerService.php +++ b/backend/app/Services/AiVideoAnalyzerService.php @@ -201,6 +201,16 @@ class AiVideoAnalyzerService // Perform Gemini AI or Curriculum-grounded Analysis $analysisResult = self::generateAnalysis($lessonTitle, $duration, $curriculum); + if (($analysisResult['status'] ?? '') === 'needs_evidence_review') { + return [ + 'status' => 'needs_evidence_review', + 'lesson_id' => $lessonId, + 'message' => 'لا يمكن نشر فصول أو أسئلة للفيديو من دون تفريغ زمني ومراجعة تربوية مرتبطة بالدرس.', + 'timeline_chapters' => [], + 'checkpoints_count' => 0, + ]; + } + // 1. Save Timeline Chapters to lessons table if (!empty($analysisResult['timeline_chapters'])) { Database::query( @@ -407,6 +417,15 @@ class AiVideoAnalyzerService */ private static function generateAnalysis(string $lessonTitle, int $duration, array $curriculum): array { + // This legacy signature receives neither a timestamped transcript nor + // the approved Markdown version. It must never create publishable + // video claims, even when an AI key happens to be configured. + return [ + 'timeline_chapters' => [], + 'socratic_checkpoints' => [], + 'status' => 'needs_evidence_review', + ]; + $geminiKey = self::nextGeminiApiKey(); if (!empty($geminiKey)) { @@ -482,8 +501,14 @@ class AiVideoAnalyzerService } } - // High-Precision Curriculum Grounded Engine (Offline / Safe Fallback) - return self::buildGroundedCurriculumAnalysis($lessonTitle, $duration, $curriculum); + // A title and curriculum outline do not prove what appears at a given + // moment in the video. Do not publish chapters or questions without + // transcript evidence and a successful review workflow. + return [ + 'timeline_chapters' => [], + 'socratic_checkpoints' => [], + 'status' => 'needs_evidence_review', + ]; } /** diff --git a/backend/app/Services/CliqPaymentService.php b/backend/app/Services/CliqPaymentService.php index 43b3de5..75b8b10 100644 --- a/backend/app/Services/CliqPaymentService.php +++ b/backend/app/Services/CliqPaymentService.php @@ -27,14 +27,14 @@ class CliqPaymentService public const DEFAULT_BANK_NAME = 'بنك الاتحاد / البنك العربي (الأردن)'; /** - * إنشاء الجداول المخصصة لمدفوعات كليك وطابور السحب تلقائياً إن لم تكن موجودة + * @deprecated Schema is installed through migrations only. This no-op + * prevents legacy callers from running DDL during an HTTP request. */ public static function ensureSchema(): void { - static $ran = false; - if ($ran) return; - $ran = true; - + /* Runtime DDL removed; apply 20260909_payment_safety_hold.sql instead. */ + return; + /* try { // جدول مدفوعات كليك الواردة من الطلاب Database::query( @@ -79,6 +79,16 @@ class CliqPaymentService } catch (\Throwable $e) { error_log('[CliqPaymentService] ensureSchema warning: ' . $e->getMessage()); } + */ + } + + private static function unavailable(string $operation): array + { + return [ + 'status' => 'unavailable', + 'operation' => $operation, + 'message' => 'هذه العملية المالية غير متاحة حتى يكتمل ربط مزود الدفع والتسوية الموثقة. لم يتم إنشاء حركة مالية أو تفعيل وصول أو سحب رصيد.', + ]; } /** @@ -86,6 +96,8 @@ class CliqPaymentService */ public static function initiatePayment(int $studentId, int $courseId): array { + return self::unavailable('initiate_payment'); + self::ensureSchema(); $course = Database::selectOne("SELECT id, title, price_jod, teacher_id FROM courses WHERE id = ? LIMIT 1", [$courseId]); @@ -130,6 +142,8 @@ class CliqPaymentService string $receiptBase64, ?string $manualTxId = null ): array { + return self::unavailable('submit_receipt'); + self::ensureSchema(); $payment = Database::selectOne( @@ -225,6 +239,8 @@ class CliqPaymentService */ public static function requestTeacherPayout(int $teacherId, float $amountJod, string $teacherCliqAlias): array { + return self::unavailable('request_payout'); + self::ensureSchema(); if ($amountJod < 20.00) { @@ -259,6 +275,8 @@ class CliqPaymentService */ public static function processPayoutQueue(): array { + return self::unavailable('process_payout_queue'); + self::ensureSchema(); $queuedItems = Database::select( @@ -298,6 +316,8 @@ class CliqPaymentService */ public static function getTeacherPayouts(int $teacherId): array { + return []; + self::ensureSchema(); $payouts = Database::select( diff --git a/backend/app/Services/CurriculumService.php b/backend/app/Services/CurriculumService.php index cd5a607..bbf49f1 100644 --- a/backend/app/Services/CurriculumService.php +++ b/backend/app/Services/CurriculumService.php @@ -28,56 +28,11 @@ class CurriculumService } /** - * Runtime migration: add is_system_curriculum (and related columns) to `courses` - * if they are missing. Safe to call multiple times — uses IF NOT EXISTS / IGNORE. + * Compatibility hook. Course schema is managed by migrations, never by requests. */ public static function ensureSystemCurriculumColumn(): void { - static $ran = false; - if ($ran) return; - $ran = true; - - try { - // Check whether the column exists to avoid noisy ALTER errors on every request - $row = Database::selectOne( - "SELECT COUNT(*) as cnt FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = DATABASE() - AND TABLE_NAME = 'courses' - AND COLUMN_NAME = 'is_system_curriculum' - LIMIT 1" - ); - - if (empty($row['cnt'])) { - Database::query( - "ALTER TABLE `courses` - ADD COLUMN `is_system_curriculum` TINYINT(1) NOT NULL DEFAULT 0 - COMMENT 'الدورة الرئيسية للمنهاج الوزاري' - AFTER `is_school_exclusive`" - ); - } - - // Also ensure grade_level + stream columns exist (for student filtering) - $gradeRow = Database::selectOne( - "SELECT COUNT(*) as cnt FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = DATABASE() - AND TABLE_NAME = 'courses' - AND COLUMN_NAME = 'grade_level' - LIMIT 1" - ); - if (empty($gradeRow['cnt'])) { - Database::query( - "ALTER TABLE `courses` - ADD COLUMN `grade_level` VARCHAR(50) DEFAULT NULL - COMMENT 'المرحلة الدراسية مثل: tawjihi_2008' - AFTER `is_system_curriculum`, - ADD COLUMN `stream` ENUM('scientific','literary','vocational','general') DEFAULT NULL - COMMENT 'الفرع الدراسي' - AFTER `grade_level`" - ); - } - } catch (\Throwable $e) { - error_log('[CurriculumService] ensureSystemCurriculumColumn error: ' . $e->getMessage()); - } + // Intentionally empty. Run backend/scripts/run_migrations.php before serving. } /** diff --git a/backend/app/Services/LearningPackageService.php b/backend/app/Services/LearningPackageService.php new file mode 100644 index 0000000..7cee757 --- /dev/null +++ b/backend/app/Services/LearningPackageService.php @@ -0,0 +1,19 @@ +400,'status'=>'error','message'=>'هوية الدرس غير صالحة.']; + $lesson=Database::selectOne("SELECT cl.id,cl.uuid,cl.grade_key,cl.title FROM curriculum_lessons cl WHERE cl.uuid=? AND cl.source_status='approved' LIMIT 1",[$lessonUuid]); + if(!$lesson)return ['http_status'=>404,'status'=>'error','message'=>'الدرس غير منشور.']; + $access=StudentAccessControlService::validateLessonAccess($studentId,$nationalId,StudentAccessControlService::normalizeGrade((string)$lesson['grade_key']),null,null,false); + if(empty($access['allowed']))return ['http_status'=>403,'status'=>'forbidden','message'=>$access['message']??'غير مصرح بالوصول إلى الدرس.']; + $package=Database::selectOne("SELECT lp.id,lp.structure_json,pb.bundle_version FROM lesson_learning_packages lp JOIN publication_bundles pb ON pb.id=lp.bundle_id AND pb.status='published' WHERE lp.curriculum_lesson_id=? AND lp.package_type='english' AND lp.status='published' LIMIT 1",[$lesson['id']]); + if(!$package)return ['http_status'=>404,'status'=>'not_ready','message'=>'حزمة الإنجليزية المعتمدة لهذا الدرس لم تُنشر بعد.']; + $entries=Database::select("SELECT e.lemma,e.part_of_speech,e.meaning_ar,e.ipa_verified,a.uuid AS audio_asset_id FROM english_lexical_entries e LEFT JOIN content_assets a ON a.id=e.audio_asset_id AND a.review_status='approved' WHERE e.lesson_learning_package_id=? AND e.review_status='approved' ORDER BY e.id",[$package['id']]); + return ['http_status'=>200,'status'=>'success','data'=>['curriculum_lesson_id'=>$lesson['uuid'],'title'=>$lesson['title'],'bundle_version'=>$package['bundle_version'],'structure'=>json_decode((string)$package['structure_json'],true)??[],'lexical_entries'=>$entries]]; + } +} diff --git a/backend/app/Services/PublishedContentService.php b/backend/app/Services/PublishedContentService.php new file mode 100644 index 0000000..02f5deb --- /dev/null +++ b/backend/app/Services/PublishedContentService.php @@ -0,0 +1,52 @@ +$account,'gross_fils'=>$gross,'held_fils'=>$held,'available_fils'=>max(0,$gross-$held)]; + } + public static function placeWithdrawalHold(int $teacherId,int $amountFils,string $key):array { + if($amountFils<=0||$amountFils>100000000||!preg_match('/^[A-Za-z0-9._:-]{16,128}$/',$key))return ['http_status'=>400,'status'=>'error','message'=>'قيمة الحجز أو مفتاح الإعادة غير صالح.']; + $pdo=Database::getConnection();$pdo->beginTransaction();try { + $existing=Database::selectOne('SELECT uuid,amount_fils,status FROM teacher_withdrawal_holds WHERE teacher_id=? AND idempotency_key=? FOR UPDATE',[$teacherId,$key]); + if($existing){if((int)$existing['amount_fils']!==$amountFils)return self::finish($pdo,['http_status'=>409,'status'=>'idempotency_conflict','message'=>'استعمل المفتاح ذاته بمبلغ مختلف.']);return self::finish($pdo,['http_status'=>200,'status'=>$existing['status'],'withdrawal_hold_id'=>$existing['uuid'],'replayed'=>true]);} + $balance=self::balance($teacherId); + if($amountFils>$balance['available_fils'])return self::finish($pdo,['http_status'=>409,'status'=>'insufficient_available_balance','message'=>'الرصيد المتاح لا يغطي الحجز.','available_fils'=>$balance['available_fils']]); + $uuid=self::uuid();Database::insert("INSERT INTO teacher_withdrawal_holds (uuid,teacher_id,amount_fils,status,idempotency_key) VALUES (?,?,?,'held',?)",[$uuid,$teacherId,$amountFils,$key]); + return self::finish($pdo,['http_status'=>201,'status'=>'held','withdrawal_hold_id'=>$uuid,'amount_fils'=>$amountFils]); + }catch(\Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;} + } + public static function releaseHold(int $teacherId,string $holdUuid,string $reason):array { + if(!self::isUuid($holdUuid)||trim($reason)==='')return ['http_status'=>400,'status'=>'error','message'=>'بيانات إلغاء الحجز غير صالحة.']; + $changed=Database::execute("UPDATE teacher_withdrawal_holds SET status='released',released_at=NOW() WHERE uuid=? AND teacher_id=? AND status='held'",[$holdUuid,$teacherId]); + return $changed===1?['http_status'=>200,'status'=>'released','reason'=>$reason]:['http_status'=>409,'status'=>'hold_not_releasable','message'=>'الحجز غير موجود أو تمت تسويته.']; + } + private static function account(int $teacherId):int { + $existing=Database::selectOne("SELECT id FROM ledger_accounts WHERE code='teacher_available' AND owner_type='teacher' AND owner_id=? AND currency='JOD' LIMIT 1",[$teacherId]); + if($existing)return(int)$existing['id']; + Database::insert("INSERT INTO ledger_accounts (code,owner_type,owner_id,currency) VALUES ('teacher_available','teacher',?,'JOD')",[$teacherId]); + return(int)(Database::selectOne("SELECT id FROM ledger_accounts WHERE code='teacher_available' AND owner_type='teacher' AND owner_id=? AND currency='JOD' LIMIT 1",[$teacherId])['id']??0); + } + private static function isUuid(string $v):bool{return(bool)preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i',$v);} + private static function uuid():string{$b=random_bytes(16);$b[6]=chr((ord($b[6])&15)|64);$b[8]=chr((ord($b[8])&63)|128);return vsprintf('%s%s-%s-%s-%s-%s%s%s',str_split(bin2hex($b),4));} + private static function finish(\PDO $pdo,array $result):array{$pdo->commit();return $result;} +} diff --git a/backend/app/Services/TeacherRatingService.php b/backend/app/Services/TeacherRatingService.php index 5805682..5240c4d 100644 --- a/backend/app/Services/TeacherRatingService.php +++ b/backend/app/Services/TeacherRatingService.php @@ -15,8 +15,11 @@ class TeacherRatingService public static function ensureSchema(): void { + // Schema is installed by migrations. Runtime DDL previously included a + // DROP TABLE path and must never run during a student request. + return; + /* if (self::$schemaChecked) return; - try { // 1. Ensure teacher_reviews table exists with all columns Database::query(" @@ -75,6 +78,7 @@ class TeacherRatingService } catch (\Throwable $e) { error_log("TeacherRatingService schema note: " . $e->getMessage()); } + */ } /** @@ -210,8 +214,8 @@ class TeacherRatingService ); $totalReviews = count($reviews); - $weightedRating = 5.00; - $rawRating = 5.00; + $weightedRating = 0.00; + $rawRating = 0.00; if ($totalReviews > 0) { $sumWeighted = 0; @@ -226,7 +230,7 @@ class TeacherRatingService $sumRaw += $val; } - $weightedRating = $sumWeights > 0 ? round($sumWeighted / $sumWeights, 2) : 5.00; + $weightedRating = $sumWeights > 0 ? round($sumWeighted / $sumWeights, 2) : 0.00; $rawRating = round($sumRaw / $totalReviews, 2); } @@ -237,7 +241,9 @@ class TeacherRatingService $masteryScore = self::calculateMasteryImpact($teacherId); // 4. AI Socratic Engagement Index - $aiEngagementScore = 96.00; + // There is no verified AI engagement measurement yet. Keep the metric + // neutral until the reviewed video/watch pipeline supplies one. + $aiEngagementScore = 0.00; // 5. Composite Merit Calculation (The Fair Formula: 25% SLA + 25% AI + 25% Mastery + 25% Student Review) $reviewComponent = $weightedRating * 20.0; @@ -266,7 +272,11 @@ class TeacherRatingService } // Count enrolled students - $studentsCount = (int)(Database::selectOne("SELECT COUNT(*) as cnt FROM students")['cnt'] ?? 0); + $studentsCount = (int)(Database::selectOne( + "SELECT COUNT(DISTINCT lp.student_id) AS cnt FROM lesson_progress lp + JOIN lessons l ON l.id=lp.lesson_id JOIN courses c ON c.id=l.course_id + WHERE c.teacher_id=?", [$teacherId] + )['cnt'] ?? 0); // Upsert into teacher_performance_metrics Database::query( @@ -295,7 +305,7 @@ class TeacherRatingService $slaData['avg_response_minutes'], $slaData['response_rate_pct'], $slaData['active_queue_count'], - max(1, $studentsCount), + $studentsCount, $totalReviews, $rawRating, $weightedRating, @@ -364,20 +374,19 @@ class TeacherRatingService $totalReplies = (int)(Database::selectOne("SELECT COUNT(DISTINCT receiver_id) as cnt FROM chat_messages WHERE sender_id = ?", [$teacherId])['cnt'] ?? 0); // Calculate Base Response Rate - $responseRate = $totalInquiries > 0 ? min(100.0, round(($totalReplies / $totalInquiries) * 100.0, 1)) : 99.0; - if ($responseRate < 80.0) $responseRate = 95.0; + $responseRate = $totalInquiries > 0 ? min(100.0, round(($totalReplies / $totalInquiries) * 100.0, 1)) : 0.0; - $baseResponseMinutes = 3; + $baseResponseMinutes = 0; if ($isActivelySolving && $queueDepth > 0) { - $effectiveSlaScore = 99.0; + $effectiveSlaScore = 0.0; $effectiveMinutes = $baseResponseMinutes + min(2, (int)($queueDepth * 0.5)); } elseif ($queueDepth > 0) { $effectiveMinutes = $baseResponseMinutes + min(5, $queueDepth); - $effectiveSlaScore = max(90.0, 98.0 - ($queueDepth * 0.8)); + $effectiveSlaScore = 0.0; } else { $effectiveMinutes = $baseResponseMinutes; - $effectiveSlaScore = 99.5; + $effectiveSlaScore = 0.0; } return [ @@ -393,11 +402,11 @@ class TeacherRatingService } return [ - 'avg_response_minutes' => 3, - 'response_rate_pct' => 99.0, + 'avg_response_minutes' => 0, + 'response_rate_pct' => 0.0, 'active_queue_count' => 0, - 'is_active_solving' => true, - 'sla_score' => 98.0 + 'is_active_solving' => false, + 'sla_score' => 0.0 ]; } @@ -436,7 +445,7 @@ class TeacherRatingService FROM teachers t LEFT JOIN teacher_performance_metrics m ON t.id = m.teacher_id WHERE t.is_marketplace_public = 1 - ORDER BY " . ($sortBy === 'fastest' ? "COALESCE(m.avg_response_minutes, 10) ASC" : "COALESCE(m.composite_merit_score, 90.00) DESC") + ORDER BY " . ($sortBy === 'fastest' ? "CASE WHEN m.avg_response_minutes IS NULL OR m.avg_response_minutes = 0 THEN 1 ELSE 0 END, m.avg_response_minutes ASC" : "COALESCE(m.total_reviews_count, 0) DESC, COALESCE(m.weighted_student_rating, 0) DESC") ); foreach ($teachers as &$t) { diff --git a/backend/app/Services/TeacherSubmissionService.php b/backend/app/Services/TeacherSubmissionService.php new file mode 100644 index 0000000..0904686 --- /dev/null +++ b/backend/app/Services/TeacherSubmissionService.php @@ -0,0 +1,48 @@ +beginTransaction(); + try { + $previous = Database::selectOne('SELECT request_sha256,response_json,http_status FROM submission_idempotency_keys WHERE teacher_id=? AND operation=? AND idempotency_key=? FOR UPDATE', [$teacherId,$operation,$key]); + if ($previous) { + if (!hash_equals((string)$previous['request_sha256'], $hash)) { $pdo->rollBack(); return self::result(409,'idempotency_conflict','استُخدم مفتاح الإعادة مع طلب مختلف.'); } + $pdo->commit(); return array_merge(json_decode((string)$previous['response_json'], true) ?: [], ['http_status'=>(int)$previous['http_status'],'replayed'=>true]); + } + $lesson = Database::selectOne("SELECT id,uuid,title FROM curriculum_lessons WHERE uuid=? AND source_status='approved' LIMIT 1 FOR UPDATE", [$lessonUuid]); + if (!$lesson) return self::commit($pdo,$teacherId,$operation,$key,$hash,self::result(404,'error','الدرس المنهجي غير معتمد أو غير موجود.')); + $submission = Database::selectOne('SELECT * FROM teacher_submissions WHERE teacher_id=? AND curriculum_lesson_id=? FOR UPDATE',[$teacherId,$lesson['id']]); + if ($submission && !$replacement) return self::commit($pdo,$teacherId,$operation,$key,$hash,['http_status'=>409,'status'=>'existing_submission','message'=>'لديك حصة مسجلة لهذا الدرس. استخدم الاستبدال لإنشاء نسخة مرشحة.','submission_id'=>$submission['uuid'],'current_published_video_version_id'=>$submission['current_published_video_version_id'] ?: null]); + if (!$submission && $replacement) return self::commit($pdo,$teacherId,$operation,$key,$hash,self::result(409,'replacement_target_missing','لا توجد حصة حالية لاستبدالها.')); + if ($submissionUuid && (!$submission || !hash_equals((string)$submission['uuid'],$submissionUuid))) return self::commit($pdo,$teacherId,$operation,$key,$hash,self::result(409,'replacement_target_mismatch','هدف الاستبدال لا يطابق حصة المعلم الحالية.')); + if (!$submission) { Database::insert("INSERT INTO teacher_submissions (uuid,teacher_id,curriculum_lesson_id,status) VALUES (?,?,?,'draft')",[self::uuid(),$teacherId,$lesson['id']]); $submission=Database::selectOne('SELECT * FROM teacher_submissions WHERE teacher_id=? AND curriculum_lesson_id=? FOR UPDATE',[$teacherId,$lesson['id']]); } + $next=(int)(Database::selectOne('SELECT COALESCE(MAX(version_number),0) n FROM video_versions WHERE teacher_submission_id=? FOR UPDATE',[$submission['id']])['n'] ?? 0)+1; + Database::insert("INSERT INTO video_versions (uuid,teacher_submission_id,version_number,status,replaces_video_version_id) VALUES (?,?,?,'draft',?)",[self::uuid(),$submission['id'],$next,$submission['current_published_video_version_id'] ?: null]); + $version=Database::selectOne('SELECT uuid FROM video_versions WHERE teacher_submission_id=? AND version_number=?',[$submission['id'],$next]); + return self::commit($pdo,$teacherId,$operation,$key,$hash,['http_status'=>201,'status'=>'ready_for_upload','submission_id'=>$submission['uuid'],'video_version_id'=>$version['uuid'],'version_number'=>$next,'replacement'=>$replacement,'lesson'=>['id'=>$lesson['uuid'],'title'=>$lesson['title']]]); + } catch (\Throwable $e) { if($pdo->inTransaction())$pdo->rollBack(); throw $e; } + } + public static function attachUploadedLesson(int $teacherId,string $versionUuid,int $lessonId,string $sha): void { + if(!self::uuidValid($versionUuid)||!preg_match('/^[0-9a-f]{64}$/i',$sha)) throw new \RuntimeException('هوية النسخة أو بصمة الملف غير صالحة.'); + $pdo=Database::getConnection();$pdo->beginTransaction();try { $v=Database::selectOne("SELECT vv.id,vv.status,ts.id submission_id FROM video_versions vv JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id WHERE vv.uuid=? AND ts.teacher_id=? FOR UPDATE",[$versionUuid,$teacherId]); if(!$v||!in_array($v['status'],['draft','uploading'],true)) throw new \RuntimeException('نسخة الفيديو غير متاحة لهذا الرفع.'); Database::query("UPDATE video_versions SET source_lesson_id=?,content_sha256=?,status='review' WHERE id=?",[$lessonId,$sha,$v['id']]);Database::query("UPDATE teacher_submissions SET status='review' WHERE id=?",[$v['submission_id']]);$pdo->commit(); VideoReviewService::queueForVersion((int)$v['id']); } catch(\Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;} + } + public static function reserveUpload(int $teacherId, string $versionUuid): bool { + if (!self::uuidValid($versionUuid)) return false; + return Database::execute( + "UPDATE video_versions vv JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id + SET vv.status='uploading', ts.status='uploading' + WHERE vv.uuid=? AND ts.teacher_id=? AND vv.status='draft'", + [$versionUuid, $teacherId] + ) === 1; + } + private static function commit(\PDO $pdo,int $teacher,string $op,string $key,string $hash,array $r):array {Database::insert('INSERT INTO submission_idempotency_keys (teacher_id,operation,idempotency_key,request_sha256,response_json,http_status) VALUES (?,?,?,?,?,?)',[$teacher,$op,$key,$hash,json_encode($r,JSON_UNESCAPED_UNICODE),$r['http_status']]);$pdo->commit();return $r;} + private static function result(int $status,string $code,string $message):array{return ['http_status'=>$status,'status'=>$code,'message'=>$message];} + private static function uuidValid(string $v):bool{return(bool)preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i',$v);} + private static function uuid():string{$b=random_bytes(16);$b[6]=chr((ord($b[6])&15)|64);$b[8]=chr((ord($b[8])&63)|128);return vsprintf('%s%s-%s-%s-%s-%s%s%s',str_split(bin2hex($b),4));} +} diff --git a/backend/app/Services/VideoReviewService.php b/backend/app/Services/VideoReviewService.php new file mode 100644 index 0000000..2c4e0b5 --- /dev/null +++ b/backend/app/Services/VideoReviewService.php @@ -0,0 +1,159 @@ +400,'status'=>'error','message'=>'قرار المراجعة غير صالح.']; + } + if (empty($report['bindings']) || empty($report['coverage'])) { + return ['http_status'=>422,'status'=>'incomplete_report','message'=>'التقرير لا يحتوي bindings وتغطية الأدلة المطلوبة.']; + } + $pdo=Database::getConnection(); $pdo->beginTransaction(); + try { + $job=Database::selectOne('SELECT j.id,j.status,e.id AS evidence_id FROM video_review_jobs j LEFT JOIN video_review_evidence e ON e.video_review_job_id=j.id WHERE j.uuid=? FOR UPDATE',[$jobUuid]); + if(!$job) return self::finish($pdo,['http_status'=>404,'status'=>'error','message'=>'مهمة الفحص غير موجودة.']); + if(($job['status'] ?? '') !== 'ready_for_human_review') return self::finish($pdo,['http_status'=>409,'status'=>'job_not_reviewable','message'=>'حالة المهمة لا تسمح بقرار جديد.']); + if (empty($job['evidence_id'])) return self::finish($pdo,['http_status'=>422,'status'=>'missing_evidence','message'=>'لا يمكن اعتماد الفحص من دون أدلة فيديو محفوظة.']); + $approved=$decision==='approved' && $recommendation==='ready_for_human_review'; + Database::query("INSERT INTO video_review_reports (video_review_job_id,recommendation,report_json,reviewer_id,human_decision,reviewed_at) VALUES (?,?,?,?,?,NOW()) ON DUPLICATE KEY UPDATE recommendation=VALUES(recommendation),report_json=VALUES(report_json),reviewer_id=VALUES(reviewer_id),human_decision=VALUES(human_decision),reviewed_at=NOW()",[$job['id'],$recommendation,json_encode($report,JSON_UNESCAPED_UNICODE),$reviewerId,$decision]); + Database::query('UPDATE video_review_jobs SET status=?, completed_at=NOW() WHERE id=?',[$approved?'approved':'rejected',$job['id']]); + return self::finish($pdo,['http_status'=>200,'status'=>$approved?'approved':'rejected']); + } catch(\Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;} + } + /** Attach reviewed, version-bound evidence before human review. */ + public static function submitEvidence(string $jobUuid, int $reviewerId, string $transcriptAssetUuid, string $visualAssetUuid, array $coverage): array + { + if (!self::isUuid($jobUuid) || !self::isUuid($transcriptAssetUuid) || !self::isUuid($visualAssetUuid)) { + return ['http_status'=>400,'status'=>'error','message'=>'هويات أدلة الفحص غير صالحة.']; + } + if (empty($coverage['segments']) || !is_array($coverage['segments'])) { + return ['http_status'=>422,'status'=>'incomplete_evidence','message'=>'يلزم سجل تغطية زمني للفيديو.']; + } + $pdo=Database::getConnection(); $pdo->beginTransaction(); + try { + $job=Database::selectOne('SELECT id,video_sha256,status FROM video_review_jobs WHERE uuid=? FOR UPDATE',[$jobUuid]); + if (!$job) return self::finish($pdo,['http_status'=>404,'status'=>'error','message'=>'مهمة الفحص غير موجودة.']); + if (!in_array($job['status'],['queued','collecting_evidence','needs_evidence'],true)) return self::finish($pdo,['http_status'=>409,'status'=>'job_not_collecting','message'=>'لا تقبل المهمة أدلة جديدة في حالتها الحالية.']); + $transcript=Database::selectOne("SELECT id,sha256,asset_type,review_status,source_reference FROM content_assets WHERE uuid=? FOR UPDATE",[$transcriptAssetUuid]); + $visual=Database::selectOne("SELECT id,sha256,asset_type,review_status,source_reference FROM content_assets WHERE uuid=? FOR UPDATE",[$visualAssetUuid]); + if (!$transcript || !$visual || $transcript['asset_type'] !== 'transcript' || $transcript['review_status'] !== 'approved' || $visual['review_status'] !== 'approved') { + return self::finish($pdo,['http_status'=>422,'status'=>'untrusted_evidence','message'=>'يلزم تفريغ زمني ودليل بصري معتمدان.']); + } + $segments=$coverage['segments']; $lastEnd=0; + foreach ($segments as $segment) { + if (!is_array($segment) || !isset($segment['start_seconds'],$segment['end_seconds'],$segment['transcript_ref'],$segment['visual_ref']) || !is_numeric($segment['start_seconds']) || !is_numeric($segment['end_seconds']) || (float)$segment['start_seconds'] < $lastEnd || (float)$segment['end_seconds'] <= (float)$segment['start_seconds']) { + return self::finish($pdo,['http_status'=>422,'status'=>'invalid_coverage','message'=>'تغطية الفيديو يجب أن تكون متصلة ومربوطة بالتفريغ والدليل البصري.']); + } + $lastEnd=(float)$segment['end_seconds']; + } + Database::query('INSERT INTO video_review_evidence (video_review_job_id,transcript_asset_id,visual_evidence_asset_id,coverage_json,submitted_by) VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE transcript_asset_id=VALUES(transcript_asset_id),visual_evidence_asset_id=VALUES(visual_evidence_asset_id),coverage_json=VALUES(coverage_json),submitted_by=VALUES(submitted_by),created_at=NOW()',[$job['id'],$transcript['id'],$visual['id'],json_encode($coverage,JSON_UNESCAPED_UNICODE),$reviewerId]); + Database::query("UPDATE video_review_jobs SET status='ready_for_human_review' WHERE id=?",[$job['id']]); + return self::finish($pdo,['http_status'=>200,'status'=>'ready_for_human_review']); + } catch(\Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;} + } + + public static function publishApproved(string $jobUuid, ?string $expectedCurrentVersionUuid): array + { + $pdo = Database::getConnection(); + $pdo->beginTransaction(); + try { + $job = Database::selectOne( + "SELECT j.id, j.status, j.video_version_id, vv.uuid AS version_uuid, + vv.source_lesson_id, ts.id AS submission_id, + currentv.uuid AS current_version_uuid + FROM video_review_jobs j + JOIN video_versions vv ON vv.id = j.video_version_id + JOIN teacher_submissions ts ON ts.id = vv.teacher_submission_id + LEFT JOIN video_versions currentv ON currentv.id = ts.current_published_video_version_id + WHERE j.uuid = ? FOR UPDATE", + [$jobUuid] + ); + if (!$job) return self::finish($pdo, ['status' => 'error', 'http_status' => 404, 'message' => 'مهمة الفحص غير موجودة.']); + $report = Database::selectOne('SELECT human_decision FROM video_review_reports WHERE video_review_job_id = ? FOR UPDATE', [$job['id']]); + if (($job['status'] ?? '') !== 'approved' || ($report['human_decision'] ?? '') !== 'approved') { + return self::finish($pdo, ['status' => 'review_not_approved', 'http_status' => 409, 'message' => 'لا يمكن النشر قبل تقرير مكتمل وموافقة مراجع بشري.']); + } + if ($expectedCurrentVersionUuid !== null && !hash_equals($expectedCurrentVersionUuid, (string)($job['current_version_uuid'] ?? ''))) { + return self::finish($pdo, ['status' => 'current_version_conflict', 'http_status' => 409, 'message' => 'تغيرت النسخة المنشورة منذ فتح المراجعة.']); + } + if (empty($job['source_lesson_id'])) return self::finish($pdo, ['status' => 'storage_not_ready', 'http_status' => 409, 'message' => 'الفيديو غير مرتبط بتخزين جاهز.']); + Database::query("UPDATE video_versions SET status = 'superseded' WHERE teacher_submission_id = ? AND status = 'published'", [$job['submission_id']]); + Database::query("UPDATE video_versions SET status = 'published', published_at = NOW() WHERE id = ?", [$job['video_version_id']]); + Database::query("UPDATE teacher_submissions SET status = 'published', current_published_video_version_id = ? WHERE id = ?", [$job['video_version_id'], $job['submission_id']]); + $pdo->commit(); + return ['status' => 'published', 'http_status' => 200, 'video_version_id' => $job['version_uuid']]; + } catch (\Throwable $e) { + if ($pdo->inTransaction()) $pdo->rollBack(); + throw $e; + } + } + + public static function queueForVersion(int $versionId): array + { + $existing = Database::selectOne( + "SELECT uuid, status FROM video_review_jobs + WHERE video_version_id = ? AND status NOT IN ('failed', 'superseded') + ORDER BY id DESC LIMIT 1", + [$versionId] + ); + if ($existing) return ['job_id' => $existing['uuid'], 'status' => $existing['status'], 'reused' => true]; + + $binding = Database::selectOne( + "SELECT vv.content_sha256, ts.curriculum_lesson_id, cl.uuid AS lesson_uuid, + cl.curriculum_version, a.uuid AS asset_uuid, a.sha256 AS markdown_sha256 + FROM video_versions vv + JOIN teacher_submissions ts ON ts.id = vv.teacher_submission_id + JOIN curriculum_lessons cl ON cl.id = ts.curriculum_lesson_id + LEFT JOIN publication_bundles pb ON pb.curriculum_lesson_id = cl.id AND pb.status = 'published' + LEFT JOIN publication_bundle_assets pba ON pba.publication_bundle_id = pb.id AND pba.role = 'primary_lesson' + LEFT JOIN content_assets a ON a.id = pba.content_asset_id AND a.review_status = 'approved' + WHERE vv.id = ? LIMIT 1", + [$versionId] + ); + if (!$binding) throw new \RuntimeException('نسخة الفيديو غير مرتبطة بدرس منهجي.'); + + $missing = ['timestamped_transcript', 'visual_evidence', 'coverage_manifest']; + if (empty($binding['asset_uuid'])) $missing[] = 'approved_markdown_asset'; + $jobUuid = self::uuid(); + $manifest = [ + 'curriculum_lesson_id' => $binding['lesson_uuid'], + 'curriculum_version' => $binding['curriculum_version'], + 'markdown_asset_id' => $binding['asset_uuid'] ?: null, + 'missing_inputs' => $missing, + ]; + Database::insert( + "INSERT INTO video_review_jobs + (uuid, video_version_id, curriculum_lesson_id, prompt_version, schema_version, video_sha256, markdown_sha256, status, input_manifest_json) + VALUES (?, ?, ?, 'video-review-v1', 'video-review-schema-v1', ?, ?, 'needs_evidence', ?)", + [$jobUuid, $versionId, $binding['curriculum_lesson_id'], $binding['content_sha256'] ?: str_repeat('0', 64), $binding['markdown_sha256'] ?: str_repeat('0', 64), json_encode($manifest, JSON_UNESCAPED_UNICODE)] + ); + return ['job_id' => $jobUuid, 'status' => 'needs_evidence', 'missing_inputs' => $missing]; + } + + private static function isUuid(string $value): bool + { + return (bool)preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $value); + } + + private static function uuid(): string + { + $bytes = random_bytes(16); + $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40); + $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4)); + } + + private static function finish(\PDO $pdo, array $result): array + { + $pdo->commit(); + return $result; + } +} diff --git a/backend/app/Services/VideoService.php b/backend/app/Services/VideoService.php index a310048..1b9f972 100644 --- a/backend/app/Services/VideoService.php +++ b/backend/app/Services/VideoService.php @@ -21,10 +21,13 @@ class VideoService */ public static function ensureSchema(): void { + // Schema is installed by backend/scripts/run_migrations.php. + // Legacy request-time DDL remains below only as historical reference. + return; + /* if (self::$schemaChecked) { return; } - try { // Check if storage_type column exists $cols = Database::select("SHOW COLUMNS FROM lessons LIKE 'storage_type'"); @@ -91,6 +94,7 @@ class VideoService } catch (\Throwable $e) { error_log("VideoService schema ensure note: " . $e->getMessage()); } + */ } /** diff --git a/backend/app/Services/WatchSessionService.php b/backend/app/Services/WatchSessionService.php new file mode 100644 index 0000000..404150d --- /dev/null +++ b/backend/app/Services/WatchSessionService.php @@ -0,0 +1,58 @@ +beginTransaction(); + try { + $active=Database::selectOne("SELECT uuid FROM watch_sessions WHERE student_id=? AND video_version_id=? AND status='active' FOR UPDATE",[$studentId,$row['version_id']]); + if ($active) { $pdo->commit(); return ['http_status'=>200,'status'=>'active','watch_session_id'=>$active['uuid'],'reused'=>true]; } + $uuid=self::uuid(); + Database::insert("INSERT INTO watch_sessions (uuid,student_id,video_version_id,funding_source,status) VALUES (?,?,?,?, 'active')",[$uuid,$studentId,$row['version_id'],$row['funding_source']]); + $id=(int)(Database::selectOne('SELECT id FROM watch_sessions WHERE uuid=?',[$uuid])['id'] ?? 0); + Database::insert("INSERT INTO watch_events (watch_session_id,sequence_no,event_type,client_position_ms,payload_json) VALUES (?,1,'start',0,?)",[$id,json_encode(['server_contract'=>'watch-v1'])]); + $pdo->commit(); return ['http_status'=>201,'status'=>'started','watch_session_id'=>$uuid]; + } catch(\Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;} + } + public static function event(int $studentId,string $sessionUuid,int $sequence,string $type,int $positionMs,array $payload=[]):array { + if(!self::isUuid($sessionUuid)||$sequence<2||!in_array($type,['heartbeat','pause','seek','resume','end','buffer'],true)||$positionMs<0) return ['http_status'=>400,'status'=>'error','message'=>'حدث المشاهدة غير صالح.']; + $pdo=Database::getConnection();$pdo->beginTransaction();try { + $session=Database::selectOne("SELECT ws.id,ws.status,ws.video_version_id,ws.funding_source FROM watch_sessions ws WHERE ws.uuid=? AND ws.student_id=? FOR UPDATE",[$sessionUuid,$studentId]); + if(!$session)return self::finish($pdo,['http_status'=>404,'status'=>'error','message'=>'جلسة المشاهدة غير موجودة.']); + if($session['status']!=='active')return self::finish($pdo,['http_status'=>409,'status'=>'ended','message'=>'انتهت جلسة المشاهدة.']); + $last=Database::selectOne('SELECT sequence_no,event_type,client_position_ms,server_received_at FROM watch_events WHERE watch_session_id=? ORDER BY sequence_no DESC LIMIT 1 FOR UPDATE',[$session['id']]); + if((int)$last['sequence_no'] >= $sequence) return self::finish($pdo,['http_status'=>200,'status'=>'replayed']); + if((int)$last['sequence_no']+1 !== $sequence) return self::finish($pdo,['http_status'=>409,'status'=>'sequence_gap','message'=>'تسلسل أحداث المشاهدة غير متصل.']); + Database::insert('INSERT INTO watch_events (watch_session_id,sequence_no,event_type,client_position_ms,payload_json) VALUES (?,?,?,?,?)',[$session['id'],$sequence,$type,$positionMs,json_encode($payload,JSON_UNESCAPED_UNICODE)]); + $eligible=0; + if($type==='heartbeat' && in_array($last['event_type'],['start','heartbeat','resume'],true)) { + $clientDelta=$positionMs-(int)$last['client_position_ms']; + $serverDelta=(int)(Database::selectOne('SELECT TIMESTAMPDIFF(SECOND, ?, NOW()) AS s',[$last['server_received_at']])['s'] ?? 0); + // A contiguous interval is bounded by elapsed server time and 60s; + // seeks, background bursts, and client-only minutes earn nothing. + if($clientDelta>=1000 && $clientDelta<=90000 && $serverDelta>=1 && $serverDelta<=90) { + $eligible=min((int)floor($clientDelta/1000),(int)$serverDelta,60); + if($eligible>0) Database::insert("INSERT INTO eligible_watch_intervals (watch_session_id,start_ms,end_ms,eligible_seconds,reason_code,policy_version,status) VALUES (?,?,?,?,?,'watch-v1','measured')",[$session['id'],(int)$last['client_position_ms'],$positionMs,$eligible,'contiguous_heartbeat']); + } + } + if($type==='end'){Database::query("UPDATE watch_sessions SET status='ended',server_ended_at=NOW() WHERE id=?",[$session['id']]);} + return self::finish($pdo,['http_status'=>200,'status'=>$type==='end'?'ended':'recorded','eligible_seconds_added'=>$eligible]); + }catch(\Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;} + } + private static function authorisedVersion(int $studentId,string $uuid,?string $national):array { + if(!self::isUuid($uuid))return ['http_status'=>400,'status'=>'error','message'=>'هوية نسخة الفيديو غير صالحة.']; + $row=Database::selectOne("SELECT vv.id,l.id lesson_id,l.course_id,c.grade_level FROM video_versions vv JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id AND ts.current_published_video_version_id=vv.id AND ts.status='published' JOIN lessons l ON l.id=vv.source_lesson_id AND l.encoding_status='ready' JOIN courses c ON c.id=l.course_id WHERE vv.uuid=? AND vv.status='published' LIMIT 1",[$uuid]); + if(!$row)return ['http_status'=>404,'status'=>'error','message'=>'نسخة الفيديو غير منشورة.']; + $access=StudentAccessControlService::validateLessonAccess($studentId,$national,StudentAccessControlService::normalizeGrade($row['grade_level']??'grade_10'),(int)$row['course_id'],(int)$row['lesson_id'],false); + if(empty($access['allowed']))return ['http_status'=>403,'status'=>'forbidden','message'=>$access['message']??'غير مصرح بالمشاهدة.']; + return ['allowed'=>true,'version_id'=>(int)$row['id'],'funding_source'=>!empty($access['is_sponsored'])?'school':(($access['reason']??'')==='paid_pass_active'?'marketplace':'none')]; + } + private static function isUuid(string $v):bool{return(bool)preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i',$v);} + private static function uuid():string{$b=random_bytes(16);$b[6]=chr((ord($b[6])&15)|64);$b[8]=chr((ord($b[8])&63)|128);return vsprintf('%s%s-%s-%s-%s-%s%s%s',str_split(bin2hex($b),4));} + private static function finish(\PDO $pdo,array $r):array{$pdo->commit();return $r;} +} diff --git a/backend/app/Views/GuardianPortal.php b/backend/app/Views/GuardianPortal.php index 1dc419d..1ffbb71 100644 --- a/backend/app/Views/GuardianPortal.php +++ b/backend/app/Views/GuardianPortal.php @@ -114,15 +114,13 @@ class GuardianPortal
متابعة التحصيل الأكاديمي والجاهزية الوزارية 📊 -

الابن: أحمد (الفرع العلمي)

-

مدرسة الثقافة العسكرية المعتمدة • الرقم الوطني: 2008XXXX12

+

لا توجد بيانات طالب مرتبطة بعد

+

اربط حساب الطالب المصرح به لعرض بياناته الفعلية.

- - - + لا يوجد طلاب مرتبطون بالحساب
@@ -130,8 +128,8 @@ class GuardianPortal
مؤشر الجاهزية للوزاري (Socratic Readiness) -
94.2%
- ✓ مستوى متقدم جداً ومؤهل لعلامة كاملة +
غير متاح
+ تظهر القراءة بعد وصول بيانات الطالب الفعلية.
diff --git a/backend/app/Views/StudentPortal.php b/backend/app/Views/StudentPortal.php index c7bf62b..a5618d7 100644 --- a/backend/app/Views/StudentPortal.php +++ b/backend/app/Views/StudentPortal.php @@ -727,8 +727,8 @@ class StudentPortal
مؤشر الجدارة الكلي - % - ★ + +
سرعة الرد (Workerman) @@ -2501,8 +2501,8 @@ class StudentPortal
مؤشر الجدارة الكلي - ${parseFloat(t.composite_merit_score || 96.5).toFixed(1)}% - ★ ${parseFloat(t.star_equivalent || 4.9).toFixed(1)} + ${t.composite_merit_score == null ? 'غير متاح' : `${parseFloat(t.composite_merit_score).toFixed(1)}%`} + ${t.star_equivalent == null ? 'لا توجد تقييمات موثقة' : `★ ${parseFloat(t.star_equivalent).toFixed(1)}`}
سرعة الرد (Workerman) diff --git a/backend/app/Views/TeacherPortal.php b/backend/app/Views/TeacherPortal.php index 301d984..1d8ff19 100644 --- a/backend/app/Views/TeacherPortal.php +++ b/backend/app/Views/TeacherPortal.php @@ -418,15 +418,15 @@ class TeacherPortal
الكورسات المنشورة - +
الطلاب المتفاعلون - +
متوسط إتقان الطلاب - 94.6% + غير متاح
@@ -494,25 +494,25 @@ class TeacherPortal
مؤشر الجدارة الكلي المركب - 96.8% - معلم نخبوي معتمد 💎 + غير متاح + قيد بناء سجل التقييم
سرعة الاستجابة (Workerman SLA) - 3 دقائق ⚡ - نسبة التجاوب: 99.2% + غير متاح + نسبة التجاوب: غير متاحة
تقييم الطلاب المعاير والموزون - ★ 4.90 / 5.0 + لا توجد تقييمات موثقة بناءً على التقييمات الموثقة
معدل رفع إتقان الطلاب (Mastery) - +94.6% + غير متاح في الكويزات السقراطية والامتحانات
@@ -1517,13 +1517,13 @@ class TeacherPortal const data = await res.json(); if (res.ok && data.status === 'success' && data.data) { const m = data.data; - document.getElementById('rep_merit_score').textContent = `${parseFloat(m.composite_merit_score || 96.8).toFixed(1)}%`; - document.getElementById('rep_tier_badge').textContent = m.reputation_tier || 'معلم نخبوي معتمد 💎'; - document.getElementById('rep_response_speed').textContent = `${parseInt(m.avg_response_minutes || 3)} دقائق ⚡`; - document.getElementById('rep_response_rate').textContent = `نسبة التجاوب: ${parseFloat(m.response_rate_percentage || 99.2).toFixed(1)}%`; - document.getElementById('rep_weighted_rating').textContent = `★ ${parseFloat(m.star_equivalent || 4.9).toFixed(2)} / 5.0`; + document.getElementById('rep_merit_score').textContent = m.composite_merit_score == null ? 'غير متاح' : `${parseFloat(m.composite_merit_score).toFixed(1)}%`; + document.getElementById('rep_tier_badge').textContent = m.reputation_tier || 'قيد بناء سجل التقييم'; + document.getElementById('rep_response_speed').textContent = m.avg_response_minutes == null ? 'غير متاح' : `${parseInt(m.avg_response_minutes)} دقائق ⚡`; + document.getElementById('rep_response_rate').textContent = m.response_rate_percentage == null ? 'نسبة التجاوب: غير متاحة' : `نسبة التجاوب: ${parseFloat(m.response_rate_percentage).toFixed(1)}%`; + document.getElementById('rep_weighted_rating').textContent = m.star_equivalent == null ? 'لا توجد تقييمات موثقة' : `★ ${parseFloat(m.star_equivalent).toFixed(2)} / 5.0`; document.getElementById('rep_reviews_count').textContent = `بناءً على ${m.total_reviews_count || 0} تقييم موثق`; - document.getElementById('rep_mastery_gain').textContent = `+${parseFloat(m.mastery_impact_score || 94.6).toFixed(1)}%`; + document.getElementById('rep_mastery_gain').textContent = m.mastery_impact_score == null ? 'غير متاح' : `+${parseFloat(m.mastery_impact_score).toFixed(1)}%`; } } catch (e) { console.error('Load rep data notice:', e); diff --git a/backend/cron/run_scheduled_tasks.php b/backend/cron/run_scheduled_tasks.php index ebb2610..dddbeac 100644 --- a/backend/cron/run_scheduled_tasks.php +++ b/backend/cron/run_scheduled_tasks.php @@ -99,31 +99,8 @@ if ($task === 'all' || $task === 'grades') { // 3. تسوية ومطابقة مدفوعات كليك المعلقة (Reconcile Pending CliQ Slips) // ───────────────────────────────────────────────────────────────────────────── if ($task === 'all' || $task === 'reconcile') { - echo "\n[3/5] Reconciling Pending CliQ Slips (Timeouts > 24 Hours)...\n"; - try { - CliqPaymentService::ensureSchema(); - $staleCount = (int)Database::selectOne( - "SELECT COUNT(*) as cnt FROM cliq_payments - WHERE verification_status = 'pending' - AND created_at < DATE_SUB(NOW(), INTERVAL 24 HOUR)" - )['cnt']; - - if (!$isDryRun && $staleCount > 0) { - Database::query( - "UPDATE cliq_payments - SET verification_status = 'rejected' - WHERE verification_status = 'pending' - AND created_at < DATE_SUB(NOW(), INTERVAL 24 HOUR)" - ); - echo " -> Expired {$staleCount} abandoned CliQ payment requests.\n"; - } else { - echo " -> Abandoned pending requests found: {$staleCount}.\n"; - } - $results['reconcile'] = "Expired: {$staleCount}"; - } catch (\Throwable $e) { - echo " -> ERROR: " . $e->getMessage() . "\n"; - $results['reconcile'] = 'Error: ' . $e->getMessage(); - } + echo "\n[3/5] CliQ reconciliation is disabled until provider integration is verified.\n"; + $results['reconcile'] = 'Unavailable: provider-backed reconciliation is not configured'; } // ───────────────────────────────────────────────────────────────────────────── diff --git a/backend/database_schema.sql b/backend/database_schema.sql index c8016aa..13c73f3 100644 --- a/backend/database_schema.sql +++ b/backend/database_schema.sql @@ -647,16 +647,16 @@ CREATE TABLE IF NOT EXISTS `teacher_reviews` ( `student_id` BIGINT UNSIGNED NOT NULL, `course_id` BIGINT UNSIGNED DEFAULT NULL, `lesson_id` BIGINT UNSIGNED DEFAULT NULL, - `rating_overall` DECIMAL(3, 2) NOT NULL DEFAULT 5.00, - `rating_clarity` INT NOT NULL DEFAULT 5, - `rating_response_speed` INT NOT NULL DEFAULT 5, - `rating_socratic_interaction` INT NOT NULL DEFAULT 5, + `rating_overall` DECIMAL(3, 2) NOT NULL, + `rating_clarity` INT NOT NULL, + `rating_response_speed` INT NOT NULL, + `rating_socratic_interaction` INT NOT NULL, `review_text` TEXT DEFAULT NULL, `review_weight` DECIMAL(4, 3) NOT NULL DEFAULT 1.000, - `student_watch_percentage` DECIMAL(5, 2) NOT NULL DEFAULT 100.00, - `socratic_accuracy_rate` DECIMAL(5, 2) NOT NULL DEFAULT 100.00, + `student_watch_percentage` DECIMAL(5, 2) NOT NULL DEFAULT 0.00, + `socratic_accuracy_rate` DECIMAL(5, 2) NOT NULL DEFAULT 0.00, `is_flagged_anomaly` TINYINT(1) NOT NULL DEFAULT 0, - `is_verified` TINYINT(1) NOT NULL DEFAULT 1, + `is_verified` TINYINT(1) NOT NULL DEFAULT 0, `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_tr_teacher` (`teacher_id`), @@ -670,19 +670,19 @@ CREATE TABLE IF NOT EXISTS `teacher_reviews` ( -- ------------------------------------------------------------------------------ CREATE TABLE IF NOT EXISTS `teacher_performance_metrics` ( `teacher_id` BIGINT UNSIGNED NOT NULL, - `avg_response_minutes` INT NOT NULL DEFAULT 4, - `response_rate_percentage` DECIMAL(5, 2) NOT NULL DEFAULT 98.50, + `avg_response_minutes` INT NULL DEFAULT NULL, + `response_rate_percentage` DECIMAL(5, 2) NULL DEFAULT NULL, `active_queue_count` INT UNSIGNED NOT NULL DEFAULT 0, `total_students_enrolled` INT UNSIGNED NOT NULL DEFAULT 0, `total_reviews_count` INT UNSIGNED NOT NULL DEFAULT 0, - `raw_avg_rating` DECIMAL(3, 2) NOT NULL DEFAULT 5.00, - `weighted_student_rating` DECIMAL(3, 2) NOT NULL DEFAULT 5.00, - `ai_engagement_score` DECIMAL(5, 2) NOT NULL DEFAULT 96.00, - `sla_speed_score` DECIMAL(5, 2) NOT NULL DEFAULT 98.00, - `mastery_impact_score` DECIMAL(5, 2) NOT NULL DEFAULT 94.00, - `composite_merit_score` DECIMAL(5, 2) NOT NULL DEFAULT 96.50, - `star_equivalent` DECIMAL(3, 2) NOT NULL DEFAULT 4.90, - `reputation_tier` VARCHAR(100) NOT NULL DEFAULT 'معلم نخبوي معتمد 💎', + `raw_avg_rating` DECIMAL(3, 2) NULL DEFAULT NULL, + `weighted_student_rating` DECIMAL(3, 2) NULL DEFAULT NULL, + `ai_engagement_score` DECIMAL(5, 2) NULL DEFAULT NULL, + `sla_speed_score` DECIMAL(5, 2) NULL DEFAULT NULL, + `mastery_impact_score` DECIMAL(5, 2) NULL DEFAULT NULL, + `composite_merit_score` DECIMAL(5, 2) NULL DEFAULT NULL, + `star_equivalent` DECIMAL(3, 2) NULL DEFAULT NULL, + `reputation_tier` VARCHAR(100) NULL DEFAULT NULL, `last_calculated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`teacher_id`), CONSTRAINT `fk_tpm_teacher` FOREIGN KEY (`teacher_id`) REFERENCES `teachers` (`id`) ON DELETE CASCADE @@ -747,3 +747,11 @@ CREATE TABLE IF NOT EXISTS `otp_verifications` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; SET FOREIGN_KEY_CHECKS = 1; + +-- NEXT-PHASE SCHEMA NOTICE +-- The authoritative additive DDL for curriculum identity, submissions, review, +-- ledger, watch sessions, revenue, English packages, and remediation lives in +-- backend/migrations/20260909_*.sql. Apply it with +-- `php backend/scripts/run_migrations.php`; do not copy those statements here, +-- because duplicated DDL causes the fresh-schema file and production migrations +-- to drift apart. diff --git a/backend/migrations/20260909_curriculum_publication_identity.sql b/backend/migrations/20260909_curriculum_publication_identity.sql new file mode 100644 index 0000000..467afaa --- /dev/null +++ b/backend/migrations/20260909_curriculum_publication_identity.sql @@ -0,0 +1,74 @@ +-- Curriculum publication identity. Apply after taking a schema backup. +-- This migration is additive: it does not rewrite `lessons` or local assets. + +CREATE TABLE IF NOT EXISTS `curriculum_lessons` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `uuid` CHAR(36) NOT NULL, + `grade_key` VARCHAR(64) NOT NULL, + `subject_key` VARCHAR(96) NOT NULL, + `semester_key` VARCHAR(64) NOT NULL, + `unit_key` VARCHAR(96) NOT NULL, + `lesson_key` VARCHAR(128) NOT NULL, + `curriculum_version` VARCHAR(64) NOT NULL, + `title` VARCHAR(500) NOT NULL, + `source_manifest_path` VARCHAR(500) NOT NULL, + `source_status` ENUM('unverified','reviewed','approved','withdrawn') NOT NULL DEFAULT 'unverified', + `reviewed_at` TIMESTAMP NULL DEFAULT NULL, + `reviewed_by` BIGINT UNSIGNED NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_curriculum_lesson_uuid` (`uuid`), + UNIQUE KEY `uq_curriculum_lesson_identity` (`grade_key`,`subject_key`,`semester_key`,`unit_key`,`lesson_key`,`curriculum_version`), + KEY `idx_curriculum_lesson_manifest` (`source_manifest_path`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `publication_bundles` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `uuid` CHAR(36) NOT NULL, + `curriculum_lesson_id` BIGINT UNSIGNED NOT NULL, + `bundle_version` VARCHAR(64) NOT NULL, + `status` ENUM('draft','review','published','withdrawn') NOT NULL DEFAULT 'draft', + `published_at` TIMESTAMP NULL DEFAULT NULL, + `withdrawn_at` TIMESTAMP NULL DEFAULT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_bundle_uuid` (`uuid`), + UNIQUE KEY `uq_bundle_lesson_version` (`curriculum_lesson_id`,`bundle_version`), + KEY `idx_bundle_publication` (`curriculum_lesson_id`,`status`,`published_at`), + CONSTRAINT `fk_bundle_curriculum_lesson` FOREIGN KEY (`curriculum_lesson_id`) REFERENCES `curriculum_lessons` (`id`) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `content_assets` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `uuid` CHAR(36) NOT NULL, + `asset_type` ENUM('lesson_markdown','worksheet_markdown','textbook_pdf','lesson_mindmap_image','unit_mindmap_image','audio','transcript','other') NOT NULL, + `storage_driver` ENUM('local','r2') NOT NULL DEFAULT 'local', + `storage_key` VARCHAR(700) NOT NULL, + `mime_type` VARCHAR(127) NOT NULL, + `byte_size` BIGINT UNSIGNED NOT NULL, + `sha256` CHAR(64) NOT NULL, + `source_reference` VARCHAR(700) NULL, + `rights_status` ENUM('unknown','review_required','cleared','restricted') NOT NULL DEFAULT 'unknown', + `review_status` ENUM('draft','review','approved','withdrawn') NOT NULL DEFAULT 'draft', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_content_asset_uuid` (`uuid`), + UNIQUE KEY `uq_content_asset_integrity` (`storage_driver`,`storage_key`,`sha256`), + KEY `idx_content_asset_status` (`review_status`,`asset_type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `publication_bundle_assets` ( + `publication_bundle_id` BIGINT UNSIGNED NOT NULL, + `content_asset_id` BIGINT UNSIGNED NOT NULL, + `role` ENUM('primary_lesson','worksheet','textbook','lesson_mindmap','unit_mindmap','audio','transcript','supporting') NOT NULL, + `sort_order` SMALLINT UNSIGNED NOT NULL DEFAULT 0, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`publication_bundle_id`,`content_asset_id`,`role`), + KEY `idx_bundle_assets_role` (`publication_bundle_id`,`role`,`sort_order`), + CONSTRAINT `fk_bundle_asset_bundle` FOREIGN KEY (`publication_bundle_id`) REFERENCES `publication_bundles` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_bundle_asset_asset` FOREIGN KEY (`content_asset_id`) REFERENCES `content_assets` (`id`) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Rollback: do not drop published records. Mark a bundle withdrawn and preserve +-- its assets, then restore the preceding published bundle if one exists. diff --git a/backend/migrations/20260909_financial_ledger.sql b/backend/migrations/20260909_financial_ledger.sql new file mode 100644 index 0000000..ffbdcce --- /dev/null +++ b/backend/migrations/20260909_financial_ledger.sql @@ -0,0 +1,28 @@ +-- Financial ledger foundation. Amounts are integer fils (1 JOD = 1000 fils). +CREATE TABLE IF NOT EXISTS `ledger_accounts` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `code` VARCHAR(80) NOT NULL, + `owner_type` ENUM('platform','teacher','school') NOT NULL, `owner_id` BIGINT UNSIGNED NULL, + `currency` CHAR(3) NOT NULL DEFAULT 'JOD', `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), UNIQUE KEY `uq_ledger_account` (`code`,`owner_type`,`owner_id`,`currency`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE IF NOT EXISTS `ledger_entries` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `uuid` CHAR(36) NOT NULL, + `debit_account_id` BIGINT UNSIGNED NOT NULL, `credit_account_id` BIGINT UNSIGNED NOT NULL, + `amount_fils` BIGINT UNSIGNED NOT NULL, `currency` CHAR(3) NOT NULL DEFAULT 'JOD', + `reference_type` VARCHAR(64) NOT NULL, `reference_id` VARCHAR(128) NOT NULL, + `reason` VARCHAR(500) NOT NULL, `policy_version` VARCHAR(64) NOT NULL, + `reversal_of_entry_id` BIGINT UNSIGNED NULL, `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), UNIQUE KEY `uq_ledger_entry_uuid` (`uuid`), + UNIQUE KEY `uq_ledger_reference` (`reference_type`,`reference_id`,`debit_account_id`,`credit_account_id`), + KEY `idx_ledger_debit` (`debit_account_id`), KEY `idx_ledger_credit` (`credit_account_id`), + CONSTRAINT `fk_ledger_debit` FOREIGN KEY (`debit_account_id`) REFERENCES `ledger_accounts` (`id`) ON DELETE RESTRICT, + CONSTRAINT `fk_ledger_credit` FOREIGN KEY (`credit_account_id`) REFERENCES `ledger_accounts` (`id`) ON DELETE RESTRICT, + CONSTRAINT `fk_ledger_reversal` FOREIGN KEY (`reversal_of_entry_id`) REFERENCES `ledger_entries` (`id`) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE IF NOT EXISTS `teacher_withdrawal_holds` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `uuid` CHAR(36) NOT NULL, `teacher_id` BIGINT UNSIGNED NOT NULL, + `amount_fils` BIGINT UNSIGNED NOT NULL, `status` ENUM('held','released','settled','cancelled') NOT NULL DEFAULT 'held', + `idempotency_key` VARCHAR(128) NOT NULL, `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, `released_at` TIMESTAMP NULL, + PRIMARY KEY (`id`), UNIQUE KEY `uq_withdrawal_hold_uuid` (`uuid`), UNIQUE KEY `uq_withdrawal_idempotency` (`teacher_id`,`idempotency_key`), + KEY `idx_withdrawal_teacher_status` (`teacher_id`,`status`), CONSTRAINT `fk_withdrawal_teacher` FOREIGN KEY (`teacher_id`) REFERENCES `teachers` (`id`) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/migrations/20260909_learning_packages_and_remediation.sql b/backend/migrations/20260909_learning_packages_and_remediation.sql new file mode 100644 index 0000000..91fc92f --- /dev/null +++ b/backend/migrations/20260909_learning_packages_and_remediation.sql @@ -0,0 +1,33 @@ +CREATE TABLE IF NOT EXISTS `lesson_learning_packages` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `curriculum_lesson_id` BIGINT UNSIGNED NOT NULL, `bundle_id` BIGINT UNSIGNED NOT NULL, + `package_type` ENUM('general','english') NOT NULL DEFAULT 'general', `status` ENUM('draft','review','published','withdrawn') NOT NULL DEFAULT 'draft', + `structure_json` JSON NOT NULL, `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), UNIQUE KEY `uq_lesson_package` (`curriculum_lesson_id`,`bundle_id`,`package_type`), + CONSTRAINT `fk_learning_package_lesson` FOREIGN KEY (`curriculum_lesson_id`) REFERENCES `curriculum_lessons` (`id`) ON DELETE RESTRICT, + CONSTRAINT `fk_learning_package_bundle` FOREIGN KEY (`bundle_id`) REFERENCES `publication_bundles` (`id`) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE IF NOT EXISTS `english_lexical_entries` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `lesson_learning_package_id` BIGINT UNSIGNED NOT NULL, + `lemma` VARCHAR(255) NOT NULL, `part_of_speech` VARCHAR(64) NULL, `meaning_ar` TEXT NOT NULL, + `ipa_verified` VARCHAR(255) NULL, `audio_asset_id` BIGINT UNSIGNED NULL, `source_reference` VARCHAR(500) NOT NULL, + `review_status` ENUM('draft','approved','withdrawn') NOT NULL DEFAULT 'draft', + PRIMARY KEY (`id`), UNIQUE KEY `uq_english_lemma_package` (`lesson_learning_package_id`,`lemma`), + CONSTRAINT `fk_lexical_package` FOREIGN KEY (`lesson_learning_package_id`) REFERENCES `lesson_learning_packages` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_lexical_audio` FOREIGN KEY (`audio_asset_id`) REFERENCES `content_assets` (`id`) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE IF NOT EXISTS `learning_attempts` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `uuid` CHAR(36) NOT NULL, `student_id` BIGINT UNSIGNED NOT NULL, + `curriculum_lesson_id` BIGINT UNSIGNED NOT NULL, `activity_type` VARCHAR(64) NOT NULL, `activity_id` VARCHAR(128) NOT NULL, + `result_status` ENUM('correct','incorrect','partial','ungraded') NOT NULL, `help_level` TINYINT UNSIGNED NOT NULL DEFAULT 0, + `submitted_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uq_learning_attempt_uuid` (`uuid`), + KEY `idx_attempt_student_lesson` (`student_id`,`curriculum_lesson_id`,`submitted_at`), + CONSTRAINT `fk_attempt_student` FOREIGN KEY (`student_id`) REFERENCES `students` (`id`) ON DELETE RESTRICT, + CONSTRAINT `fk_attempt_lesson` FOREIGN KEY (`curriculum_lesson_id`) REFERENCES `curriculum_lessons` (`id`) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE IF NOT EXISTS `remediation_plans` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `student_id` BIGINT UNSIGNED NOT NULL, `curriculum_lesson_id` BIGINT UNSIGNED NOT NULL, + `status` ENUM('active','completed','expired') NOT NULL DEFAULT 'active', `plan_json` JSON NOT NULL, `due_at` TIMESTAMP NULL, `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), KEY `idx_remediation_student` (`student_id`,`status`), + CONSTRAINT `fk_remediation_student` FOREIGN KEY (`student_id`) REFERENCES `students` (`id`) ON DELETE RESTRICT, + CONSTRAINT `fk_remediation_lesson` FOREIGN KEY (`curriculum_lesson_id`) REFERENCES `curriculum_lessons` (`id`) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/migrations/20260909_payment_safety_hold.sql b/backend/migrations/20260909_payment_safety_hold.sql new file mode 100644 index 0000000..b727502 --- /dev/null +++ b/backend/migrations/20260909_payment_safety_hold.sql @@ -0,0 +1,40 @@ +-- Payment safety hold: apply this migration before deploying the P0 payment changes. +-- No balance is created or reconciled by this migration. Existing records remain +-- historical evidence and must be reconciled against a real provider later. + +CREATE TABLE IF NOT EXISTS `cliq_payments` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `uuid` CHAR(36) NOT NULL UNIQUE, + `student_id` BIGINT UNSIGNED NOT NULL, + `course_id` BIGINT UNSIGNED NOT NULL, + `amount_jod` DECIMAL(8,2) NOT NULL, + `cliq_alias` VARCHAR(100) NOT NULL, + `reference_code` VARCHAR(50) NOT NULL UNIQUE, + `bank_transaction_id` VARCHAR(100) DEFAULT NULL UNIQUE, + `receipt_image_path` VARCHAR(500) DEFAULT NULL, + `verification_status` ENUM('pending','verified','rejected','manual_review') NOT NULL DEFAULT 'pending', + `ai_extracted_json` JSON DEFAULT NULL, + `verified_at` TIMESTAMP NULL DEFAULT NULL, + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_cliq_student` (`student_id`), + KEY `idx_cliq_ref` (`reference_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `payout_queue` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `uuid` CHAR(36) NOT NULL UNIQUE, + `teacher_id` BIGINT UNSIGNED NOT NULL, + `amount_jod` DECIMAL(8,2) NOT NULL, + `teacher_cliq_alias` VARCHAR(150) NOT NULL, + `status` ENUM('queued','processing','completed','failed','cancelled') NOT NULL DEFAULT 'queued', + `transaction_reference` VARCHAR(100) DEFAULT NULL, + `failure_reason` TEXT DEFAULT NULL, + `processed_at` TIMESTAMP NULL DEFAULT NULL, + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_payout_teacher` (`teacher_id`), + KEY `idx_payout_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Rollback: do not drop either table. They may contain financial evidence. diff --git a/backend/migrations/20260909_q_watch_sessions.sql b/backend/migrations/20260909_q_watch_sessions.sql new file mode 100644 index 0000000..bd473bf --- /dev/null +++ b/backend/migrations/20260909_q_watch_sessions.sql @@ -0,0 +1,15 @@ +-- Must run before revenue periods because verified ratings reference watch_sessions. +CREATE TABLE IF NOT EXISTS `watch_sessions` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `uuid` CHAR(36) NOT NULL, `student_id` BIGINT UNSIGNED NOT NULL, `video_version_id` BIGINT UNSIGNED NOT NULL, + `funding_source` ENUM('school','marketplace','none') NOT NULL, `server_started_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, `server_ended_at` TIMESTAMP NULL, `status` ENUM('active','ended','invalidated') NOT NULL DEFAULT 'active', + PRIMARY KEY (`id`), UNIQUE KEY `uq_watch_session_uuid` (`uuid`), KEY `idx_watch_student_version` (`student_id`,`video_version_id`,`status`), + CONSTRAINT `fk_watch_student` FOREIGN KEY (`student_id`) REFERENCES `students` (`id`) ON DELETE RESTRICT, CONSTRAINT `fk_watch_version` FOREIGN KEY (`video_version_id`) REFERENCES `video_versions` (`id`) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE IF NOT EXISTS `watch_events` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `watch_session_id` BIGINT UNSIGNED NOT NULL, `sequence_no` INT UNSIGNED NOT NULL, `event_type` ENUM('start','heartbeat','pause','seek','resume','end','buffer') NOT NULL, `client_position_ms` BIGINT UNSIGNED NOT NULL, `server_received_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, `payload_json` JSON NULL, + PRIMARY KEY (`id`), UNIQUE KEY `uq_watch_event_sequence` (`watch_session_id`,`sequence_no`), CONSTRAINT `fk_watch_event_session` FOREIGN KEY (`watch_session_id`) REFERENCES `watch_sessions` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE IF NOT EXISTS `eligible_watch_intervals` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `watch_session_id` BIGINT UNSIGNED NOT NULL, `start_ms` BIGINT UNSIGNED NOT NULL, `end_ms` BIGINT UNSIGNED NOT NULL, `eligible_seconds` INT UNSIGNED NOT NULL, `reason_code` VARCHAR(80) NOT NULL, `policy_version` VARCHAR(64) NOT NULL, `status` ENUM('measured','excluded','review') NOT NULL DEFAULT 'measured', + PRIMARY KEY (`id`), UNIQUE KEY `uq_eligible_interval` (`watch_session_id`,`start_ms`,`end_ms`,`policy_version`), CONSTRAINT `fk_eligible_session` FOREIGN KEY (`watch_session_id`) REFERENCES `watch_sessions` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/migrations/20260909_revenue_periods_and_verified_ratings.sql b/backend/migrations/20260909_revenue_periods_and_verified_ratings.sql new file mode 100644 index 0000000..ae1b316 --- /dev/null +++ b/backend/migrations/20260909_revenue_periods_and_verified_ratings.sql @@ -0,0 +1,30 @@ +CREATE TABLE IF NOT EXISTS `revenue_periods` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `uuid` CHAR(36) NOT NULL, + `starts_at` TIMESTAMP NOT NULL, `ends_at` TIMESTAMP NOT NULL, + `policy_version` VARCHAR(64) NOT NULL, `status` ENUM('open','closing','closed','reopened') NOT NULL DEFAULT 'open', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), UNIQUE KEY `uq_revenue_period_uuid` (`uuid`), UNIQUE KEY `uq_revenue_period_dates` (`starts_at`,`ends_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE IF NOT EXISTS `teacher_revenue_allocations` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `revenue_period_id` BIGINT UNSIGNED NOT NULL, + `teacher_id` BIGINT UNSIGNED NOT NULL, `video_version_id` BIGINT UNSIGNED NOT NULL, + `eligible_seconds` BIGINT UNSIGNED NOT NULL, `amount_fils` BIGINT UNSIGNED NOT NULL, + `status` ENUM('estimated','held','approved','reversed') NOT NULL DEFAULT 'estimated', + `ledger_entry_id` BIGINT UNSIGNED NULL, `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), UNIQUE KEY `uq_teacher_allocation` (`revenue_period_id`,`teacher_id`,`video_version_id`), + CONSTRAINT `fk_allocation_period` FOREIGN KEY (`revenue_period_id`) REFERENCES `revenue_periods` (`id`) ON DELETE RESTRICT, + CONSTRAINT `fk_allocation_teacher` FOREIGN KEY (`teacher_id`) REFERENCES `teachers` (`id`) ON DELETE RESTRICT, + CONSTRAINT `fk_allocation_version` FOREIGN KEY (`video_version_id`) REFERENCES `video_versions` (`id`) ON DELETE RESTRICT, + CONSTRAINT `fk_allocation_ledger` FOREIGN KEY (`ledger_entry_id`) REFERENCES `ledger_entries` (`id`) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE IF NOT EXISTS `lesson_video_ratings` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `student_id` BIGINT UNSIGNED NOT NULL, + `video_version_id` BIGINT UNSIGNED NOT NULL, `rating` TINYINT UNSIGNED NOT NULL, + `watch_session_id` BIGINT UNSIGNED NOT NULL, `status` ENUM('pending','verified','flagged','rejected') NOT NULL DEFAULT 'pending', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), UNIQUE KEY `uq_student_video_rating` (`student_id`,`video_version_id`), + CONSTRAINT `fk_lvr_student` FOREIGN KEY (`student_id`) REFERENCES `students` (`id`) ON DELETE RESTRICT, + CONSTRAINT `fk_lvr_version` FOREIGN KEY (`video_version_id`) REFERENCES `video_versions` (`id`) ON DELETE RESTRICT, + CONSTRAINT `fk_lvr_session` FOREIGN KEY (`watch_session_id`) REFERENCES `watch_sessions` (`id`) ON DELETE RESTRICT, + CONSTRAINT `chk_lvr_rating` CHECK (`rating` BETWEEN 1 AND 5) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/migrations/20260909_teacher_submission_versions.sql b/backend/migrations/20260909_teacher_submission_versions.sql new file mode 100644 index 0000000..b6dd800 --- /dev/null +++ b/backend/migrations/20260909_teacher_submission_versions.sql @@ -0,0 +1,62 @@ +-- Teacher lesson submissions and immutable video candidates. +-- Requires 20260909_curriculum_publication_identity.sql. + +CREATE TABLE IF NOT EXISTS `teacher_submissions` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `uuid` CHAR(36) NOT NULL, + `teacher_id` BIGINT UNSIGNED NOT NULL, + `curriculum_lesson_id` BIGINT UNSIGNED NOT NULL, + `status` ENUM('draft','uploading','review','published','withdrawn','archived') NOT NULL DEFAULT 'draft', + `current_published_video_version_id` BIGINT UNSIGNED NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_teacher_submission_uuid` (`uuid`), + UNIQUE KEY `uq_teacher_curriculum_submission` (`teacher_id`,`curriculum_lesson_id`), + KEY `idx_submission_lesson_status` (`curriculum_lesson_id`,`status`), + CONSTRAINT `fk_submission_teacher` FOREIGN KEY (`teacher_id`) REFERENCES `teachers` (`id`) ON DELETE RESTRICT, + CONSTRAINT `fk_submission_curriculum_lesson` FOREIGN KEY (`curriculum_lesson_id`) REFERENCES `curriculum_lessons` (`id`) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `video_versions` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `uuid` CHAR(36) NOT NULL, + `teacher_submission_id` BIGINT UNSIGNED NOT NULL, + `version_number` INT UNSIGNED NOT NULL, + `status` ENUM('draft','uploading','review','rejected','published','superseded','withdrawn') NOT NULL DEFAULT 'draft', + `replaces_video_version_id` BIGINT UNSIGNED NULL, + `source_lesson_id` BIGINT UNSIGNED NULL, + `upload_audit_id` BIGINT UNSIGNED NULL, + `content_sha256` CHAR(64) NULL, + `review_report_json` JSON NULL, + `published_at` TIMESTAMP NULL DEFAULT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_video_version_uuid` (`uuid`), + UNIQUE KEY `uq_submission_version` (`teacher_submission_id`,`version_number`), + KEY `idx_video_version_status` (`teacher_submission_id`,`status`), + CONSTRAINT `fk_video_version_submission` FOREIGN KEY (`teacher_submission_id`) REFERENCES `teacher_submissions` (`id`) ON DELETE RESTRICT, + CONSTRAINT `fk_video_version_replacement` FOREIGN KEY (`replaces_video_version_id`) REFERENCES `video_versions` (`id`) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +ALTER TABLE `teacher_submissions` + ADD CONSTRAINT `fk_submission_current_version` + FOREIGN KEY (`current_published_video_version_id`) REFERENCES `video_versions` (`id`) ON DELETE RESTRICT; + +CREATE TABLE IF NOT EXISTS `submission_idempotency_keys` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `teacher_id` BIGINT UNSIGNED NOT NULL, + `operation` VARCHAR(64) NOT NULL, + `idempotency_key` VARCHAR(128) NOT NULL, + `request_sha256` CHAR(64) NOT NULL, + `response_json` JSON NULL, + `http_status` SMALLINT UNSIGNED NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_submission_idempotency` (`teacher_id`,`operation`,`idempotency_key`), + CONSTRAINT `fk_submission_idempotency_teacher` FOREIGN KEY (`teacher_id`) REFERENCES `teachers` (`id`) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Rollback: preserve submissions and versions. Withdraw affected publications; +-- do not drop records that may be referenced by student progress or review data. diff --git a/backend/migrations/20260909_video_review_jobs.sql b/backend/migrations/20260909_video_review_jobs.sql new file mode 100644 index 0000000..fccdca4 --- /dev/null +++ b/backend/migrations/20260909_video_review_jobs.sql @@ -0,0 +1,31 @@ +CREATE TABLE IF NOT EXISTS `video_review_jobs` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `uuid` CHAR(36) NOT NULL, + `video_version_id` BIGINT UNSIGNED NOT NULL, + `curriculum_lesson_id` BIGINT UNSIGNED NOT NULL, + `prompt_version` VARCHAR(64) NOT NULL, + `schema_version` VARCHAR(64) NOT NULL, + `video_sha256` CHAR(64) NOT NULL, + `markdown_sha256` CHAR(64) NOT NULL, + `status` ENUM('queued','collecting_evidence','needs_evidence','ready_for_human_review','rejected','approved','failed','superseded') NOT NULL DEFAULT 'queued', + `input_manifest_json` JSON NOT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `completed_at` TIMESTAMP NULL DEFAULT NULL, + PRIMARY KEY (`id`), UNIQUE KEY `uq_review_job_uuid` (`uuid`), + KEY `idx_review_job_version` (`video_version_id`,`status`), + CONSTRAINT `fk_review_job_version` FOREIGN KEY (`video_version_id`) REFERENCES `video_versions` (`id`) ON DELETE RESTRICT, + CONSTRAINT `fk_review_job_lesson` FOREIGN KEY (`curriculum_lesson_id`) REFERENCES `curriculum_lessons` (`id`) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `video_review_reports` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `video_review_job_id` BIGINT UNSIGNED NOT NULL, + `recommendation` ENUM('reject','revise','manual_review','ready_for_human_review') NOT NULL, + `report_json` JSON NOT NULL, + `reviewer_id` BIGINT UNSIGNED NULL, + `human_decision` ENUM('pending','approved','rejected') NOT NULL DEFAULT 'pending', + `reviewed_at` TIMESTAMP NULL DEFAULT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), UNIQUE KEY `uq_review_report_job` (`video_review_job_id`), + CONSTRAINT `fk_review_report_job` FOREIGN KEY (`video_review_job_id`) REFERENCES `video_review_jobs` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/backend/migrations/20260909_z_remove_synthetic_reputation_defaults.sql b/backend/migrations/20260909_z_remove_synthetic_reputation_defaults.sql new file mode 100644 index 0000000..9c3649e --- /dev/null +++ b/backend/migrations/20260909_z_remove_synthetic_reputation_defaults.sql @@ -0,0 +1,18 @@ +-- Removes presentation-grade synthetic reputation defaults. Existing measurements +-- are retained; unknown measurements remain NULL until backed by verified data. +ALTER TABLE `teacher_performance_metrics` + MODIFY `avg_response_minutes` INT NULL DEFAULT NULL, + MODIFY `response_rate_percentage` DECIMAL(5,2) NULL DEFAULT NULL, + MODIFY `raw_avg_rating` DECIMAL(3,2) NULL DEFAULT NULL, + MODIFY `weighted_student_rating` DECIMAL(3,2) NULL DEFAULT NULL, + MODIFY `ai_engagement_score` DECIMAL(5,2) NULL DEFAULT NULL, + MODIFY `sla_speed_score` DECIMAL(5,2) NULL DEFAULT NULL, + MODIFY `mastery_impact_score` DECIMAL(5,2) NULL DEFAULT NULL, + MODIFY `composite_merit_score` DECIMAL(5,2) NULL DEFAULT NULL, + MODIFY `star_equivalent` DECIMAL(3,2) NULL DEFAULT NULL, + MODIFY `reputation_tier` VARCHAR(100) NULL DEFAULT NULL; + +ALTER TABLE `teacher_reviews` + ALTER `student_watch_percentage` SET DEFAULT 0.00, + ALTER `socratic_accuracy_rate` SET DEFAULT 0.00, + ALTER `is_verified` SET DEFAULT 0; diff --git a/backend/migrations/20260909_zz_video_review_evidence.sql b/backend/migrations/20260909_zz_video_review_evidence.sql new file mode 100644 index 0000000..dc0fdae --- /dev/null +++ b/backend/migrations/20260909_zz_video_review_evidence.sql @@ -0,0 +1,15 @@ +-- Immutable evidence records are required before a reviewer can approve a video. +CREATE TABLE IF NOT EXISTS `video_review_evidence` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `video_review_job_id` BIGINT UNSIGNED NOT NULL, + `transcript_asset_id` BIGINT UNSIGNED NOT NULL, + `visual_evidence_asset_id` BIGINT UNSIGNED NOT NULL, + `coverage_json` JSON NOT NULL, + `submitted_by` BIGINT UNSIGNED NOT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_review_evidence_job` (`video_review_job_id`), + CONSTRAINT `fk_review_evidence_job` FOREIGN KEY (`video_review_job_id`) REFERENCES `video_review_jobs` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_review_evidence_transcript` FOREIGN KEY (`transcript_asset_id`) REFERENCES `content_assets` (`id`) ON DELETE RESTRICT, + CONSTRAINT `fk_review_evidence_visual` FOREIGN KEY (`visual_evidence_asset_id`) REFERENCES `content_assets` (`id`) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/backend/public/index.php b/backend/public/index.php index a861a65..94e0460 100644 --- a/backend/public/index.php +++ b/backend/public/index.php @@ -92,6 +92,12 @@ $router->get('/api/curriculum/search', function ($request, $response) { $router->get('/api/curriculum/simulations', [\App\Controllers\CurriculumController::class, 'listSimulations']); $router->get('/api/curriculum/simulations/{subject}/{simName}', [\App\Controllers\CurriculumController::class, 'getSimulation']); $router->get('/api/curriculum/document', [\App\Controllers\CurriculumController::class, 'getDocumentContent']); +$router->get('/api/curriculum/assets/{assetId}', [\App\Controllers\CurriculumController::class, 'getPublishedAsset'], $studentMiddleware); +$router->get('/api/curriculum/lessons/{lessonId}/videos', [\App\Controllers\VideoController::class, 'listPublishedLessonVideos'], $studentMiddleware); +$router->get('/api/curriculum/lessons/{lessonId}/english-package', [\App\Controllers\CurriculumController::class, 'getPublishedEnglishPackage'], $studentMiddleware); +$router->get('/api/video-versions/{versionId}/playback', [\App\Controllers\VideoController::class, 'getVideoVersionPlayback'], $studentMiddleware); +$router->post('/api/video-versions/{versionId}/watch-sessions', [\App\Controllers\VideoController::class, 'startWatchSession'], $studentMiddleware); +$router->post('/api/watch-sessions/{sessionId}/events', [\App\Controllers\VideoController::class, 'recordWatchEvent'], $studentMiddleware); // Health & Diagnostic Routes $router->get('/api/health', function ($request, $response) { @@ -157,6 +163,10 @@ $router->post('/api/teacher/broadcast', [\App\Controllers\TeacherController: // Dual Video Storage & Cloudflare R2 / HLS Stream Routes (API-Driven) $router->post('/api/teacher/videos/upload-direct', [\App\Controllers\VideoController::class, 'uploadDirect'], $teacherMiddleware); +$router->post('/api/teacher/submissions/preflight', [\App\Controllers\VideoController::class, 'preflightSubmission'], $teacherMiddleware); +$router->post('/api/admin/video-review/publish', [\App\Controllers\VideoController::class, 'publishReviewedVersion'], $superAdminMiddleware); +$router->post('/api/admin/video-review/decision', [\App\Controllers\VideoController::class, 'recordVideoReviewDecision'], $superAdminMiddleware); +$router->post('/api/admin/video-review/evidence', [\App\Controllers\VideoController::class, 'submitVideoReviewEvidence'], $superAdminMiddleware); $router->post('/api/teacher/lessons/checkpoints', [\App\Controllers\VideoController::class, 'saveCheckpoint'], $teacherMiddleware); $router->get('/api/student/lessons', [\App\Controllers\VideoController::class, 'getStudentLessons'], [\App\Middlewares\AuthMiddleware::class]); $router->get('/api/videos/stream/{uuid}', [\App\Controllers\VideoController::class, 'streamLocalVideo'], [\App\Middlewares\AuthMiddleware::class]); diff --git a/backend/scripts/backfill_curriculum_identity.php b/backend/scripts/backfill_curriculum_identity.php new file mode 100644 index 0000000..de9cfa2 --- /dev/null +++ b/backend/scripts/backfill_curriculum_identity.php @@ -0,0 +1,157 @@ + $grade) { + foreach (($grade['subjects'] ?? []) as $subjectKey => $subject) { + foreach (($subject['semesters'] ?? []) as $semesterKey => $semester) { + foreach (($semester['units'] ?? []) as $unitKey => $unit) { + foreach (($unit['lessons'] ?? []) as $lesson) { + $file = (string)($lesson['file'] ?? ''); + $candidate = realpath($storageRoot . '/' . ltrim($file, '/')); + $insideStorage = $candidate && str_starts_with( + $candidate, + rtrim($storageRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR + ); + $rows[] = [ + 'grade_key' => (string)$gradeKey, + 'subject_key' => (string)$subjectKey, + 'semester_key' => (string)$semesterKey, + 'unit_key' => (string)$unitKey, + 'lesson_key' => (string)($lesson['id'] ?? ''), + 'title' => (string)($lesson['title'] ?? ''), + 'manifest_path' => $file, + 'path' => $insideStorage ? $candidate : null, + 'missing' => !$insideStorage, + ]; + } + } + } + } +} + +$identityCounts = []; +foreach ($rows as $row) { + $identity = implode('|', [$row['grade_key'], $row['subject_key'], $row['semester_key'], $row['unit_key'], $row['lesson_key']]); + $identityCounts[$identity] = ($identityCounts[$identity] ?? 0) + 1; +} +$conflicts = array_keys(array_filter($identityCounts, static fn (int $count): bool => $count > 1)); +$missing = array_values(array_filter($rows, static fn (array $row): bool => $row['missing'])); + +$report = [ + 'mode' => $apply ? 'apply' : 'dry_run', + 'curriculum_version' => 'manifest-2026-09-09', + 'lessons_found' => count($rows), + 'missing_assets' => array_map(static fn (array $row): array => [ + 'identity' => implode('/', [$row['grade_key'], $row['subject_key'], $row['semester_key'], $row['unit_key'], $row['lesson_key']]), + 'title' => $row['title'], + 'manifest_path' => $row['manifest_path'] ?: null, + 'reason' => $row['manifest_path'] === '' ? 'missing_manifest_file_reference' : 'file_not_found_in_local_storage', + ], $missing), + 'identity_conflicts' => $conflicts, + 'eligible_for_review_queue' => count($rows) - count($missing), +]; + +if (!$apply) { + echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL; + exit(($missing || $conflicts) ? 2 : 0); +} + +if ($conflicts) { + fwrite(STDERR, "Conflicting curriculum identities found. Resolve them before --apply.\n"); + echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL; + exit(2); +} + +require_once dirname(__DIR__) . '/app/bootstrap.php'; + +use App\Core\Database; + +$pdo = Database::getConnection(); +$pdo->beginTransaction(); +try { + foreach ($rows as $row) { + if ($row['missing']) { + continue; + } + $lessonUuid = selfUuid(); + Database::query( + "INSERT INTO curriculum_lessons + (uuid, grade_key, subject_key, semester_key, unit_key, lesson_key, curriculum_version, title, source_manifest_path, source_status) + VALUES (?, ?, ?, ?, ?, ?, 'manifest-2026-09-09', ?, ?, 'unverified') + ON DUPLICATE KEY UPDATE title = VALUES(title), source_manifest_path = VALUES(source_manifest_path)", + [$lessonUuid, $row['grade_key'], $row['subject_key'], $row['semester_key'], $row['unit_key'], $row['lesson_key'], $row['title'], $row['manifest_path']] + ); + $lessonId = (int)Database::selectOne( + "SELECT id FROM curriculum_lessons WHERE grade_key = ? AND subject_key = ? AND semester_key = ? AND unit_key = ? AND lesson_key = ? AND curriculum_version = 'manifest-2026-09-09' LIMIT 1", + [$row['grade_key'], $row['subject_key'], $row['semester_key'], $row['unit_key'], $row['lesson_key']] + )['id']; + Database::query( + "INSERT INTO publication_bundles (uuid, curriculum_lesson_id, bundle_version, status) + VALUES (?, ?, 'manifest-2026-09-09', 'draft') + ON DUPLICATE KEY UPDATE id = id", + [selfUuid(), $lessonId] + ); + $bundleId = (int)Database::selectOne( + "SELECT id FROM publication_bundles WHERE curriculum_lesson_id = ? AND bundle_version = 'manifest-2026-09-09' LIMIT 1", + [$lessonId] + )['id']; + $sha = hash_file('sha256', $row['path']); + $bytes = filesize($row['path']); + Database::query( + "INSERT INTO content_assets (uuid, asset_type, storage_driver, storage_key, mime_type, byte_size, sha256, source_reference, review_status) + VALUES (?, 'lesson_markdown', 'local', ?, 'text/markdown; charset=utf-8', ?, ?, ?, 'draft') + ON DUPLICATE KEY UPDATE id = id", + [selfUuid(), $row['manifest_path'], $bytes, $sha, 'manifest.json'] + ); + $assetId = (int)Database::selectOne( + "SELECT id FROM content_assets WHERE storage_driver = 'local' AND storage_key = ? AND sha256 = ? LIMIT 1", + [$row['manifest_path'], $sha] + )['id']; + Database::query( + "INSERT IGNORE INTO publication_bundle_assets (publication_bundle_id, content_asset_id, role) VALUES (?, ?, 'primary_lesson')", + [$bundleId, $assetId] + ); + } + $pdo->commit(); + $report['created_review_queue'] = true; + echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL; +} catch (Throwable $e) { + $pdo->rollBack(); + fwrite(STDERR, "Backfill rolled back: {$e->getMessage()}\n"); + exit(1); +} + +function selfUuid(): string +{ + $bytes = random_bytes(16); + $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40); + $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4)); +} diff --git a/backend/scripts/run_migrations.php b/backend/scripts/run_migrations.php new file mode 100644 index 0000000..bbb4122 --- /dev/null +++ b/backend/scripts/run_migrations.php @@ -0,0 +1,42 @@ +exec("CREATE TABLE IF NOT EXISTS schema_migrations (filename VARCHAR(255) NOT NULL PRIMARY KEY, sha256 CHAR(64) NOT NULL, applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); +$applied = Database::select('SELECT filename, sha256, applied_at FROM schema_migrations ORDER BY filename'); +$known = array_column($applied, null, 'filename'); + +foreach ($files as $file) { + $name = basename($file); $sha = hash_file('sha256', $file); + if (isset($known[$name])) { + if (!hash_equals((string)$known[$name]['sha256'], $sha)) { fwrite(STDERR, "REFUSE {$name}: applied migration checksum changed.\n"); exit(2); } + echo "APPLIED {$name} {$known[$name]['applied_at']}\n"; continue; + } + if ($mode === 'status') { echo "PENDING {$name}\n"; continue; } + if ($mode === 'dry_run') { echo "WOULD APPLY {$name}\n"; continue; } + $sql = file_get_contents($file); + if ($sql === false || trim($sql) === '') { fwrite(STDERR, "REFUSE {$name}: unreadable or empty.\n"); exit(2); } + echo "APPLYING {$name}\n"; + try { + // MySQL DDL may commit implicitly. The migration is recorded only after + // its complete SQL succeeds; repair any partial DDL manually before retry. + $pdo->exec($sql); + Database::insert('INSERT INTO schema_migrations (filename, sha256) VALUES (?, ?)', [$name, $sha]); + echo "APPLIED {$name}\n"; + } catch (Throwable $e) { + fwrite(STDERR, "FAILED {$name}: {$e->getMessage()}\n"); + exit(1); + } +} diff --git a/backend/scripts/video_upload_worker.php b/backend/scripts/video_upload_worker.php index 0c2c543..e100b6e 100644 --- a/backend/scripts/video_upload_worker.php +++ b/backend/scripts/video_upload_worker.php @@ -19,6 +19,16 @@ function updateVideoTask(string $file, array $changes): void { if ($taskId === '' || !file_exists($stateFile)) exit(1); $task = json_decode(file_get_contents($stateFile), true) ?: []; +// Legacy worker has no teacher_submission/video_version binding and therefore +// cannot prove which curriculum lesson or candidate it is publishing. Keep the +// uploaded file for an operator; do not create or overwrite a lesson by title. +updateVideoTask($stateFile, [ + 'status' => 'needs_submission_pipeline', + 'progress' => 0, + 'message' => 'هذا العامل موقوف حتى يمرر مسار الإرسال نسخة فيديو مرتبطة بالدرس المنهجي.', +]); +exit(2); + try { updateVideoTask($stateFile, ['status' => 'processing', 'progress' => 15, 'message' => 'جاري تحويل الفيديو إلى HLS...']); $file = (string)($task['file'] ?? ''); diff --git a/docs/MEDIUM_EXECUTION_TASKS.md b/docs/MEDIUM_EXECUTION_TASKS.md new file mode 100644 index 0000000..84cb15e --- /dev/null +++ b/docs/MEDIUM_EXECUTION_TASKS.md @@ -0,0 +1,135 @@ +# قائمة تنفيذ صَقِل — جلسات Medium قابلة للاستئناف + +هذه القائمة هي سجل التشغيل العملي للمحادثات القادمة. النموذج الافتراضي لكل مهمة هو **GPT-5.6 Terra على Medium**. لا تفتح أكثر من مهمة تنفيذية واحدة في المحادثة الواحدة إلا إذا كانت الثانية لا تمس الملفات أو قاعدة البيانات نفسها. استخدم Sol/Astra للمراجعة النهائية للمال والأمن فقط، بعد اكتمال الاختبارات. + +## طريقة الاستئناف + +في أي جلسة جديدة ابدأ بالنص التالي، ثم نفذ أول مهمة `planned` اكتملت متطلباتها: + +```text +اقرأ AGENTS.md وdocs/MEDIUM_EXECUTION_TASKS.md وملفات المهمة المحددة. +نفذ المهمة فقط، واحفظ تغييرات المستخدم. لا تنشر ولا تتصل بخدمة دفع حية. +حدّث حالة المهمة إلى implemented_unverified أو verified فقط بدليل فعلي. +``` + +| الترتيب | المهمة | النموذج/الجهد | يعتمد على | الحالة | +|---:|---|---|---|---| +| 00 | تثبيت بيئة الاختبار والأوامر الفعلية | Medium | — | implemented_unverified | +| 01 | إيقاف النجاح المالي والمحتوى البديل الوهمي | Medium | 00 | implemented_unverified | +| 02 | هوية المنهج والأصول المنشورة | Medium | 01 | implemented_unverified | +| 03 | حصة المعلم والإصدار والاستبدال الذري | Medium | 02 | implemented_unverified | +| 04 | تدقيق الفيديو والأسئلة من الأدلة | Medium | 02, 03 | planned | +| 05 | API صفر/واحد/متعدد واختيار المعلم | Medium | 02, 04 | planned | +| 06 | دفتر الحسابات وحجز الرصيد | Medium ثم Sol review | 00, 01 | planned | +| 07 | جلسات المشاهدة والدقائق المؤهلة | Medium | 02, 05, 06 | planned | +| 08 | تسوية صندوق المعلمين وكشف الحساب | Medium ثم Sol review | 06, 07 | planned | +| 09 | مزود الدفع والبصمة والتحويل | Medium ثم Sol/Astra review | 06 | blocked: مزود دفع/سياسة متجر | +| 10 | الإنجليزية: حزمة الدرس والصوت الموحد | Medium | 01, 02 | planned | +| 11 | موارد الدرس وحلقة العلاج وولي الأمر | Medium | 02, 04, 05, 10 | planned | +| 12 | المؤسسات والتقييمات والتلعيب | Medium | 02, 05, 07, 11 | planned | +| 13 | إثبات الصف العاشر وقرار الإطلاق | Medium ثم Astra review | كل ما يلزم | planned | + +## قاعدة تحديث قاعدة البيانات + +شغّل من جذر المشروع فقط: `php backend/scripts/run_migrations.php --status` ثم `--dry-run`، وبعد نسخة احتياطية وstaging نفّذ الأمر دون خيارات. يسجل المشغّل اسم كل migration وSHA-256؛ يرفض تشغيل ملف تغيّر بعد تسجيله. لا تستخدم `migrate.php fresh` لهذا المسار لأنه قد يحذف قاعدة البيانات. + +## بطاقات التسليم + +### 00 — خط الأساس + +اقرأ `backend/public/index.php` و`backend/cron/run_scheduled_tasks.php` و`backend/composer.json` وملفات الاختبار قبل تشغيل أي منها. أنشئ سجلاً لأوامر الفحص الآمنة، ولا تستخدم `artisan`. أنجز harness اختبارات PHP محلياً لا يحتاج قاعدة إنتاج أو Gemini. + +### 01 — الصدق أولاً + +نفذ NP-01 من `NEXT_PHASE_EXECUTION_BACKLOG.md`: لا تفعيل وصول من صورة إيصال، لا إكمال سحب دون مزود، لا تحويل أرقام المنصة إلى دخل معلم، ولا مستند أو IPA أو درس بديل عند فشل المصدر. أضف migration رسمية لما كان ينشأ وقت الطلب. اختبر فشل الخدمات وحالات عدم توفر الدفع. + +### 02 — هوية المحتوى + +نفذ migration لـ`curriculum_lesson`, `content_asset`, وإصدار الحزمة. اعتمد IDs ثابتة ولا تطابق بالعنوان أو LIKE. أنشئ endpoint يطلب asset ID فقط ويعيد الأصل المنشور الموافق أو خطأ. اكتب backfill dry-run مع تقرير تعارضات. + +### 03 — نشر واستبدال الحصة + +نفذ `teacher_submission` و`video_version` وقيد حصة أساسية واحدة لكل معلم/درس. أضف Idempotency-Key وقفل/transaction للنشر. البديلة تبقى مرشحة حتى يجتاز فحصها ولا توقف النسخة القديمة. + +### 04 — الفحص السقراطي + +طبق `VIDEO_REVIEW_AND_SOCRATIC_PROMPTS.md`: لا توليد في GET، ولا fallback أسئلة. job يجمع تفريغاً وأدلة وصوراً وMarkdown بإصدارات، ويخضع لـschema validation ومراجعة بشرية. ابن regression fixtures معزولة لا تستدعي AI. + +### 05 — اختيار المعلم + +اعرض حصصاً منشورة ومصرحاً بها للدرس المحدد فقط: صفر موارد صادقة، واحد تشغيل مباشر، متعدد قائمة paginated مرتبة بتقييم موثق. المشغل يعمل بـvideo_version ID وليس عنواناً. + +### 06–08 — المال بالدقائق + +لا تبدأ قبل تثبيت policy تجارية موقعة. نفذ دفتر الفلس، ثم أحداث مشاهدة غير موثوقة من العميل، ثم توزيع صندوق الفترة وإشعارات معلم تفسيرية. كل تعديل قيد عكسي، وكل سحب حجز ذري. راجع Sol قبل تشغيل أي رصيد حقيقي. + +### 09 — الدفع والبصمة + +مهمة محجوبة حتى يحدد المستخدم مزود تحصيل/تحويل حقيقي وقناة التوزيع وسياسة المتاجر. عندها نبني intents، webhooks موقعة، sandbox، تأكيد بصمة مرتبط بتحدٍ، وتسوية. لا يتم تخزين بصمة. + +### 10–12 — تجربة الطالب والمعلم + +أعد بناء الإنجليزية من الحزمة المنشورة، لا من قوائم ثابتة. وحّد الصوت ولا تولد IPA/ترجمة. أضف PDF وورقة وخريطتي صور، ثم الحلقة العلاجية، ثم تنظيم المؤسسات والتقييمات. + +### 13 — الإطلاق + +اختبر 12 درساً حقيقياً للصف العاشر على staging مع بيانات محكومة. لا توسع قبل دليل مصادر، صحة أسئلة، عودة الطالب بعد أسبوع، ومساهمة تشغيل موجبة. هذه بوابة مراجعة Astra النهائية. + +## قاعدة التحديث + +بعد كل مهمة اكتب في آخر هذه الوثيقة: المهمة، commit/diff، الاختبارات الفعلية، ما لم يختبر، والقرار التالي. لا تحوّل `blocked` إلى `planned` دون القرار أو الخدمة المطلوبة. + +## سجل التنفيذ + +### 2026-09-09 — 00 و01 (implemented_unverified) + +- أضيفت `backend/migrations/20260909_payment_safety_hold.sql` لنقل تعريف جداول كليك وطابور السحب من وقت الطلب إلى migration واضحة لا تحذف أدلة مالية عند التراجع. +- واجهات كليك والسحب والتسوية المجدولة تعيد حالة عدم توفر ولا تنشئ إيصالاً أو حركة أو وصولاً أو سحباً. لوحة المعلم لا تستنتج دخلاً أو رصيداً من عدد المشتركين أو أسعار الدورات. +- حذف مصدر Markdown التجريبي؛ طلب المستند يحتاج مسار Markdown دقيقاً داخل تخزين المنهج، وفشل المصدر يعرض حالة صادقة. تشغيل الفيديو أصبح للقراءة فقط ولا يولد نقاط فحص من العنوان أو المنهج العام. +- فحوص شُغلت: `php -l` للملفات PHP المعدلة، و`git diff --check`، و`flutter analyze` للتطبيقين. لم يظهر خطأ في الملفات المعدلة؛ توجد تحذيرات قديمة غير مانعة في المشروعين. +- لم تُنفذ migration على قاعدة بيانات، ولم تُشغّل اختبارات تكامل بقاعدة معزولة أو مزود دفع؛ لا تنشر هذه الدفعة قبل تنفيذ ذلك. +- الخطوة التالية: ابدأ 02 بهوية محتوى وأصول منشورة؛ بعدها فقط ابدأ 03 و04. + +### 2026-09-09 — 02 (implemented_unverified) + +- أضيفت migration `20260909_curriculum_publication_identity.sql` لجداول هوية الدرس، bundle النشر، الأصل ذي checksum، وربط الأصول بالحزمة. الاستيراد لا ينشر شيئاً تلقائياً. +- أضيف `GET /api/curriculum/assets/{assetId}` للطالب المصرح؛ يقبل UUID فقط ويقرأ أصلاً محلياً approved من bundle منشور فقط. لا يستقبل مسار ملف من المستخدم. +- أضيف `backend/scripts/backfill_curriculum_identity.php`: افتراضياً dry-run بلا اتصال DB، و`--apply` ينشئ طابور مراجعة draft/unverified ضمن transaction فقط بعد تطبيق migration وحل التعارضات. +- نتيجة dry-run المحلية: 74 سجل manifest، 55 ملفاً محلياً قابلاً لطابور المراجعة، و19 مرجعاً بلا ملف. لا يوجد تعارض هوية تلقائي؛ المراجع الناقصة مدرجة في تقرير الأمر. +- فحوص شُغلت: PHP lint للخدمة/المتحكم/route/script و`git diff --check`. لم تطبق migration ولم يشغّل `--apply`. +- الخطوة التالية: 03، بناء حصة المعلم ونسخة الفيديو والاستبدال الذري فوق `curriculum_lessons`. + +### 2026-09-09 — 03 (implemented_unverified) + +- أضيفت migration `20260909_teacher_submission_versions.sql`: قيد فريد للمعلم والدرس المنهجي، نسخ فيديو مرشحة مرقمة، مؤشر منفصل للنسخة المنشورة، ومفاتيح idempotency. +- أضيف مسار `POST /api/teacher/submissions/preflight` مع `Idempotency-Key`. الحصة الثانية تعيد `409 existing_submission`؛ الاستبدال يتطلب هدف الحصة الحالية وينشئ candidate جديداً فقط. +- رفع الفيديو المباشر صار يتطلب `video_version_id` من preflight ويحجز النسخة atomically من draft إلى uploading قبل تخزين الفيديو. بعد الرفع ترتبط بالدرس القديم بوصفها `review`؛ لا تنشر ولا تستبدل النسخة الحالية تلقائياً. +- فحوص شُغلت: PHP lint للخدمة والمتحكم والـroute و`git diff --check`. +- لم تطبق migration ولم ينفذ اختبار تزامن أو transaction على MySQL. تطبيق المعلم لا يمرر بعد `curriculum_lesson_id`/`video_version_id` لأنه يحتاج تشغيل backfill ومراجعة/نشر هوية الدروس أولاً؛ لذلك الرفع القديم يرفض صراحة بدلاً من تجاوز القيد. +- الخطوة التالية: 04، job تدقيق الفيديو من التفريغ وMarkdown وإصدار النسخة، ثم نشر ذري للنسخة التي اجتازت المراجعة. + +### 2026-09-09 — إدارة migrations و04 (in_progress) + +- أضيف `backend/scripts/run_migrations.php` كمشغل موحد للمجلد `backend/migrations` مع أوضاع status وdry-run وapply وسجل checksum. +- أضيفت migration `20260909_video_review_jobs.sql` لسجل job وتقرير مراجعة بشرية؛ لا يوجد فيها نشر آلي أو توليد أسئلة تلقائي. + +### 2026-09-09 — 04 و05 (implemented_unverified، تكامل الواجهة مؤجل) + +- أضيفت `video_review_evidence` مع مسار إداري `POST /api/admin/video-review/evidence`. لا ينتقل job إلى مراجعة بشرية قبل تفريغ زمني معتمد ودليل بصري معتمد وسجل تغطية متصل؛ ولا يقبل قرار الموافقة أو النشر من دون هذا السجل. +- أضيفت `GET /api/curriculum/lessons/{lessonId}/videos`: يعتمد UUID الدرس، يعرض النسخ المنشورة فقط، ويرشح كل صف بصلاحية الطالب قبل كشف الاسم أو التقييم أو العدد. لا يحدث هذا الاستعلام بيانات الطالب أو تصريحاته أثناء التصفح. +- نقطة التشغيل الجديدة `GET /api/video-versions/{versionId}/playback` تتحقق من صلاحية النسخة المنشورة والصلاحية الدراسية قبل توقيع البث. أسئلة الفيديو تبقى فارغة إلى أن تنشر من مسار الأدلة والمراجعة. +- أزيل DDL من طلبات المعلم والمنهج والامتحان؛ أصبحت hooks التوافقية no-op وتحتاج البيئة إلى `run_migrations.php` قبل الخدمة. أزيلت قيم السمعة والإنجاز الافتراضية من واجهات المعلم والطالب وولي الأمر ومن HDDL، مع migration تصحح defaults للبيئات الموجودة. +- فحوص شُغلت: `php -l` على كل PHP في `backend/app` و`backend/scripts` و`backend/cron` (ناجح)، و`git diff --check` (ناجح). محاولة `php backend/scripts/run_migrations.php --dry-run` لم تبدأ لأن البيئة المحلية بلا `.env` و`ENCRYPTION_KEY`. +- لم يكتمل بعد: Flutter ما زال يستدعي `/api/lessons/playback` لأن شجرة المنهج الحالية لا تحمل UUID الدرس المنشور. لا تربطه بالـAPI الجديد إلا بعد تطبيق migrations وbackfill ومراجعة/نشر bundles حقيقية؛ عندها نفذ حالة صفر/واحد/متعدد في `subject_hub_screen.dart` ثم مرر `video_version_id` إلى المشغل. +- الخطوة التالية: 06 بعد سياسة دخل موقعة، أو تنفيذ واجهة اختيار المعلم فور توافر UUIDs منشورة على شجرة المنهج. + +### 2026-09-09 — 05 و07 و10 (implemented_unverified) + +- شجرة المنهج تثري الدروس من قاعدة البيانات بعد migrations/backfill فقط بـ`curriculum_lesson_id` و`has_video` المنشور. لا تعيد مساراً بديلاً عندما لا تكون الهوية منشورة. +- تطبيق الطالب يستخدم `/api/curriculum/lessons/{lessonId}/videos` ثم `/api/video-versions/{versionId}/playback`: حصة واحدة تفتح مباشرة، وأكثر من حصة يظهر لها اختيار معلم صريح بالتقييم الموثق وعدد التقييمات. أزيل استدعاء `/api/lessons/playback` القائم على العنوان من التطبيق. +- أضيفت جلسة المشاهدة: `POST /api/video-versions/{id}/watch-sessions` ثم أحداث متسلسلة. يحتسب الخادم interval متصلاً من heartbeat فقط، بمقارنة زمن الخادم وموضع العميل وحدود صارمة؛ لا ينشئ ذلك رصيداً أو تسوية أو دخل معلم. +- أضيف `TeacherLedgerService` لعرض الرصيد بالفلس وحجز السحب idempotently من رصيد دفتر قائم فقط. لا توجد واجهة حجز أو تسوية مفعلة ولا أي سياسة سعر/صندوق مختلقة. +- أزيل مختبر الإنجليزية الثابت وما فيه من IPA ومفردات وصوت غير موثقين. أضيف `GET /api/curriculum/lessons/{lessonId}/english-package` الذي يقرأ حزمة إنجليزية منشورة فقط ومفرداتها وصوتها approved؛ الواجهة تعرض حالة انتظار صادقة إلى أن تُزرع الحزم المعتمدة. +- فحوص شُغلت: PHP lint كامل لـ`backend/app` و`backend/scripts` و`backend/cron`، و`git diff --check`، وFlutter analyze لملفات الطالب المعدلة؛ كلها ناجحة. +- لم تُطبق migrations أو تختبر المعاملات على MySQL لأن `.env` و`ENCRYPTION_KEY` غير متاحين محلياً. كما أن 06–08 لا يجوز تفعيلها أو حساب توزيع مالي قبل سياسة دخل موقعة، و09 ما زالت محجوبة بتحديد مزود دفع/تحويل فعلي. +- الخطوة التالية تلقائياً: نفذ migrations على staging مع backup، شغّل backfill dry-run ثم راجع الـ19 مرجعاً الناقصاً، وانشر bundles وحزم إنجليزية حقيقية للصف العاشر. بعدها اختبر مسارات صفر/واحد/متعدد وجلسات المشاهدة بتكامل MySQL قبل أي نشر.