328 lines
9.7 KiB
Dart
328 lines
9.7 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_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<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<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;
|
|
}
|
|
|
|
questionActionBusy.value = true;
|
|
try {
|
|
final List<IncomingQuestion> queue = List<IncomingQuestion>.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<IncomingQuestion> 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<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 {
|
|
final GuessScoreSummary? score = await _api.resetGuessScoreSummary();
|
|
if (score == null) {
|
|
return false;
|
|
}
|
|
guessScoreSummary.value = score;
|
|
return true;
|
|
}
|
|
|
|
void _syncPendingFromQueue(int count) {
|
|
final List<IncomingQuestion> 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 = <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;
|
|
}
|
|
}
|