import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:signalr_netcore/signalr_client.dart'; import '../config/api_config.dart'; import '../models/guess_score_reset_result.dart'; import '../models/guess_score_summary.dart'; import '../models/incoming_question.dart'; import '../models/question_defer_result.dart'; import '../models/question_source_preference.dart'; import '../models/question_submit_result.dart'; import '../repositories/question_source_config_repository.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 scoreResetBusy = ValueNotifier(false); final ValueNotifier spellingResetBusy = 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); if (questionActionBusy.value) { debugPrint('ReceiveQuestion: ignored push because action is busy'); return; } if (pendingQuestion.value != null && pendingQuestion.value!.id != question.id) { debugPrint('ReceiveQuestion: ignored push because another question is active'); return; } _applyIncoming(question); } void _applyIncoming(IncomingQuestion question) { if (!_acceptsQuestionSource(question)) { return; } 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 { // Align client filters with server-side source preferences before fetch. await QuestionSourceConfigRepository.instance.loadFromRemote(); final List active = await _fetchUnansweredForPanel(); questionPanelOpen.value = true; if (active.isNotEmpty) { questionQueue.value = active; pendingQuestion.value = active.first; pendingQuestionCount.value = active.length; hasPendingQuestion.value = true; } else if (pendingQuestion.value != null && _acceptsQuestionSource(pendingQuestion.value!)) { questionQueue.value = [pendingQuestion.value!]; pendingQuestionCount.value = 1; hasPendingQuestion.value = true; } else { pendingQuestion.value = null; questionQueue.value = []; hasPendingQuestion.value = _shouldKeepPanelOpenWithoutQuestion(); } } finally { questionActionBusy.value = false; } } bool _shouldKeepPanelOpenWithoutQuestion() { final List sources = QuestionSourceConfigRepository.instance.current; return QuestionSourceConfigRepository.isSpellingOnlyEnabled(sources) || QuestionSourceConfigRepository.isMarketOnlyEnabled(sources); } bool _acceptsQuestionSource(IncomingQuestion question) { final List sources = QuestionSourceConfigRepository.instance.current; if (QuestionSourceConfigRepository.isSpellingOnlyEnabled(sources)) { return question.isSpelling; } if (QuestionSourceConfigRepository.isMarketOnlyEnabled(sources)) { return question.isMarketHistory; } if (QuestionSourceConfigRepository.isSpellingFullyWeighted(sources) && question.isMarketHistory) { return false; } if (!QuestionSourceConfigRepository.isMarketHistoryEnabled(sources)) { return question.sourceId != 'market_history'; } if (!QuestionSourceConfigRepository.isSpellingEnabled(sources)) { return !question.isSpelling; } return true; } List _filterForEnabledSources( List questions, ) { return questions.where(_acceptsQuestionSource).toList(growable: false); } /// Polls with a spinner-covered retry loop until the server has the right /// question type ready. /// /// When spelling is configured at any percentage the server may still be /// running catalog seeding or pipeline setup. We keep retrying (the spinner /// overlay from [questionActionBusy] blocks the UI) until: /// • a spelling question appears in the result, or /// • spelling is not 100% and we have any non-empty result (market is valid /// at that point in the mix), or /// • [_maxPanelFetchAttempts] are exhausted. static const int _maxPanelFetchAttempts = 10; static const Duration _panelFetchPause = Duration(milliseconds: 300); Future> _fetchUnansweredForPanel() async { final List sources = QuestionSourceConfigRepository.instance.current; final Map mix = QuestionSourceConfigRepository.effectiveMix(sources); final int spellingShare = mix['spelling'] ?? 0; // No spelling configured — single fetch is sufficient. if (spellingShare == 0) { return _api.fetchUnanswered(); } for (var attempt = 0; attempt < _maxPanelFetchAttempts; attempt++) { final List fetched = await _api.fetchUnanswered(); final List filtered = _filterForEnabledSources(fetched); // A spelling question is ready — accept immediately. if (filtered.any((IncomingQuestion q) => q.isSpelling)) { return filtered; } // Spelling is not the only source and we have something — valid mix result. if (filtered.isNotEmpty && spellingShare < 100) { return filtered; } // Queue is still empty or only has wrong-source questions — wait and retry. if (attempt < _maxPanelFetchAttempts - 1) { await Future.delayed(_panelFetchPause); } } final List finalFetch = await _api.fetchUnanswered(); return _filterForEnabledSources(finalFetch); } void closeQuestionPanel() { questionPanelOpen.value = false; } /// Replaces a queue row after tile-load spelling prepare succeeds. void applyPreparedQuestion(IncomingQuestion prepared) { final List queue = List.from(questionQueue.value); final int index = queue.indexWhere((IncomingQuestion q) => q.id == prepared.id); if (index >= 0) { queue[index] = prepared; questionQueue.value = queue; } if (pendingQuestion.value?.id == prepared.id) { pendingQuestion.value = prepared; } } /// Swipe left: move current question to end of queue without answering. Future deferCurrentQuestion() async { final IncomingQuestion? question = currentQuestion; if (question == null || questionActionBusy.value) { return; } final String skippedQuestionId = question.id; questionActionBusy.value = true; try { final QuestionDeferResult? result = await _api.deferQuestion( questionId: skippedQuestionId, ); if (result == null) { return; } if (result.unansweredCount == 0) { _clearPendingUi(); return; } final List refreshed = await _fetchUnansweredForPanel(); if (refreshed.isEmpty) { if (result.nextQuestion != null && _acceptsQuestionSource(result.nextQuestion!)) { IncomingQuestion nextQ = result.nextQuestion!; if (nextQ.needsPrepare) { try { final IncomingQuestion? prepared = await _api.prepareSpellingQuestion( questionId: nextQ.id, ); if (prepared != null) { nextQ = prepared; } } catch (e) { debugPrint('Failed to prepare next question in defer: $e'); } } _setActiveQuestion( nextQ, unansweredCount: result.unansweredCount, ); } else if (_shouldKeepPanelOpenWithoutQuestion()) { pendingQuestion.value = null; questionQueue.value = []; hasPendingQuestion.value = true; questionPanelOpen.value = true; } else { _clearPendingUi(); } return; } IncomingQuestion next = refreshed.first; if (refreshed.length > 1 && next.id == skippedQuestionId) { next = refreshed.firstWhere( (IncomingQuestion q) => q.id != skippedQuestionId, orElse: () => refreshed.first, ); } else if (result.nextQuestion != null && result.nextQuestion!.id != skippedQuestionId && _acceptsQuestionSource(result.nextQuestion!)) { next = result.nextQuestion!; } if (next.needsPrepare) { try { final IncomingQuestion? prepared = await _api.prepareSpellingQuestion( questionId: next.id, ); if (prepared != null) { next = prepared; } } catch (e) { debugPrint('Failed to prepare next question in defer: $e'); } } _setActiveQuestion( next, unansweredCount: result.unansweredCount, queue: refreshed, ); } finally { questionActionBusy.value = false; } } /// Submit a typed spelling answer. Future submitCurrentSpellingAnswer(String answerText) async { final IncomingQuestion? question = currentQuestion; if (question == null || questionActionBusy.value || !question.usesSpellingWordTile) { return; } final String trimmed = answerText.trim(); if (trimmed.isEmpty) { return; } await _submitAnswer( answeredQuestionId: question.id, question: question, answerText: trimmed, ); } /// Swipe right: submit slider value as the answer. Future submitCurrentAnswer({num answer = 0}) async { final IncomingQuestion? question = currentQuestion; if (question == null || questionActionBusy.value) { return; } num effectiveAnswer = answer; if (question.isSpellingFollowUp && answer == 0) { effectiveAnswer = 1; } await _submitAnswer( answeredQuestionId: question.id, question: question, answer: effectiveAnswer, ); } Future _submitAnswer({ required String answeredQuestionId, required IncomingQuestion question, num? answer, String? answerText, }) async { questionActionBusy.value = true; final DateTime startTime = DateTime.now(); try { final QuestionSubmitResult? result = await _api.submitAnswer( questionId: answeredQuestionId, answer: answer, answerText: answerText, ); if (result == null) { return; } if (question.usesSpellingWordTile) { final int elapsedMs = DateTime.now().difference(startTime).inMilliseconds; final int remainingMs = 1000 - elapsedMs; if (remainingMs > 0) { await Future.delayed(Duration(milliseconds: remainingMs)); } } if (question.isMarketHistory) { guessScoreSummary.value = result.score; } if (result.nextQuestion != null && _acceptsQuestionSource(result.nextQuestion!)) { IncomingQuestion nextQ = result.nextQuestion!; if (nextQ.needsPrepare) { try { final IncomingQuestion? prepared = await _api.prepareSpellingQuestion( questionId: nextQ.id, ); if (prepared != null) { nextQ = prepared; } } catch (e) { debugPrint('Failed to prepare next question in submit: $e'); } } _setActiveQuestion( nextQ, unansweredCount: result.unansweredCount, ); return; } if (result.unansweredCount == 0) { if (_shouldKeepPanelOpenWithoutQuestion()) { pendingQuestion.value = null; questionQueue.value = []; hasPendingQuestion.value = true; questionPanelOpen.value = true; } else { _clearPendingUi(); } return; } final List refreshed = await _fetchUnansweredForPanel(); final List remaining = refreshed .where((IncomingQuestion q) => q.id != answeredQuestionId) .toList(); if (remaining.isEmpty) { if (_shouldKeepPanelOpenWithoutQuestion()) { pendingQuestion.value = null; questionQueue.value = []; hasPendingQuestion.value = true; questionPanelOpen.value = true; } else { _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 spelling links, familiarity progress, and spelling questions on the server. Future resetSpellingProgress() async { spellingResetBusy.value = true; try { final result = await _api.resetSpellingProgress(); if (result == null) { return false; } final List refreshed = await _fetchUnansweredForPanel(); final List active = refreshed.where(_acceptsQuestionSource).toList(growable: false); if (active.isNotEmpty) { pendingQuestion.value = active.first; pendingQuestionCount.value = active.length; hasPendingQuestion.value = true; if (questionPanelOpen.value) { questionQueue.value = active; } } else { pendingQuestion.value = null; questionQueue.value = []; hasPendingQuestion.value = _shouldKeepPanelOpenWithoutQuestion(); } return true; } finally { spellingResetBusy.value = false; } } /// Clears cumulative guess score and answer statistics on the server. Future resetGuessScoreSummary() async { scoreResetBusy.value = true; try { final GuessScoreResetResult? result = await _api.resetGuessScoreSummary(); if (result == null) { return false; } guessScoreSummary.value = result.score; _clearPendingUi(); if (result.question != null) { _applyIncoming(result.question!); } return true; } finally { scoreResetBusy.value = false; } } /// 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; } }