import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:signalr_netcore/signalr_client.dart'; import '../config/api_config.dart'; import '../models/guess_score_summary.dart'; import '../models/incoming_question.dart'; import '../models/question_submit_result.dart'; import 'auth_service.dart'; import 'questions_api_service.dart'; /// Maintains a SignalR connection to receive incoming questions from the API. class QuestionsHubService { QuestionsHubService._(); static final QuestionsHubService instance = QuestionsHubService._(); final QuestionsApiService _api = QuestionsApiService(); final ValueNotifier hasPendingQuestion = ValueNotifier(false); final ValueNotifier pendingQuestion = ValueNotifier(null); final ValueNotifier pendingQuestionCount = ValueNotifier(0); final ValueNotifier questionPanelOpen = ValueNotifier(false); final ValueNotifier> questionQueue = ValueNotifier>([]); final ValueNotifier questionActionBusy = ValueNotifier(false); final ValueNotifier guessScoreSummary = ValueNotifier(GuessScoreSummary.empty); HubConnection? _connection; bool _connecting = false; IncomingQuestion? get currentQuestion { final IncomingQuestion? pending = pendingQuestion.value; final List queue = questionQueue.value; // After an answer, [pendingQuestion] advances but the old card can remain at // queue[0] until the queue is replaced — prefer the active pending target. if (pending != null && (queue.isEmpty || queue.first.id != pending.id)) { return pending; } if (queue.isNotEmpty) { return queue.first; } return pending; } /// Login hook: load persisted score, bootstrap question state, then SignalR. Future onLogin() async { await _refreshGuessScoreSummary(); final ({IncomingQuestion? question, GuessScoreSummary? score}) boot = await _api.bootstrapOnLogin(); if (boot.score != null) { guessScoreSummary.value = boot.score!; } else { await _refreshGuessScoreSummary(); } if (boot.question != null) { _applyIncoming(boot.question!); } await connect(); } Future connect() async { if (_connecting) { return; } if (_connection?.state == HubConnectionState.Connected) { return; } final String? token = await AuthService.instance.getIdToken(); if (token == null) { return; } _connecting = true; try { await disconnect(); final HubConnection connection = HubConnectionBuilder() .withUrl( questionsHubUrl, options: HttpConnectionOptions( accessTokenFactory: () async => await AuthService.instance.getIdToken() ?? '', requestTimeout: 30000, ), ) .withAutomaticReconnect( retryDelays: [0, 2000, 5000, 10000], ) .build(); connection.on('ReceiveQuestion', _onReceiveQuestion); _connection = connection; await connection.start(); } catch (e, st) { debugPrint('Questions hub connect failed: $e\n$st'); await disconnect(); } finally { _connecting = false; } } void _onReceiveQuestion(List? arguments) { if (arguments == null || arguments.isEmpty) { return; } final Object? raw = arguments.first; final Map json; if (raw is Map) { json = raw; } else if (raw is Map) { json = Map.from(raw); } else { debugPrint('ReceiveQuestion: unexpected payload type ${raw.runtimeType}'); return; } final IncomingQuestion question = IncomingQuestion.fromJson(json); _applyIncoming(question); } void _applyIncoming(IncomingQuestion question) { final int count = question.unansweredCount < 1 ? 1 : question.unansweredCount; pendingQuestion.value = question; pendingQuestionCount.value = count; hasPendingQuestion.value = count >= 1; if (questionPanelOpen.value) { final List queue = List.from( questionQueue.value, ); if (!queue.any((IncomingQuestion q) => q.id == question.id)) { queue.add(question); questionQueue.value = queue; } } } /// Opens the inline question panel and loads the full pending queue. Future openQuestionPanel() async { questionActionBusy.value = true; try { final List fetched = await _api.fetchUnanswered(); if (fetched.isNotEmpty) { questionQueue.value = fetched; pendingQuestion.value = fetched.first; pendingQuestionCount.value = fetched.length; hasPendingQuestion.value = true; } else if (pendingQuestion.value != null) { questionQueue.value = [pendingQuestion.value!]; } else { questionQueue.value = []; } questionPanelOpen.value = hasPendingQuestion.value; } finally { questionActionBusy.value = false; } } void closeQuestionPanel() { questionPanelOpen.value = false; } /// Swipe left: move current question to end of queue without answering. Future deferCurrentQuestion() async { final IncomingQuestion? question = currentQuestion; if (question == null || questionActionBusy.value) { return; } questionActionBusy.value = true; try { final List queue = List.from( questionQueue.value, ); if (queue.isEmpty) { return; } final IncomingQuestion current = queue.removeAt(0); queue.add(current); final int? serverCount = await _api.deferQuestion(questionId: current.id); if (serverCount != null) { final List refreshed = await _api.fetchUnanswered(); questionQueue.value = refreshed.isNotEmpty ? refreshed : queue; _syncPendingFromQueue(serverCount); } else { questionQueue.value = queue; _syncPendingFromQueue(queue.length); } } finally { questionActionBusy.value = false; } } /// Swipe right: submit slider value as the answer. Future submitCurrentAnswer({num answer = 0}) async { final IncomingQuestion? question = currentQuestion; if (question == null || questionActionBusy.value) { return; } final String answeredQuestionId = question.id; questionActionBusy.value = true; try { final QuestionSubmitResult? result = await _api.submitAnswer( questionId: answeredQuestionId, answer: answer, ); if (result == null) { return; } guessScoreSummary.value = result.score; if (result.nextQuestion != null) { _setActiveQuestion( result.nextQuestion!, unansweredCount: result.unansweredCount, ); return; } if (result.unansweredCount == 0) { _clearPendingUi(); return; } final List refreshed = await _api.fetchUnanswered(); final List remaining = refreshed .where((IncomingQuestion q) => q.id != answeredQuestionId) .toList(); if (remaining.isEmpty) { _clearPendingUi(); return; } _setActiveQuestion( remaining.first, unansweredCount: result.unansweredCount, queue: remaining, ); } finally { questionActionBusy.value = false; } } /// Makes [question] the sole active card (queue head matches [pendingQuestion]). void _setActiveQuestion( IncomingQuestion question, { required int unansweredCount, List? queue, }) { final int count = unansweredCount < 1 ? 1 : unansweredCount; pendingQuestion.value = question; pendingQuestionCount.value = count; hasPendingQuestion.value = true; if (questionPanelOpen.value) { questionQueue.value = queue ?? [question]; } } Future _refreshGuessScoreSummary() async { final GuessScoreSummary? score = await _api.fetchGuessScoreSummary(); if (score == null) { return; } guessScoreSummary.value = score; } /// Clears cumulative guess score and answer statistics on the server. Future resetGuessScoreSummary() async { final GuessScoreSummary? score = await _api.resetGuessScoreSummary(); if (score == null) { return false; } guessScoreSummary.value = score; return true; } void _syncPendingFromQueue(int count) { final List queue = questionQueue.value; if (queue.isEmpty || count == 0) { _clearPendingUi(); return; } pendingQuestion.value = queue.first; pendingQuestionCount.value = count; hasPendingQuestion.value = true; } /// Clears pending question UI; keeps the SignalR connection alive. void _clearPendingUi() { hasPendingQuestion.value = false; pendingQuestion.value = null; pendingQuestionCount.value = 0; questionQueue.value = []; questionPanelOpen.value = false; } void dismissPending() { _clearPendingUi(); } Future disconnect() async { final HubConnection? connection = _connection; _connection = null; if (connection != null) { try { await connection.stop(); } catch (_) { // Ignore shutdown errors. } } _clearPendingUi(); } /// Clears in-memory score (call on sign-out; score remains on server per UID). void clearGuessScoreCache() { guessScoreSummary.value = GuessScoreSummary.empty; } }