Commit ac3d4138 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix: audio mute bypass, TF timer race condition, analytics resilience, and login/profile validation

parent 48c75590
using System; using System;
using com.al_arcade.shared;
using Cysharp.Threading.Tasks; using Cysharp.Threading.Tasks;
using EasyTransition; using EasyTransition;
using Supabase.Gotrue; using Supabase.Gotrue;
...@@ -42,34 +43,26 @@ public class AppRouter : MonoBehaviour ...@@ -42,34 +43,26 @@ public class AppRouter : MonoBehaviour
private async UniTask Boot() private async UniTask Boot()
{ {
var cached = UserService.Instance.LoadFromCache();
// 1. Try cache first — instant
// var cached = UserService.Instance.LoadFromCache();
TransitionManager.Instance().onTransitionCutPointReached += HideSplash; TransitionManager.Instance().onTransitionCutPointReached += HideSplash;
// 2. Init Supabase
bool ready = await SupabaseManager.Instance.Initialize(); bool ready = await SupabaseManager.Instance.Initialize();
if (!ready) if (!ready)
{ {
// Offline but have cache — go to home if (cached != null) { GoToHome(); return; }
// if (cached != null) { GoToHome(); return; }
ShowError("Failed to connect"); ShowError("Failed to connect");
return; return;
} }
// 3. Ensure session
var authResult = await SupabaseAuthentication.Instance.EnsureSession(); var authResult = await SupabaseAuthentication.Instance.EnsureSession();
if (authResult.IsT1) if (authResult.IsT1)
{ {
// if (cached != null) { GoToHome(); return; } if (cached != null) { GoToHome(); return; }
GoToLogin(); GoToLogin();
return; return;
} }
// 4. Refresh from network var prof = await UserService.Instance.GetCurrentUser();
var profileResult = UserService.Instance.GetCurrentUser();
var prof = await profileResult;
prof.Switch( prof.Switch(
async success => async success =>
...@@ -79,8 +72,16 @@ public class AppRouter : MonoBehaviour ...@@ -79,8 +72,16 @@ public class AppRouter : MonoBehaviour
}, },
error => error =>
{ {
Debug.LogError($"[Boot] Failed to load user profile: {error}"); if (cached != null)
GoToLogin(); {
Debug.LogWarning($"[Boot] Network profile fetch failed, using cache");
GoToHome();
}
else
{
Debug.LogError($"[Boot] Failed to load user profile: {error}");
GoToLogin();
}
} }
); );
} }
...@@ -120,10 +121,16 @@ public class AppRouter : MonoBehaviour ...@@ -120,10 +121,16 @@ public class AppRouter : MonoBehaviour
public static async void Logout() public static async void Logout()
{ {
CleanupSingletons();
await SupabaseAuthentication.Instance.LogOut(); await SupabaseAuthentication.Instance.LogOut();
GoToLogin(); GoToLogin();
} }
private static void CleanupSingletons()
{
try { SSAudioManager.Instance?.StopMusic(); } catch { }
}
// ─── Helpers ───────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────
private static void HideSplash() private static void HideSplash()
{ {
......
...@@ -127,22 +127,31 @@ public class UserService ...@@ -127,22 +127,31 @@ public class UserService
if (response == null) if (response == null)
return new ErrorResult("User profile not found"); return new ErrorResult("User profile not found");
var oldUser = CurrentUser;
CurrentUser = response; CurrentUser = response;
IsFromCache = false; IsFromCache = false;
// SaveToCache(CurrentUser); SaveToCache(CurrentUser);
// if (oldUser != null && oldUser.Rank != CurrentUser.Rank) if (oldUser != null && oldUser.Rank != CurrentUser.Rank)
// OnRankChanged?.Invoke(oldUser.Rank, CurrentUser.Rank); OnRankChanged?.Invoke(oldUser.Rank, CurrentUser.Rank);
// if (oldUser != null && oldUser.Points != CurrentUser.Points) if (oldUser != null && oldUser.Points != CurrentUser.Points)
// OnPointsChanged?.Invoke(oldUser.Points, CurrentUser.Points); OnPointsChanged?.Invoke(oldUser.Points, CurrentUser.Points);
OnUserChanged?.Invoke(CurrentUser); OnUserChanged?.Invoke(CurrentUser);
return new UserResult(CurrentUser); return new UserResult(CurrentUser);
} }
catch (Exception ex) catch (Exception ex)
{ {
Debug.LogError($"[UserService] GetCurrentUser failed: {ex.Message} + {ex.StackTrace}"); Debug.LogWarning($"[UserService] GetCurrentUser network failed: {ex.Message}");
var fallback = LoadFromCache();
if (fallback != null)
{
Debug.Log("[UserService] Using cached user profile");
return new UserResult(fallback);
}
return new ErrorResult(ex.Message); return new ErrorResult(ex.Message);
} }
} }
......
...@@ -110,14 +110,26 @@ public static class EducationManager ...@@ -110,14 +110,26 @@ public static class EducationManager
// --- Generic ID Lookups --- // --- Generic ID Lookups ---
public static int GetGradeId(string name) => public static int GetGradeId(string name)
GradeMap.FirstOrDefault(x => x.Value == name.Trim()).Key; {
if (string.IsNullOrWhiteSpace(name)) return -1;
var match = GradeMap.FirstOrDefault(x => x.Value == name.Trim());
return GradeMap.ContainsKey(match.Key) ? match.Key : -1;
}
public static int GetTermId(string name) => public static int GetTermId(string name)
TermMap.FirstOrDefault(x => x.Value == name.Trim()).Key; {
if (string.IsNullOrWhiteSpace(name)) return -1;
var match = TermMap.FirstOrDefault(x => x.Value == name.Trim());
return TermMap.ContainsKey(match.Key) ? match.Key : -1;
}
public static int GetCurriculumId(string name) => public static int GetCurriculumId(string name)
CurriculumMap.FirstOrDefault(x => x.Value == name.Trim()).Key; {
if (string.IsNullOrWhiteSpace(name)) return -1;
var match = CurriculumMap.FirstOrDefault(x => x.Value == name.Trim());
return CurriculumMap.ContainsKey(match.Key) ? match.Key : -1;
}
// --- Lists for UI Dropdowns --- // --- Lists for UI Dropdowns ---
public static List<string> GradeList => GradeMap.Values.ToList(); public static List<string> GradeList => GradeMap.Values.ToList();
......
...@@ -35,31 +35,42 @@ public class LoginController : MonoBehaviour ...@@ -35,31 +35,42 @@ public class LoginController : MonoBehaviour
public async void RegisterAnon() public async void RegisterAnon()
{ {
var trimmed = username.text?.Trim();
int gradeId = EducationManager.GetGradeId(grade.value);
int termId = EducationManager.GetTermId(term.value);
int currId = EducationManager.GetCurriculumId(curriculum.value);
if (string.IsNullOrEmpty(trimmed)
|| trimmed.Length < 2
|| trimmed.Length > 30
|| gradeId < 0
|| termId < 0
|| currId < 0
|| string.IsNullOrEmpty(sex.value))
{
Debug.LogWarning("[Login] Validation failed — fill all fields, username 2-30 chars");
return;
}
register.text = "جاري التسجيل..."; register.text = "جاري التسجيل...";
register.SetEnabled(false); register.SetEnabled(false);
var auth = await SupabaseAuthentication.Instance.LoginAnon(); var auth = await SupabaseAuthentication.Instance.LoginAnon();
if (auth.IsT1) if (auth.IsT1)
{ {
Debug.LogError($"Authentication failed"); Debug.LogError("[Login] Authentication failed");
return;
}
// Check if all fields are filled
if (string.IsNullOrEmpty(username.text) || grade.value == null || sex.value == null || term.value == null || curriculum.value == null)
{
Debug.LogError("Please fill in all fields");
register.text = "تسجيل"; register.text = "تسجيل";
register.SetEnabled(true); register.SetEnabled(true);
return; return;
} }
var signUp = await UserService.Instance.CreateProfile( var signUp = await UserService.Instance.CreateProfile(
username: username.text, username: trimmed,
grade: EducationManager.GetGradeId(grade.value), grade: gradeId,
sex: sex.value, sex: sex.value,
term: EducationManager.GetTermId(term.value), term: termId,
curriculum: EducationManager.GetCurriculumId(curriculum.value) curriculum: currId
); );
signUp.Switch(user => signUp.Switch(user =>
...@@ -67,12 +78,10 @@ public class LoginController : MonoBehaviour ...@@ -67,12 +78,10 @@ public class LoginController : MonoBehaviour
AppRouter.GoToHome(); AppRouter.GoToHome();
}, error => }, error =>
{ {
Debug.LogError($"Failed to create user: {error.Message}"); Debug.LogError($"[Login] Failed to create user: {error.Message}");
register.text = "تسجيل"; register.text = "تسجيل";
register.SetEnabled(true); register.SetEnabled(true);
}); });
} }
......
...@@ -55,10 +55,19 @@ public class ProfileController : MonoBehaviour ...@@ -55,10 +55,19 @@ public class ProfileController : MonoBehaviour
private void UpdateProfile() private void UpdateProfile()
{ {
int gradeId = EducationManager.GetGradeId(Grade.value);
int termId = EducationManager.GetTermId(Term.value);
if (gradeId < 0 || termId < 0)
{
Debug.LogWarning("[Profile] Invalid grade or term selection");
return;
}
UserService.Instance.UpdateProfile( UserService.Instance.UpdateProfile(
username: UsernameLabel.value, username: UsernameLabel.value,
grade: EducationManager.GetGradeId(Grade.value), grade: gradeId,
term: EducationManager.GetTermId(Term.value) term: termId
); );
} }
......
...@@ -514,7 +514,7 @@ namespace com.al_arcade.cs ...@@ -514,7 +514,7 @@ namespace com.al_arcade.cs
_scoreLbl.enabled = value; _scoreLbl.enabled = value;
if (value) if (value)
{ {
_scoreLbl.Text = "الوقت الموفر"; _scoreLbl.Text = "نقاط إضافية";
} }
else else
{ {
......
...@@ -87,7 +87,7 @@ public class ChallengeCanvas : MonoBehaviour ...@@ -87,7 +87,7 @@ public class ChallengeCanvas : MonoBehaviour
public void ShowChallengeResult(bool hasWon, int timeSaved, int pointsChange) public void ShowChallengeResult(bool hasWon, int timeSaved, int pointsChange)
{ {
statusText.Text = hasWon ? "كسبت التحدي" : "خسرت التحدي"; statusText.Text = hasWon ? "كسبت التحدي" : "خسرت التحدي";
timeSavedText.Text = hasWon ? $"الوقت الموفر: {timeSaved} ثانية" : ""; timeSavedText.Text = hasWon ? $"نقاط إضافية: {timeSaved}" : "";
pointsEarnedText.Text = hasWon ? $"النقاط المكتسبة: {pointsChange}" : $"خسرت {pointsChange} نقطة"; pointsEarnedText.Text = hasWon ? $"النقاط المكتسبة: {pointsChange}" : $"خسرت {pointsChange} نقطة";
restartButton.gameObject.SetActive(true); restartButton.gameObject.SetActive(true);
......
...@@ -377,7 +377,8 @@ namespace com.al_arcade.mcq ...@@ -377,7 +377,8 @@ namespace com.al_arcade.mcq
if (particles != null && player != null) if (particles != null && player != null)
particles.PlayCorrectBurst(player.transform.position + Vector3.up * 2f); particles.PlayCorrectBurst(player.transform.position + Vector3.up * 2f);
_mainCamera.DOColor(SSColorPalette.CorrectWord, 1).SetEase(Ease.Flash, 2); DOTween.Kill(_mainCamera, "camColor");
_mainCamera.DOColor(SSColorPalette.CorrectWord, 1).SetEase(Ease.Flash, 2).SetId("camColor");
} }
else else
{ {
...@@ -401,7 +402,8 @@ namespace com.al_arcade.mcq ...@@ -401,7 +402,8 @@ namespace com.al_arcade.mcq
if (particles != null && player != null) if (particles != null && player != null)
particles.PlayWrongBurst(player.transform.position + Vector3.up * 2f); particles.PlayWrongBurst(player.transform.position + Vector3.up * 2f);
_mainCamera.DOColor(SSColorPalette.WrongWord, 1).SetEase(Ease.Flash, 2); DOTween.Kill(_mainCamera, "camColor");
_mainCamera.DOColor(SSColorPalette.WrongWord, 1).SetEase(Ease.Flash, 2).SetId("camColor");
} }
if (uiManager != null) if (uiManager != null)
...@@ -573,7 +575,8 @@ namespace com.al_arcade.mcq ...@@ -573,7 +575,8 @@ namespace com.al_arcade.mcq
private void CameraFeedback(bool correct) private void CameraFeedback(bool correct)
{ {
_mainCamera.DOFieldOfView(correct ? 78f : 76f, 0.2f).SetEase(Ease.OutQuad); DOTween.Kill(_mainCamera, "camFov");
_mainCamera.DOFieldOfView(correct ? 78f : 76f, 0.2f).SetEase(Ease.OutQuad).SetId("camFov");
} }
// ─── End Sequences ─────────────────────────────────────────────────── // ─── End Sequences ───────────────────────────────────────────────────
......
...@@ -81,8 +81,8 @@ namespace com.al_arcade.mcq ...@@ -81,8 +81,8 @@ namespace com.al_arcade.mcq
runEffect.Play(); runEffect.Play();
transform.DOBlendableMoveBy(Vector3.forward * 5, .2f); transform.DOBlendableMoveBy(Vector3.forward * 5, .2f);
audioSource.PlayOneShot(dashClip); SSAudioManager.Instance?.Play(dashClip);
audioSource.PlayOneShot(correctAnswerClip); SSAudioManager.Instance?.Play(correctAnswerClip);
// RunForward(maxForwardSpeed); // RunForward(maxForwardSpeed);
} }
......
...@@ -121,14 +121,7 @@ namespace com.al_arcade.mcq ...@@ -121,14 +121,7 @@ namespace com.al_arcade.mcq
{ {
_scoreText.enabled = value; _scoreText.enabled = value;
_scoreLbl.enabled = value; _scoreLbl.enabled = value;
if (value) _scoreLbl.Text = value ? "نقاط إضافية" : "النقاط";
{
_scoreLbl.Text = "الوقت الموفر";
}
else
{
_scoreLbl.Text = "النقاط";
}
} }
public void SetStreak(int streak) public void SetStreak(int streak)
......
using System; using System;
using System.Collections; using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.Text; using System.Text;
using UnityEngine; using UnityEngine;
using UnityEngine.Networking; using UnityEngine.Networking;
...@@ -46,9 +47,110 @@ namespace com.al_arcade.shared ...@@ -46,9 +47,110 @@ namespace com.al_arcade.shared
return Instance; return Instance;
} }
// ═══════════════════════════════════════════════════
// OFFLINE ANALYTICS QUEUE
// ═══════════════════════════════════════════════════
private List<AttemptData> _pendingAttempts = new();
private bool _flushing;
private string PendingPath => Path.Combine(Application.persistentDataPath, "pending_attempts.json");
private void Start()
{
LoadPendingFromDisk();
StartCoroutine(AutoFlushLoop());
}
private void LoadPendingFromDisk()
{
try
{
if (!File.Exists(PendingPath)) return;
var json = File.ReadAllText(PendingPath);
var loaded = JsonConvert.DeserializeObject<List<AttemptData>>(json);
if (loaded != null && loaded.Count > 0)
{
_pendingAttempts.AddRange(loaded);
Debug.Log($"[SSApi] Loaded {loaded.Count} pending attempts from disk");
}
}
catch (Exception e)
{
Debug.LogWarning($"[SSApi] Failed to load pending attempts: {e.Message}");
}
}
private void SavePendingToDisk()
{
try
{
if (_pendingAttempts.Count == 0)
{
if (File.Exists(PendingPath)) File.Delete(PendingPath);
return;
}
var json = JsonConvert.SerializeObject(_pendingAttempts);
File.WriteAllText(PendingPath, json);
}
catch (Exception e)
{
Debug.LogWarning($"[SSApi] Failed to save pending attempts: {e.Message}");
}
}
private void QueueAttempt(AttemptData attempt)
{
_pendingAttempts.Add(attempt);
SavePendingToDisk();
Debug.Log($"[SSApi] Queued attempt (total pending: {_pendingAttempts.Count})");
}
private IEnumerator AutoFlushLoop()
{
while (true)
{
yield return new WaitForSeconds(60f);
if (_pendingAttempts.Count > 0 && !_flushing)
yield return FlushPendingAttempts();
}
}
private IEnumerator FlushPendingAttempts()
{
if (_pendingAttempts.Count == 0 || _flushing) yield break;
_flushing = true;
var batch = _pendingAttempts.ToArray();
var payload = new BatchAttemptPayload { attempts = batch };
var json = JsonConvert.SerializeObject(payload,
new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore });
bool success = false;
yield return PostJson("report_batch", json,
_ => success = true,
err => Debug.LogWarning($"[SSApi] Flush failed: {err}"));
if (success)
{
Debug.Log($"[SSApi] Flushed {batch.Length} pending attempts");
_pendingAttempts.Clear();
SavePendingToDisk();
}
_flushing = false;
}
// ═══════════════════════════════════════════════════ // ═══════════════════════════════════════════════════
// LOW-LEVEL HTTP HELPERS // LOW-LEVEL HTTP HELPERS
// ═══════════════════════════════════════════════════ // ═══════════════════════════════════════════════════
private void AttachAuth(UnityWebRequest req)
{
var token = SupabaseManager.Instance?.Supabase()?.Auth?.CurrentSession?.AccessToken;
if (!string.IsNullOrEmpty(token))
req.SetRequestHeader("Authorization", "Bearer " + token);
}
private IEnumerator GetRequest(string action, private IEnumerator GetRequest(string action,
Dictionary<string, string> parameters, Dictionary<string, string> parameters,
Action<string> onSuccess, Action<string> onSuccess,
...@@ -66,6 +168,7 @@ namespace com.al_arcade.shared ...@@ -66,6 +168,7 @@ namespace com.al_arcade.shared
using var req = UnityWebRequest.Get(url); using var req = UnityWebRequest.Get(url);
req.timeout = timeoutSeconds; req.timeout = timeoutSeconds;
AttachAuth(req);
yield return req.SendWebRequest(); yield return req.SendWebRequest();
...@@ -95,6 +198,7 @@ namespace com.al_arcade.shared ...@@ -95,6 +198,7 @@ namespace com.al_arcade.shared
using var req = UnityWebRequest.Post(baseUrl, form); using var req = UnityWebRequest.Post(baseUrl, form);
req.timeout = timeoutSeconds; req.timeout = timeoutSeconds;
AttachAuth(req);
yield return req.SendWebRequest(); yield return req.SendWebRequest();
...@@ -119,6 +223,7 @@ namespace com.al_arcade.shared ...@@ -119,6 +223,7 @@ namespace com.al_arcade.shared
req.uploadHandler = new UploadHandlerRaw(bodyRaw); req.uploadHandler = new UploadHandlerRaw(bodyRaw);
req.downloadHandler = new DownloadHandlerBuffer(); req.downloadHandler = new DownloadHandlerBuffer();
req.SetRequestHeader("Content-Type", "application/json"); req.SetRequestHeader("Content-Type", "application/json");
AttachAuth(req);
req.timeout = timeoutSeconds; req.timeout = timeoutSeconds;
yield return req.SendWebRequest(); yield return req.SendWebRequest();
...@@ -420,16 +525,31 @@ namespace com.al_arcade.shared ...@@ -420,16 +525,31 @@ namespace com.al_arcade.shared
string json = JsonConvert.SerializeObject(attempt, string json = JsonConvert.SerializeObject(attempt,
new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }); new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore });
bool failed = false;
yield return PostJson("report_attempt", json, yield return PostJson("report_attempt", json,
text => text =>
{ {
var r = Parse<ReportAttemptResponse>(text); var r = Parse<ReportAttemptResponse>(text);
if (r is { success: true }) if (r is { success: true })
{
onSuccess?.Invoke(r); onSuccess?.Invoke(r);
if (_pendingAttempts.Count > 0 && !_flushing)
StartCoroutine(FlushPendingAttempts());
}
else else
{
failed = true;
onError?.Invoke(r?.error ?? "Failed to report attempt"); onError?.Invoke(r?.error ?? "Failed to report attempt");
}
}, },
onError); err =>
{
failed = true;
onError?.Invoke(err);
});
if (failed)
QueueAttempt(attempt);
} }
/// <summary>Quick overload: report with minimal data.</summary> /// <summary>Quick overload: report with minimal data.</summary>
......
...@@ -132,7 +132,7 @@ namespace com.al_arcade.tf ...@@ -132,7 +132,7 @@ namespace com.al_arcade.tf
public void SubmitAnswer(bool answer) public void SubmitAnswer(bool answer)
{ {
if (!_waitingForAnswer) return; if (!_waitingForAnswer || !_timerRunning) return;
_waitingForAnswer = false; _waitingForAnswer = false;
_pendingAnswer = answer ? 1 : 0; _pendingAnswer = answer ? 1 : 0;
} }
...@@ -171,7 +171,7 @@ namespace com.al_arcade.tf ...@@ -171,7 +171,7 @@ namespace com.al_arcade.tf
if (handController != null) handController.SetReady(true); if (handController != null) handController.SetReady(true);
while (_pendingAnswer < 0) while (_pendingAnswer < 0 && _timerRunning)
{ {
if (Input.GetKeyDown(KeyCode.LeftArrow) || Input.GetKeyDown(KeyCode.A)) if (Input.GetKeyDown(KeyCode.LeftArrow) || Input.GetKeyDown(KeyCode.A))
SubmitAnswer(true); SubmitAnswer(true);
...@@ -180,6 +180,8 @@ namespace com.al_arcade.tf ...@@ -180,6 +180,8 @@ namespace com.al_arcade.tf
yield return null; yield return null;
} }
if (_pendingAnswer < 0) yield break;
if (handController != null) handController.SetReady(false); if (handController != null) handController.SetReady(false);
_state = TfGameState.Feedback; _state = TfGameState.Feedback;
......
...@@ -59,10 +59,7 @@ namespace com.al_arcade.tf ...@@ -59,10 +59,7 @@ namespace com.al_arcade.tf
private void PlayMoveSound() private void PlayMoveSound()
{ {
if (moveSound == null) return; SSAudioManager.Instance?.Play(moveSound, 1f, 1.2f);
moveAudioSource.pitch = 1.2f;
moveAudioSource.PlayOneShot(moveSound);
} }
public void Build(int stepsToWin, float stepSize, Vector3 cameraPos) public void Build(int stepsToWin, float stepSize, Vector3 cameraPos)
......
...@@ -158,7 +158,7 @@ namespace com.al_arcade.tf ...@@ -158,7 +158,7 @@ namespace com.al_arcade.tf
{ {
_scoreText.enabled = value; _scoreText.enabled = value;
_scoreLbl.enabled = value; _scoreLbl.enabled = value;
_scoreLbl.Text = value ? "الوقت الموفر" : "النقاط"; _scoreLbl.Text = value ? "نقاط إضافية" : "النقاط";
} }
public void SetStreak(int s) public void SetStreak(int s)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment