cyberhybridhub/lib/services/questions_hub_service.dart
2026-06-03 05:12:02 -05:00

353 lines
10 KiB
Dart

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_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<bool> hasPendingQuestion = ValueNotifier<bool>(false);
final ValueNotifier<IncomingQuestion?> pendingQuestion =
ValueNotifier<IncomingQuestion?>(null);
final ValueNotifier<int> pendingQuestionCount = ValueNotifier<int>(0);
final ValueNotifier<bool> questionPanelOpen = ValueNotifier<bool>(false);
final ValueNotifier<List<IncomingQuestion>> questionQueue =
ValueNotifier<List<IncomingQuestion>>(<IncomingQuestion>[]);
final ValueNotifier<bool> questionActionBusy = ValueNotifier<bool>(false);
final ValueNotifier<bool> scoreResetBusy = ValueNotifier<bool>(false);
final ValueNotifier<GuessScoreSummary> guessScoreSummary =
ValueNotifier<GuessScoreSummary>(GuessScoreSummary.empty);
HubConnection? _connection;
bool _connecting = false;
IncomingQuestion? get currentQuestion {
final IncomingQuestion? pending = pendingQuestion.value;
final List<IncomingQuestion> 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<void> 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<void> 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: <int>[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<Object?>? arguments) {
if (arguments == null || arguments.isEmpty) {
return;
}
final Object? raw = arguments.first;
final Map<String, dynamic> json;
if (raw is Map<String, dynamic>) {
json = raw;
} else if (raw is Map) {
json = Map<String, dynamic>.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<IncomingQuestion> queue = List<IncomingQuestion>.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<void> openQuestionPanel() async {
questionActionBusy.value = true;
try {
final List<IncomingQuestion> 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 = <IncomingQuestion>[pendingQuestion.value!];
} else {
questionQueue.value = <IncomingQuestion>[];
}
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<void> 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<IncomingQuestion> refreshed = await _api.fetchUnanswered();
if (refreshed.isEmpty) {
if (result.nextQuestion != null) {
_setActiveQuestion(
result.nextQuestion!,
unansweredCount: result.unansweredCount,
);
} 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) {
next = result.nextQuestion!;
}
_setActiveQuestion(
next,
unansweredCount: result.unansweredCount,
queue: refreshed,
);
} finally {
questionActionBusy.value = false;
}
}
/// Swipe right: submit slider value as the answer.
Future<void> 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<IncomingQuestion> refreshed = await _api.fetchUnanswered();
final List<IncomingQuestion> 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<IncomingQuestion>? queue,
}) {
final int count = unansweredCount < 1 ? 1 : unansweredCount;
pendingQuestion.value = question;
pendingQuestionCount.value = count;
hasPendingQuestion.value = true;
if (questionPanelOpen.value) {
questionQueue.value = queue ?? <IncomingQuestion>[question];
}
}
Future<void> _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<bool> 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 = <IncomingQuestion>[];
questionPanelOpen.value = false;
}
void dismissPending() {
_clearPendingUi();
}
Future<void> 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;
}
}