Update Saqel Platform: 2026-09-12 16:23:39

This commit is contained in:
Hamza-Ayed
2026-09-12 16:23:39 +03:00
parent f4d4f02853
commit 81fa8c5dc8
9 changed files with 585 additions and 115 deletions
@@ -223,7 +223,7 @@ class _CurriculumDocumentViewerScreenState extends State<CurriculumDocumentViewe
}
currentTitle = trimmed.replaceAll(RegExp(r'^#{2,3}\s*'), '');
} else if (trimmed.isNotEmpty) {
currentLines.add(trimmed);
currentLines.add(line);
}
}
@@ -238,6 +238,218 @@ class _CurriculumDocumentViewerScreenState extends State<CurriculumDocumentViewe
return list;
}
/// Converts LaTeX / Chemical notation into crisp Unicode and clean labels
String _cleanFormulas(String text) {
var s = text;
s = s.replaceAll(r'$$\text{CO}_2$$', 'CO₂');
s = s.replaceAll(r'$$\text{H}_2\text{O}$$', 'H₂O');
s = s.replaceAll(r'$$\text{O}_3$$', 'O₃');
s = s.replaceAll(r'$$\text{O}_2$$', 'O₂');
s = s.replaceAll(r'$$\text{N}_2$$', 'N₂');
s = s.replaceAll(r'$$\text{CH}_4$$', 'CH₄');
s = s.replaceAll(r'$$\text{SO}_2$$', 'SO₂');
s = s.replaceAll(r'$$\text{NO}_2$$', 'NO₂');
s = s.replaceAll(r'$$\text{CO}$$', 'CO');
// Generic pattern for $$\text{X}_n$$
s = s.replaceAllMapped(RegExp(r'\$\$\\text\{([A-Za-z0-9]+)\}_([0-9]+)\$\$'), (m) {
final element = m.group(1) ?? '';
final sub = (m.group(2) ?? '').split('').map((d) {
const subs = {'0': '₀', '1': '₁', '2': '₂', '3': '₃', '4': '₄', '5': '₅', '6': '₆', '7': '₇', '8': '₈', '9': '₉'};
return subs[d] ?? d;
}).join('');
return '$element$sub';
});
// Strip generic $$ ... $$ or $ ... $ markers cleanly if text
s = s.replaceAllMapped(RegExp(r'\$\$([^\$]+)\$\$'), (m) {
var formula = m.group(1) ?? '';
formula = formula.replaceAll(r'\text{', '').replaceAll('}', '');
return formula.trim();
});
s = s.replaceAllMapped(RegExp(r'\$([^\$]+)\$'), (m) {
var formula = m.group(1) ?? '';
formula = formula.replaceAll(r'\text{', '').replaceAll('}', '');
return formula.trim();
});
return s;
}
/// RichText builder parsing **bold** syntax into styled spans
Widget _buildFormattedText(String rawText, {required double fontSize, Color? baseColor}) {
final cleaned = _cleanFormulas(rawText);
final spans = <TextSpan>[];
final regex = RegExp(r'\*\*(.*?)\*\*');
int lastMatchEnd = 0;
for (final match in regex.allMatches(cleaned)) {
if (match.start > lastMatchEnd) {
spans.add(TextSpan(
text: cleaned.substring(lastMatchEnd, match.start),
style: TextStyle(
color: baseColor ?? Colors.white.withAlpha(225),
fontSize: fontSize,
height: 1.6,
),
));
}
final boldText = match.group(1) ?? '';
spans.add(TextSpan(
text: boldText,
style: TextStyle(
color: baseColor ?? AppColors.saqelCyan,
fontWeight: FontWeight.w800,
fontSize: fontSize,
height: 1.6,
),
));
lastMatchEnd = match.end;
}
if (lastMatchEnd < cleaned.length) {
spans.add(TextSpan(
text: cleaned.substring(lastMatchEnd),
style: TextStyle(
color: baseColor ?? Colors.white.withAlpha(225),
fontSize: fontSize,
height: 1.6,
),
));
}
return RichText(
text: TextSpan(children: spans),
);
}
/// Renders individual lines with rich Cupertino styling: subheadings, bullets, callouts
Widget _buildSectionLine(String line, double fontSize) {
final trimmed = line.trim();
if (trimmed.isEmpty) return const SizedBox.shrink();
// 1. Subheading (#### or ###)
if (trimmed.startsWith('#### ') || trimmed.startsWith('### ')) {
final title = trimmed.replaceAll(RegExp(r'^#{3,4}\s*'), '');
return Container(
margin: const EdgeInsets.only(top: 14, bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: AppColors.appleBlue.withAlpha(25),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.saqelCyan.withAlpha(50)),
),
child: Row(
children: [
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: AppColors.saqelCyan,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
_cleanFormulas(title),
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w800,
fontSize: fontSize + 1.0,
),
),
),
],
),
);
}
// 2. Blockquote / Callout (> Note or check of understanding)
if (trimmed.startsWith('>')) {
final quoteText = trimmed.replaceAll(RegExp(r'^>\s*'), '');
return Container(
margin: const EdgeInsets.symmetric(vertical: 6),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF0F1E36),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.guardianAmber.withAlpha(80)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(CupertinoIcons.lightbulb_fill, color: AppColors.guardianAmber, size: 18),
const SizedBox(width: 10),
Expanded(
child: _buildFormattedText(quoteText, fontSize: fontSize, baseColor: Colors.white.withAlpha(235)),
),
],
),
);
}
// 3. Sub-bullet (Indented with spaces + bullet)
final isIndented = line.startsWith(' ') || line.startsWith('\t');
if (isIndented && (trimmed.startsWith('-') || trimmed.startsWith('•') || trimmed.startsWith('*'))) {
final itemText = trimmed.replaceFirst(RegExp(r'^[-•\*]\s*'), '');
return Padding(
padding: const EdgeInsets.only(right: 18, top: 4, bottom: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
margin: const EdgeInsets.only(top: 7),
width: 5,
height: 5,
decoration: BoxDecoration(
color: AppColors.saqelCyan.withAlpha(160),
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Expanded(
child: _buildFormattedText(itemText, fontSize: fontSize - 0.5),
),
],
),
);
}
// 4. Primary Bullet (- or • or *)
if (trimmed.startsWith('-') || trimmed.startsWith('•') || trimmed.startsWith('*')) {
final itemText = trimmed.replaceFirst(RegExp(r'^[-•\*]\s*'), '');
return Padding(
padding: const EdgeInsets.only(right: 6, top: 6, bottom: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
margin: const EdgeInsets.only(top: 7),
width: 7,
height: 7,
decoration: const BoxDecoration(
color: AppColors.appleBlue,
shape: BoxShape.circle,
),
),
const SizedBox(width: 10),
Expanded(
child: _buildFormattedText(itemText, fontSize: fontSize),
),
],
),
);
}
// 5. Standard paragraph line
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: _buildFormattedText(trimmed, fontSize: fontSize),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -618,31 +830,36 @@ class _CurriculumDocumentViewerScreenState extends State<CurriculumDocumentViewe
else
..._sections.map((section) {
return Padding(
padding: const EdgeInsets.only(bottom: 14),
padding: const EdgeInsets.only(bottom: 16),
child: LuxuryCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
section.title,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 14.5),
),
const SizedBox(height: 10),
...section.lines.map((line) {
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Text(
line,
style: TextStyle(
color: line.startsWith('-') || line.startsWith('•')
? AppColors.textSecondaryDark
: Colors.white.withAlpha(230),
fontSize: _fontSize,
height: 1.55,
Row(
children: [
Container(
width: 4,
height: 18,
decoration: BoxDecoration(
color: AppColors.saqelCyan,
borderRadius: BorderRadius.circular(2),
),
),
);
}),
const SizedBox(width: 10),
Expanded(
child: Text(
_cleanFormulas(section.title),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w800,
fontSize: 15.5,
),
),
),
],
),
const SizedBox(height: 12),
...section.lines.map((line) => _buildSectionLine(line, _fontSize)),
],
),
),
@@ -135,10 +135,10 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
icon: Icon(CupertinoIcons.money_dollar_circle_fill, size: 18),
text: 'حاسبة المواريث والتجويد ⚖️',
);
} else if (_isHistorySubject || norm == 'تاريخ الأردن') {
} else if (_isHistorySubject || norm == 'تاريخ الأردن' || norm == 'التاريخ') {
return const Tab(
icon: Icon(CupertinoIcons.time_solid, size: 18),
text: 'الخط الزمني لتاريخ الأردن 🏛️',
text: 'الخط الزمني الشامل والتاريخ 🏛️',
);
} else if (_isChemistrySubject || norm == 'الكيمياء') {
return const Tab(
@@ -165,43 +165,59 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
icon: Icon(CupertinoIcons.desktopcomputer, size: 18),
text: 'مختبر الخوارزميات والبرمجة 💻',
);
} else {
} else if (norm == 'الجغرافيا') {
return const Tab(
icon: Icon(CupertinoIcons.map, size: 18),
text: 'مختبر الجغرافيا والخرائط 🗺️',
);
} else if (norm == 'التربية الوطنية والمدنية') {
return const Tab(
icon: Icon(CupertinoIcons.person_3_fill, size: 18),
text: 'مختبر المواطنة والمجتمع 🏛️',
);
} else if (_isPhysicsSubject || norm == 'الفيزياء') {
return const Tab(
icon: Icon(CupertinoIcons.bolt_fill, size: 18),
text: 'مختبر الفيزياء والميكانيكا ⚡',
);
} else {
return const Tab(
icon: Icon(CupertinoIcons.lab_flask_solid, size: 18),
text: 'المختبر الافتراضي التفاعلي 🧪',
);
}
}
Widget get _dynamicLabWidget {
Widget? specializedTool;
String? specializedTitle;
final norm = Grade10LabsRegistry.normalizeSubject(widget.subject.title);
if (_isMathSubject) {
if (_isMathSubject || norm == 'الرياضيات') {
specializedTool = const MathInteractiveLabView();
specializedTitle = 'معمل جيوجبرا 📐';
} else if (_isEnglishSubject) {
} else if (_isEnglishSubject || norm == 'اللغة الإنجليزية') {
specializedTool = const EnglishInteractiveLabView();
specializedTitle = 'مدرب الصوتيات 🎙️';
} else if (_isArabicSubject) {
} else if (_isArabicSubject || norm == 'العربية لغتي') {
specializedTool = const ArabicInteractiveLabView();
specializedTitle = 'شجرة الإعراب 📜';
} else if (_isIslamicSubject) {
} else if (_isIslamicSubject || norm == 'التربية الإسلامية') {
specializedTool = const IslamicInteractiveLabView();
specializedTitle = 'حاسبة المواريث ⚖️';
} else if (_isHistorySubject) {
} else if (_isHistorySubject || norm == 'تاريخ الأردن' || norm == 'التاريخ') {
specializedTool = const HistoryInteractiveTimelineView();
specializedTitle = 'الخط الزمني الشامل 🏛️';
} else if (_isChemistrySubject) {
} else if (_isChemistrySubject || norm == 'الكيمياء') {
specializedTool = const ChemistryInteractiveLabView();
specializedTitle = 'طيف ذرة بور ⚗️';
} else if (_isBiologySubject) {
} else if (_isBiologySubject || norm == 'العلوم الحياتية') {
specializedTool = const BiologyInteractiveLabView();
specializedTitle = 'المجهر الرقمي 🔬';
} else if (_isEarthSciencesSubject) {
} else if (_isEarthSciencesSubject || norm == 'علوم الأرض والبيئة') {
specializedTool = const EarthSciencesInteractiveLabView();
specializedTitle = 'دورة الصخور والطبقات 🌍';
} else if (_isPhysicsSubject) {
} else if (_isPhysicsSubject || norm == 'الفيزياء') {
specializedTool = const PhysicsInteractiveLabView();
specializedTitle = 'محاكاة المتجهات ⚡';
}
@@ -20,6 +20,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/utils/saqel_toast.dart';
import '../../../core/config/app_config.dart';
import '../../../core/services/storage_service.dart';
import '../../../data/models/subject_model.dart';
import '../../../logic/cubits/auth_cubit.dart';
@@ -81,15 +82,23 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
}
final token = await StorageService().getToken();
final headers = <String, String>{};
if (token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
}
String finalVideoUrl = videoUrl;
if (token != null && token.isNotEmpty && finalVideoUrl.contains('/api/videos/') && !finalVideoUrl.contains('token=')) {
final sep = finalVideoUrl.contains('?') ? '&' : '?';
finalVideoUrl = '$finalVideoUrl${sep}token=${Uri.encodeComponent(token)}';
// IMPORTANT: Check whether the URL points to our own Saqel API or to an external CDN (BunnyCDN, Cloudflare R2).
// NEVER send the Saqel user JWT Bearer token to external CDNs! CDNs reject unauthorized Bearer headers
// with HTTP 403 Forbidden ("Resource is protected by access token").
final isOurBackend = finalVideoUrl.contains('/api/videos/') ||
finalVideoUrl.startsWith(AppConfig.baseUrl) ||
finalVideoUrl.contains('localhost') ||
finalVideoUrl.contains('127.0.0.1');
final headers = <String, String>{};
if (isOurBackend && token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
if (!finalVideoUrl.contains('token=')) {
final sep = finalVideoUrl.contains('?') ? '&' : '?';
finalVideoUrl = '$finalVideoUrl${sep}token=${Uri.encodeComponent(token)}';
}
}
_videoController?.dispose();
@@ -119,6 +128,38 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
}
}
}).catchError((err) {
final errStr = err.toString();
// Resilient Fallback: If 403 Forbidden or Access Token error occurred on an external URL with token query params,
// retry once without the token parameters (e.g. public pull zones or direct CDNs).
if ((errStr.contains('403') || errStr.contains('token') || errStr.contains('permission')) &&
finalVideoUrl.contains('?')) {
final strippedUrl = finalVideoUrl.split('?').first;
if (strippedUrl != finalVideoUrl && strippedUrl.isNotEmpty) {
_videoController?.dispose();
_videoController = VideoPlayerController.networkUrl(
Uri.parse(strippedUrl),
httpHeaders: const {},
)..initialize().then((_) {
if (mounted) {
setState(() {
_isVideoInitialized = true;
_videoInitError = null;
});
_revealVideoControls();
if (shouldPlay) _videoController!.play();
}
}).catchError((retryErr) {
if (mounted) {
setState(() {
_isVideoInitialized = false;
_videoInitError = 'تعذر تشغيل بث الفيديو من المصدر السحابي، يمكنك استخدام السبورة التفاعلية للشرح.';
});
}
});
return;
}
}
if (mounted) {
setState(() {
_isVideoInitialized = false;
@@ -407,26 +448,48 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 12),
),
const SizedBox(height: 14),
ElevatedButton.icon(
icon: const Icon(CupertinoIcons.arrow_clockwise, size: 16),
label: const Text('بدء تشغيل بث الفيديو المباشر 🎥'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.saqelCyan,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
onPressed: () {
setState(() {
_videoInitError = null;
_isVideoInitialized = false;
});
_initPlayer(
state.playbackData.videoUrl,
state.currentPositionSeconds,
true,
);
},
Wrap(
alignment: WrapAlignment.center,
spacing: 10,
runSpacing: 10,
children: [
ElevatedButton.icon(
icon: const Icon(CupertinoIcons.arrow_clockwise, size: 16),
label: const Text('إعادة محاولة البث 🎥'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.saqelCyan,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
onPressed: () {
setState(() {
_videoInitError = null;
_isVideoInitialized = false;
});
_initPlayer(
state.playbackData.videoUrl,
state.currentPositionSeconds,
true,
);
},
),
OutlinedButton.icon(
icon: const Icon(CupertinoIcons.mic_fill, size: 16, color: AppColors.appleBlue),
label: const Text('السبورة والشرح الصوتي 🎙️', style: TextStyle(color: Colors.white)),
style: OutlinedButton.styleFrom(
side: BorderSide(color: AppColors.appleBlue.withAlpha(150)),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
onPressed: () {
setState(() {
_showChalkboardMode = true;
_videoInitError = null;
});
},
),
],
),
],
),
@@ -586,7 +586,7 @@ const LabIdentity kFinanceBudgetToolIdentity = LabIdentity(
const LabIdentity kHistoryJordanChronologyToolIdentity = LabIdentity(
toolKey: 'history_jordan_chronology',
subjectAr: 'تاريخ الأردن',
subjectAr: 'التاريخ',
lessonAr: 'التسلسل الزمني التفاعلي',
);
@@ -339,8 +339,12 @@ class Grade10LabsRegistry {
if (s.contains('وطنية') || s.contains('مدنية') || s.contains('civics')) {
return 'التربية الوطنية والمدنية';
}
if (s.contains('أردن') || s.contains('اردن')) return 'تاريخ الأردن';
if (s.contains('تاريخ') || s.contains('history')) return 'التاريخ';
if (s.contains('تاريخ') ||
s.contains('أردن') ||
s.contains('اردن') ||
s.contains('history')) {
return 'التاريخ';
}
if (s.contains('رقمية') || s.contains('حاسوب') || s.contains('digital')) {
return 'المهارات الرقمية';
}
@@ -353,7 +357,11 @@ class Grade10LabsRegistry {
final direct = bySubject(norm);
if (direct.isNotEmpty) return direct;
return all
.where((e) => e.subjectAr.contains(norm) || norm.contains(e.subjectAr))
.where((e) =>
e.subjectAr == norm ||
(norm == 'التاريخ' && (e.subjectAr == 'التاريخ' || e.subjectAr == 'تاريخ الأردن')) ||
e.subjectAr.contains(norm) ||
norm.contains(e.subjectAr))
.toList();
}
@@ -361,9 +369,14 @@ class Grade10LabsRegistry {
/// Defaults to true in development so that the reviewer/teacher can immediately test the labs.
static bool previewModeEnabled = true;
/// Exact identity lookup.
/// Exact identity lookup strictly isolated per subject.
/// When [previewModeEnabled] is active or [allowPreview] is true, returns the lab for testing.
/// When false (strict production gate), returns ONLY when released & carrying a published bundle.
///
/// CRITICAL INTEGRITY RULE:
/// Never leak labs across subjects. A Physics lab must NEVER appear under History, Geography,
/// Digital Skills, Civics, or any other subject. If a subject has no matching candidate lab,
/// this method returns null immediately.
static Grade10LabEntry? findForLesson({
required String subjectTitle,
required String lessonTitle,
@@ -372,42 +385,55 @@ class Grade10LabsRegistry {
bool? allowPreview,
}) {
final usePreview = allowPreview ?? previewModeEnabled;
final candidates = bySubjectNormalized(subjectTitle);
// Subject Isolation Gate: If no candidate labs exist for this subject, return null immediately.
if (candidates.isEmpty) {
return null;
}
if (usePreview) {
// 1. Exact match on curriculumLessonId within candidate subject labs
if (curriculumLessonId != null && curriculumLessonId.isNotEmpty) {
final exact = entryForCurriculumLessonId(curriculumLessonId);
if (exact != null) return exact;
}
if (lessonId != null && lessonId.isNotEmpty) {
for (final e in all) {
if (e.identity.curriculumLessonId.contains(lessonId) ||
e.identity.toolKey == lessonId ||
e.identity.sourceMarkdown.contains(lessonId)) {
return e;
}
}
}
final candidates = bySubjectNormalized(subjectTitle);
if (candidates.isNotEmpty) {
final t = lessonTitle.toLowerCase();
for (final c in candidates) {
if (t.contains(c.lessonAr.toLowerCase()) ||
c.lessonAr.toLowerCase().contains(t)) {
if (c.identity.curriculumLessonId == curriculumLessonId) {
return c;
}
}
if (lessonId != null && lessonId.isNotEmpty) {
for (final c in candidates) {
if (c.identity.lessonKey == lessonId ||
c.identity.curriculumLessonId.endsWith(lessonId)) {
return c;
}
}
// 2. Lesson Title fuzzy matching strictly within candidate subject labs
final t = lessonTitle.toLowerCase().trim();
for (final c in candidates) {
final ct = c.lessonAr.toLowerCase().trim();
if (t == ct || (ct.length > 4 && (t.contains(ct) || ct.contains(t)))) {
return c;
}
}
// 3. Lesson Key / Tool Key match strictly within candidate subject labs
if (lessonId != null && lessonId.isNotEmpty) {
for (final c in candidates) {
if (c.identity.lessonKey == lessonId ||
c.identity.toolKey == lessonId ||
c.identity.curriculumLessonId.endsWith(lessonId) ||
c.identity.sourceMarkdown.contains(lessonId)) {
return c;
}
}
return null;
}
return null;
}
return publishedEntryForCurriculumLessonId(curriculumLessonId ?? '');
if (curriculumLessonId != null && curriculumLessonId.isNotEmpty) {
for (final c in candidates) {
if (c.identity.curriculumLessonId == curriculumLessonId && c.identity.isPublished) {
return c;
}
}
}
return null;
}
static Grade10LabEntry? entryForCurriculumLessonId(