This commit is contained in:
parent
1f83f6c9f7
commit
4459852f83
File diff suppressed because one or more lines are too long
@ -35,7 +35,7 @@ class _AdminAppBarActionState extends State<AdminAppBarAction> {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(context).pushNamed(AdminRoutes.marketHistoryLog);
|
Navigator.of(context).pushNamed(AdminRoutes.marketHistoryLog);
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.admin_panel_settings_outlined),
|
icon: const Icon(Icons.admin_panel_settings),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
9
lib/data/remote/question_source_api_exception.dart
Normal file
9
lib/data/remote/question_source_api_exception.dart
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
class QuestionSourceApiException implements Exception {
|
||||||
|
QuestionSourceApiException(this.statusCode, this.body);
|
||||||
|
|
||||||
|
final int statusCode;
|
||||||
|
final String body;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'QuestionSourceApiException($statusCode): $body';
|
||||||
|
}
|
||||||
87
lib/data/remote/question_source_remote_store.dart
Normal file
87
lib/data/remote/question_source_remote_store.dart
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../../config/api_config.dart';
|
||||||
|
import '../../models/question_source_preference.dart';
|
||||||
|
import '../../services/auth_service.dart';
|
||||||
|
import 'question_source_api_exception.dart';
|
||||||
|
|
||||||
|
/// HTTP client for per-user question source preferences.
|
||||||
|
class QuestionSourceRemoteStore {
|
||||||
|
QuestionSourceRemoteStore({http.Client? client})
|
||||||
|
: _client = client ?? http.Client();
|
||||||
|
|
||||||
|
final http.Client _client;
|
||||||
|
|
||||||
|
Future<List<QuestionSourcePreference>?> fetchPreferences() async {
|
||||||
|
final String? token = await AuthService.instance.getIdToken();
|
||||||
|
if (token == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final http.Response response = await _client.get(
|
||||||
|
Uri.parse('$apiBaseUrl/v1/me/question-sources'),
|
||||||
|
headers: _authHeaders(token),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw QuestionSourceApiException(response.statusCode, response.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, dynamic> body =
|
||||||
|
jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final List<dynamic> sources =
|
||||||
|
body['sources'] as List<dynamic>? ?? <dynamic>[];
|
||||||
|
return sources
|
||||||
|
.map(
|
||||||
|
(dynamic entry) => QuestionSourcePreference.fromJson(
|
||||||
|
entry as Map<String, dynamic>,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<QuestionSourcePreference>> savePreferences(
|
||||||
|
List<QuestionSourcePreference> sources,
|
||||||
|
) async {
|
||||||
|
final String? token = await AuthService.instance.getIdToken();
|
||||||
|
if (token == null) {
|
||||||
|
throw StateError('Not signed in');
|
||||||
|
}
|
||||||
|
|
||||||
|
final http.Response response = await _client.put(
|
||||||
|
Uri.parse('$apiBaseUrl/v1/me/question-sources'),
|
||||||
|
headers: _authHeaders(token),
|
||||||
|
body: jsonEncode(<String, dynamic>{
|
||||||
|
'sources': sources
|
||||||
|
.map((QuestionSourcePreference source) => source.toStorageJson())
|
||||||
|
.toList(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw QuestionSourceApiException(response.statusCode, response.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, dynamic> body =
|
||||||
|
jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final List<dynamic> saved =
|
||||||
|
body['sources'] as List<dynamic>? ?? <dynamic>[];
|
||||||
|
return saved
|
||||||
|
.map(
|
||||||
|
(dynamic entry) => QuestionSourcePreference.fromJson(
|
||||||
|
entry as Map<String, dynamic>,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> _authHeaders(String token) {
|
||||||
|
return <String, String>{
|
||||||
|
'Authorization': 'Bearer $token',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
120
lib/data/remote/spelling_words_remote_store.dart
Normal file
120
lib/data/remote/spelling_words_remote_store.dart
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../../config/api_config.dart';
|
||||||
|
import '../../services/auth_service.dart';
|
||||||
|
|
||||||
|
class SpellingWordsApiException implements Exception {
|
||||||
|
SpellingWordsApiException(this.statusCode, this.body);
|
||||||
|
|
||||||
|
final int statusCode;
|
||||||
|
final String body;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'SpellingWordsApiException($statusCode): $body';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTP client for the shared spelling word catalog.
|
||||||
|
class SpellingWordsRemoteStore {
|
||||||
|
SpellingWordsRemoteStore({http.Client? client})
|
||||||
|
: _client = client ?? http.Client();
|
||||||
|
|
||||||
|
final http.Client _client;
|
||||||
|
|
||||||
|
static const String _basePath = '/v1/me/spelling-words';
|
||||||
|
|
||||||
|
Future<List<String>> fetchWords() async {
|
||||||
|
final String? token = await AuthService.instance.getIdToken();
|
||||||
|
if (token == null) {
|
||||||
|
throw StateError('Not signed in');
|
||||||
|
}
|
||||||
|
|
||||||
|
final http.Response response = await _client.get(
|
||||||
|
Uri.parse('$apiBaseUrl$_basePath'),
|
||||||
|
headers: _authHeaders(token),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw SpellingWordsApiException(response.statusCode, response.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, dynamic> body =
|
||||||
|
jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final List<dynamic> words = body['words'] as List<dynamic>? ?? <dynamic>[];
|
||||||
|
return words.map((dynamic w) => w as String).toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<String>> addWord(String word) async {
|
||||||
|
final String? token = await AuthService.instance.getIdToken();
|
||||||
|
if (token == null) {
|
||||||
|
throw StateError('Not signed in');
|
||||||
|
}
|
||||||
|
|
||||||
|
final http.Response response = await _client.post(
|
||||||
|
Uri.parse('$apiBaseUrl$_basePath'),
|
||||||
|
headers: _authHeaders(token),
|
||||||
|
body: jsonEncode(<String, dynamic>{'word': word}),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 201) {
|
||||||
|
throw SpellingWordsApiException(response.statusCode, response.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, dynamic> body =
|
||||||
|
jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final List<dynamic> words = body['words'] as List<dynamic>? ?? <dynamic>[];
|
||||||
|
return words.map((dynamic w) => w as String).toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<({int added, int skipped})> importText(String text) async {
|
||||||
|
final String? token = await AuthService.instance.getIdToken();
|
||||||
|
if (token == null) {
|
||||||
|
throw StateError('Not signed in');
|
||||||
|
}
|
||||||
|
|
||||||
|
final http.Response response = await _client.post(
|
||||||
|
Uri.parse('$apiBaseUrl$_basePath/import'),
|
||||||
|
headers: <String, String>{
|
||||||
|
..._authHeaders(token),
|
||||||
|
'Content-Type': 'text/plain; charset=utf-8',
|
||||||
|
},
|
||||||
|
body: text,
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw SpellingWordsApiException(response.statusCode, response.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, dynamic> body =
|
||||||
|
jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return (
|
||||||
|
added: (body['added'] as num?)?.toInt() ?? 0,
|
||||||
|
skipped: (body['skipped'] as num?)?.toInt() ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> exportText() async {
|
||||||
|
final String? token = await AuthService.instance.getIdToken();
|
||||||
|
if (token == null) {
|
||||||
|
throw StateError('Not signed in');
|
||||||
|
}
|
||||||
|
|
||||||
|
final http.Response response = await _client.get(
|
||||||
|
Uri.parse('$apiBaseUrl$_basePath/export'),
|
||||||
|
headers: <String, String>{
|
||||||
|
'Authorization': 'Bearer $token',
|
||||||
|
'Accept': 'text/plain',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw SpellingWordsApiException(response.statusCode, response.body);
|
||||||
|
}
|
||||||
|
return response.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> _authHeaders(String token) {
|
||||||
|
return <String, String>{
|
||||||
|
'Authorization': 'Bearer $token',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,8 +5,10 @@ import 'admin/screens/market_history_log_screen.dart';
|
|||||||
import 'admin/widgets/admin_gate.dart';
|
import 'admin/widgets/admin_gate.dart';
|
||||||
import 'bootstrap.dart';
|
import 'bootstrap.dart';
|
||||||
import 'models/app_user.dart';
|
import 'models/app_user.dart';
|
||||||
|
import 'routes/app_routes.dart';
|
||||||
import 'screens/home_screen.dart';
|
import 'screens/home_screen.dart';
|
||||||
import 'screens/landing_screen.dart';
|
import 'screens/landing_screen.dart';
|
||||||
|
import 'screens/spelling_list_screen.dart';
|
||||||
import 'services/auth_service.dart';
|
import 'services/auth_service.dart';
|
||||||
import 'theme/app_theme.dart';
|
import 'theme/app_theme.dart';
|
||||||
import 'widgets/profile_session.dart';
|
import 'widgets/profile_session.dart';
|
||||||
@ -36,6 +38,12 @@ class CyberHybridHubApp extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (settings.name == AppRoutes.spellingWordList) {
|
||||||
|
return MaterialPageRoute<void>(
|
||||||
|
settings: settings,
|
||||||
|
builder: (BuildContext context) => const SpellingListScreen(),
|
||||||
|
);
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,8 +5,16 @@ class IncomingQuestion {
|
|||||||
required this.text,
|
required this.text,
|
||||||
required this.sentAt,
|
required this.sentAt,
|
||||||
this.unansweredCount = 1,
|
this.unansweredCount = 1,
|
||||||
|
this.sourceId = defaultSourceId,
|
||||||
|
this.presentation = defaultPresentation,
|
||||||
|
this.spokenWord,
|
||||||
|
this.audioTitle,
|
||||||
|
this.needsPrepare = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
static const String defaultSourceId = 'market_history';
|
||||||
|
static const String defaultPresentation = 'market_glyph_slider';
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
final String text;
|
final String text;
|
||||||
final DateTime sentAt;
|
final DateTime sentAt;
|
||||||
@ -14,12 +22,82 @@ class IncomingQuestion {
|
|||||||
/// Unanswered questions in the database for this user (from API).
|
/// Unanswered questions in the database for this user (from API).
|
||||||
final int unansweredCount;
|
final int unansweredCount;
|
||||||
|
|
||||||
|
/// Question source (`market_history`, `spelling`, …).
|
||||||
|
final String sourceId;
|
||||||
|
|
||||||
|
/// UI presentation key (`market_glyph_slider`, `spelling_word`, …).
|
||||||
|
final String presentation;
|
||||||
|
|
||||||
|
/// Word read aloud after server-side selection at tile load.
|
||||||
|
final String? spokenWord;
|
||||||
|
|
||||||
|
/// Question id referenced as the audio utterance title.
|
||||||
|
final String? audioTitle;
|
||||||
|
|
||||||
|
/// True when the server still needs tile-load word selection.
|
||||||
|
final bool needsPrepare;
|
||||||
|
|
||||||
|
/// Spelling pipeline source (word quiz and follow-up prompts).
|
||||||
|
bool get isSpelling => sourceId == 'spelling';
|
||||||
|
|
||||||
|
/// Typed spelling tile (hear word, type answer).
|
||||||
|
bool get usesSpellingWordTile {
|
||||||
|
if (presentation == 'spelling_word' || needsPrepare) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!isSpelling) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (spokenWord != null && spokenWord!.isNotEmpty) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return !isSpellingFollowUp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spelling branch prompt to continue (+1) or pause (-1); uses swipe UI.
|
||||||
|
bool get isSpellingFollowUp {
|
||||||
|
if (!isSpelling || presentation == 'spelling_word') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final String lower = text.toLowerCase();
|
||||||
|
return lower.contains('enter +1') ||
|
||||||
|
lower.contains('another word') ||
|
||||||
|
lower.contains('to pause');
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isMarketHistory => sourceId == 'market_history';
|
||||||
|
|
||||||
factory IncomingQuestion.fromJson(Map<String, dynamic> json) {
|
factory IncomingQuestion.fromJson(Map<String, dynamic> json) {
|
||||||
return IncomingQuestion(
|
return IncomingQuestion(
|
||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
text: json['text'] as String,
|
text: json['text'] as String,
|
||||||
sentAt: DateTime.parse(json['sentAt'] as String).toUtc(),
|
sentAt: DateTime.parse(json['sentAt'] as String).toUtc(),
|
||||||
unansweredCount: (json['unansweredCount'] as num?)?.toInt() ?? 1,
|
unansweredCount: (json['unansweredCount'] as num?)?.toInt() ?? 1,
|
||||||
|
sourceId: json['sourceId'] as String? ?? defaultSourceId,
|
||||||
|
presentation: json['presentation'] as String? ?? defaultPresentation,
|
||||||
|
spokenWord: json['spokenWord'] as String?,
|
||||||
|
audioTitle: json['audioTitle'] as String?,
|
||||||
|
needsPrepare: json['needsPrepare'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
IncomingQuestion copyWith({
|
||||||
|
String? text,
|
||||||
|
String? spokenWord,
|
||||||
|
String? audioTitle,
|
||||||
|
int? unansweredCount,
|
||||||
|
bool? needsPrepare,
|
||||||
|
}) {
|
||||||
|
return IncomingQuestion(
|
||||||
|
id: id,
|
||||||
|
text: text ?? this.text,
|
||||||
|
sentAt: sentAt,
|
||||||
|
unansweredCount: unansweredCount ?? this.unansweredCount,
|
||||||
|
sourceId: sourceId,
|
||||||
|
presentation: presentation,
|
||||||
|
spokenWord: spokenWord ?? this.spokenWord,
|
||||||
|
audioTitle: audioTitle ?? this.audioTitle,
|
||||||
|
needsPrepare: needsPrepare ?? this.needsPrepare,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
88
lib/models/question_source_preference.dart
Normal file
88
lib/models/question_source_preference.dart
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
/// User preference for one question delivery pipeline / API source.
|
||||||
|
class QuestionSourcePreference {
|
||||||
|
const QuestionSourcePreference({
|
||||||
|
required this.id,
|
||||||
|
required this.label,
|
||||||
|
required this.description,
|
||||||
|
this.enabled = true,
|
||||||
|
this.ratio = 25,
|
||||||
|
this.comingSoon = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Pipeline key or source identifier (matches server `pipeline_key` / `source_tag`).
|
||||||
|
final String id;
|
||||||
|
final String label;
|
||||||
|
final String description;
|
||||||
|
|
||||||
|
/// Whether this source may contribute questions.
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
/// Relative weight (0–100). Effective mix uses enabled sources only.
|
||||||
|
final int ratio;
|
||||||
|
|
||||||
|
/// UI-only stub: shown in settings but not yet wired to a pipeline.
|
||||||
|
final bool comingSoon;
|
||||||
|
|
||||||
|
QuestionSourcePreference copyWith({
|
||||||
|
String? label,
|
||||||
|
String? description,
|
||||||
|
bool? enabled,
|
||||||
|
int? ratio,
|
||||||
|
bool? comingSoon,
|
||||||
|
}) {
|
||||||
|
return QuestionSourcePreference(
|
||||||
|
id: id,
|
||||||
|
label: label ?? this.label,
|
||||||
|
description: description ?? this.description,
|
||||||
|
enabled: enabled ?? this.enabled,
|
||||||
|
ratio: ratio ?? this.ratio,
|
||||||
|
comingSoon: comingSoon ?? this.comingSoon,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return <String, dynamic>{
|
||||||
|
'id': id,
|
||||||
|
'label': label,
|
||||||
|
'description': description,
|
||||||
|
'enabled': enabled,
|
||||||
|
'ratio': ratio,
|
||||||
|
'comingSoon': comingSoon,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Payload persisted to the API (labels come from server defaults).
|
||||||
|
Map<String, dynamic> toStorageJson() {
|
||||||
|
return <String, dynamic>{
|
||||||
|
'id': id,
|
||||||
|
'enabled': enabled,
|
||||||
|
'ratio': ratio,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory QuestionSourcePreference.fromJson(Map<String, dynamic> json) {
|
||||||
|
return QuestionSourcePreference(
|
||||||
|
id: json['id'] as String,
|
||||||
|
label: json['label'] as String,
|
||||||
|
description: json['description'] as String? ?? '',
|
||||||
|
enabled: json['enabled'] as bool? ?? true,
|
||||||
|
ratio: (json['ratio'] as num?)?.round().clamp(0, 100) ?? 25,
|
||||||
|
comingSoon: json['comingSoon'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return other is QuestionSourcePreference &&
|
||||||
|
other.id == id &&
|
||||||
|
other.label == label &&
|
||||||
|
other.description == description &&
|
||||||
|
other.enabled == enabled &&
|
||||||
|
other.ratio == ratio &&
|
||||||
|
other.comingSoon == comingSoon;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
Object.hash(id, label, description, enabled, ratio, comingSoon);
|
||||||
|
}
|
||||||
162
lib/repositories/question_source_config_repository.dart
Normal file
162
lib/repositories/question_source_config_repository.dart
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../data/remote/question_source_remote_store.dart';
|
||||||
|
import '../models/question_source_preference.dart';
|
||||||
|
|
||||||
|
/// Question source mix persisted via `/v1/me/question-sources`.
|
||||||
|
class QuestionSourceConfigRepository {
|
||||||
|
QuestionSourceConfigRepository._();
|
||||||
|
|
||||||
|
static final QuestionSourceConfigRepository instance =
|
||||||
|
QuestionSourceConfigRepository._();
|
||||||
|
|
||||||
|
static const List<QuestionSourcePreference> defaultPreferences =
|
||||||
|
<QuestionSourcePreference>[
|
||||||
|
QuestionSourcePreference(
|
||||||
|
id: 'market_history',
|
||||||
|
label: 'Market question',
|
||||||
|
description: 'Prospective price-move guessing game',
|
||||||
|
ratio: 50,
|
||||||
|
),
|
||||||
|
QuestionSourcePreference(
|
||||||
|
id: 'spelling',
|
||||||
|
label: 'Spelling',
|
||||||
|
description: 'Spell-check quiz from curated word lists',
|
||||||
|
ratio: 50,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
final QuestionSourceRemoteStore _remote = QuestionSourceRemoteStore();
|
||||||
|
|
||||||
|
final ValueNotifier<List<QuestionSourcePreference>> preferences =
|
||||||
|
ValueNotifier<List<QuestionSourcePreference>>(
|
||||||
|
List<QuestionSourcePreference>.from(defaultPreferences),
|
||||||
|
);
|
||||||
|
|
||||||
|
String? _activeUid;
|
||||||
|
|
||||||
|
List<QuestionSourcePreference> get current =>
|
||||||
|
mergeWithDefaults(preferences.value);
|
||||||
|
|
||||||
|
Future<void> startSession(String firebaseUid) async {
|
||||||
|
if (_activeUid == firebaseUid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_activeUid = firebaseUid;
|
||||||
|
await loadFromRemote();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> endSession() async {
|
||||||
|
_activeUid = null;
|
||||||
|
resetToDefaults();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadFromRemote() async {
|
||||||
|
try {
|
||||||
|
final List<QuestionSourcePreference>? remote =
|
||||||
|
await _remote.fetchPreferences();
|
||||||
|
if (remote != null) {
|
||||||
|
replaceAll(remote);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
replaceAll(defaultPreferences);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> saveAll(List<QuestionSourcePreference> next) async {
|
||||||
|
final List<QuestionSourcePreference> normalized = mergeWithDefaults(next);
|
||||||
|
if (_activeUid == null) {
|
||||||
|
replaceAll(normalized);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<QuestionSourcePreference> saved =
|
||||||
|
await _remote.savePreferences(normalized);
|
||||||
|
replaceAll(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
void replaceAll(List<QuestionSourcePreference> next) {
|
||||||
|
preferences.value = mergeWithDefaults(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps saved toggles/weights while picking up new default sources and labels.
|
||||||
|
static List<QuestionSourcePreference> mergeWithDefaults(
|
||||||
|
List<QuestionSourcePreference> saved,
|
||||||
|
) {
|
||||||
|
final Map<String, QuestionSourcePreference> byId =
|
||||||
|
<String, QuestionSourcePreference>{
|
||||||
|
for (final QuestionSourcePreference source in saved) source.id: source,
|
||||||
|
};
|
||||||
|
|
||||||
|
return defaultPreferences
|
||||||
|
.map((QuestionSourcePreference defaults) {
|
||||||
|
final QuestionSourcePreference? existing = byId[defaults.id];
|
||||||
|
if (existing == null) {
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
return existing.copyWith(
|
||||||
|
label: defaults.label,
|
||||||
|
description: defaults.description,
|
||||||
|
comingSoon: defaults.comingSoon,
|
||||||
|
enabled: defaults.comingSoon ? false : existing.enabled,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void resetToDefaults() {
|
||||||
|
replaceAll(defaultPreferences);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when spelling is the only enabled, non–coming-soon source.
|
||||||
|
static bool isSpellingOnlyEnabled(List<QuestionSourcePreference> sources) {
|
||||||
|
final Iterable<QuestionSourcePreference> enabled = sources.where(
|
||||||
|
(QuestionSourcePreference s) => s.enabled && !s.comingSoon,
|
||||||
|
);
|
||||||
|
return enabled.length == 1 && enabled.first.id == 'spelling';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when market history is the only enabled, non–coming-soon source.
|
||||||
|
static bool isMarketOnlyEnabled(List<QuestionSourcePreference> sources) {
|
||||||
|
final Iterable<QuestionSourcePreference> enabled = sources.where(
|
||||||
|
(QuestionSourcePreference s) => s.enabled && !s.comingSoon,
|
||||||
|
);
|
||||||
|
return enabled.length == 1 && enabled.first.id == 'market_history';
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool isMarketHistoryEnabled(List<QuestionSourcePreference> sources) {
|
||||||
|
for (final QuestionSourcePreference source in sources) {
|
||||||
|
if (source.id == 'market_history') {
|
||||||
|
return source.enabled && !source.comingSoon;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool isSpellingEnabled(List<QuestionSourcePreference> sources) {
|
||||||
|
for (final QuestionSourcePreference source in sources) {
|
||||||
|
if (source.id == 'spelling') {
|
||||||
|
return source.enabled && !source.comingSoon;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Effective share (0–100) for each enabled source; empty when none enabled.
|
||||||
|
static Map<String, int> effectiveMix(
|
||||||
|
List<QuestionSourcePreference> sources,
|
||||||
|
) {
|
||||||
|
final Iterable<QuestionSourcePreference> enabled =
|
||||||
|
sources.where((QuestionSourcePreference s) => s.enabled);
|
||||||
|
final int totalWeight =
|
||||||
|
enabled.fold<int>(0, (int sum, QuestionSourcePreference s) => sum + s.ratio);
|
||||||
|
if (totalWeight <= 0) {
|
||||||
|
return <String, int>{};
|
||||||
|
}
|
||||||
|
|
||||||
|
return <String, int>{
|
||||||
|
for (final QuestionSourcePreference source in enabled)
|
||||||
|
source.id: ((source.ratio / totalWeight) * 100).round(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
4
lib/routes/app_routes.dart
Normal file
4
lib/routes/app_routes.dart
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
/// Named routes for non-admin screens.
|
||||||
|
abstract final class AppRoutes {
|
||||||
|
static const String spellingWordList = '/spelling-words';
|
||||||
|
}
|
||||||
@ -11,8 +11,13 @@ import '../repositories/user_profile_repository.dart';
|
|||||||
import '../services/auth_service.dart';
|
import '../services/auth_service.dart';
|
||||||
import '../services/questions_hub_service.dart';
|
import '../services/questions_hub_service.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
|
import '../widgets/hub_toolbar_icon_button.dart';
|
||||||
import '../widgets/profile_avatar.dart';
|
import '../widgets/profile_avatar.dart';
|
||||||
import '../widgets/swipe_question_tile.dart';
|
import '../widgets/question_source_settings_sheet.dart';
|
||||||
|
import '../repositories/question_source_config_repository.dart';
|
||||||
|
import '../widgets/question_presentation_router.dart';
|
||||||
|
import '../widgets/market_question_placeholder.dart';
|
||||||
|
import '../widgets/spelling_question_placeholder.dart';
|
||||||
|
|
||||||
class HomeScreen extends StatelessWidget {
|
class HomeScreen extends StatelessWidget {
|
||||||
const HomeScreen({
|
const HomeScreen({
|
||||||
@ -45,32 +50,67 @@ class HomeScreen extends StatelessWidget {
|
|||||||
Scaffold(
|
Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
centerTitle: true,
|
elevation: 0,
|
||||||
|
scrolledUnderElevation: 0,
|
||||||
|
automaticallyImplyLeading: false,
|
||||||
|
titleSpacing: 8,
|
||||||
|
centerTitle: false,
|
||||||
title: ListenableBuilder(
|
title: ListenableBuilder(
|
||||||
listenable: Listenable.merge(<Listenable>[
|
listenable: Listenable.merge(<Listenable>[
|
||||||
QuestionsHubService.instance.hasPendingQuestion,
|
QuestionsHubService.instance.hasPendingQuestion,
|
||||||
QuestionsHubService.instance.pendingQuestion,
|
QuestionsHubService.instance.pendingQuestion,
|
||||||
QuestionsHubService.instance.pendingQuestionCount,
|
QuestionsHubService.instance.pendingQuestionCount,
|
||||||
QuestionsHubService.instance.guessScoreSummary,
|
QuestionsHubService.instance.guessScoreSummary,
|
||||||
|
QuestionsHubService.instance.questionPanelOpen,
|
||||||
|
QuestionsHubService.instance.questionQueue,
|
||||||
|
QuestionSourceConfigRepository.instance.preferences,
|
||||||
]),
|
]),
|
||||||
builder: (BuildContext context, Widget? child) {
|
builder: (BuildContext context, Widget? child) {
|
||||||
final int count =
|
final int count =
|
||||||
QuestionsHubService.instance.pendingQuestionCount.value;
|
QuestionsHubService.instance.pendingQuestionCount.value;
|
||||||
final bool hasPending =
|
final bool hasPending =
|
||||||
QuestionsHubService.instance.hasPendingQuestion.value;
|
QuestionsHubService.instance.hasPendingQuestion.value;
|
||||||
|
final sources = QuestionSourceConfigRepository.instance.current;
|
||||||
|
final bool spellingOnly =
|
||||||
|
QuestionSourceConfigRepository.isSpellingOnlyEnabled(sources);
|
||||||
|
final bool marketOnly =
|
||||||
|
QuestionSourceConfigRepository.isMarketOnlyEnabled(sources);
|
||||||
|
final bool showEnvelope = spellingOnly ||
|
||||||
|
marketOnly ||
|
||||||
|
(hasPending && count >= 1);
|
||||||
final GuessScoreSummary score =
|
final GuessScoreSummary score =
|
||||||
QuestionsHubService.instance.guessScoreSummary.value;
|
QuestionsHubService.instance.guessScoreSummary.value;
|
||||||
|
final IncomingQuestion? active =
|
||||||
|
QuestionsHubService.instance.currentQuestion;
|
||||||
|
final bool panelOpen =
|
||||||
|
QuestionsHubService.instance.questionPanelOpen.value;
|
||||||
|
final bool showMarketScore = !panelOpen ||
|
||||||
|
active == null ||
|
||||||
|
active.isMarketHistory;
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
_CumulativeScoreChip(
|
if (showMarketScore)
|
||||||
summary: score,
|
_CumulativeScoreChip(
|
||||||
onPressed: () => _showGuessScoreStatsDialog(context, score),
|
summary: score,
|
||||||
),
|
onPressed: () =>
|
||||||
if (hasPending && count >= 1) ...<Widget>[
|
_showGuessScoreStatsDialog(context, score),
|
||||||
|
),
|
||||||
|
if (showMarketScore && showEnvelope)
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_QuestionEnvelopeButton(
|
if (showEnvelope) ...<Widget>[
|
||||||
count: count,
|
if (!showMarketScore) const SizedBox(width: 8),
|
||||||
|
HubToolbarIconButton(
|
||||||
|
icon: Icons.mail_outline,
|
||||||
|
iconColor: AppColors.accent,
|
||||||
|
badgeCount: count >= 1 ? count : null,
|
||||||
|
tooltip: count == 1
|
||||||
|
? '1 question'
|
||||||
|
: count > 1
|
||||||
|
? '$count questions'
|
||||||
|
: marketOnly
|
||||||
|
? 'Market questions'
|
||||||
|
: 'Spelling questions',
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
QuestionsHubService.instance.openQuestionPanel(),
|
QuestionsHubService.instance.openQuestionPanel(),
|
||||||
),
|
),
|
||||||
@ -82,15 +122,22 @@ class HomeScreen extends StatelessWidget {
|
|||||||
actions: <Widget>[
|
actions: <Widget>[
|
||||||
const AdminAppBarAction(),
|
const AdminAppBarAction(),
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () => UserProfileRepository.instance.sync(),
|
key: const Key('question-source-settings-button'),
|
||||||
|
tooltip: 'Question sources',
|
||||||
|
onPressed: () => QuestionSourceSettingsSheet.show(context),
|
||||||
|
icon: const Icon(Icons.settings),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
tooltip: 'Sync profile',
|
tooltip: 'Sync profile',
|
||||||
|
onPressed: () => UserProfileRepository.instance.sync(),
|
||||||
icon: Icon(Icons.sync, color: syncIconColor),
|
icon: Icon(Icons.sync, color: syncIconColor),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () => AuthService.instance.signOut(),
|
|
||||||
tooltip: 'Sign out',
|
tooltip: 'Sign out',
|
||||||
|
onPressed: () => AuthService.instance.signOut(),
|
||||||
icon: const Icon(Icons.logout),
|
icon: const Icon(Icons.logout),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: DecoratedBox(
|
body: DecoratedBox(
|
||||||
@ -110,14 +157,31 @@ class HomeScreen extends StatelessWidget {
|
|||||||
QuestionsHubService.instance.pendingQuestion,
|
QuestionsHubService.instance.pendingQuestion,
|
||||||
QuestionsHubService.instance.questionActionBusy,
|
QuestionsHubService.instance.questionActionBusy,
|
||||||
QuestionsHubService.instance.pendingQuestionCount,
|
QuestionsHubService.instance.pendingQuestionCount,
|
||||||
|
QuestionSourceConfigRepository.instance.preferences,
|
||||||
]),
|
]),
|
||||||
builder: (BuildContext context, Widget? child) {
|
builder: (BuildContext context, Widget? child) {
|
||||||
final QuestionsHubService hub = QuestionsHubService.instance;
|
final QuestionsHubService hub = QuestionsHubService.instance;
|
||||||
final bool panelOpen = hub.questionPanelOpen.value;
|
final bool panelOpen = hub.questionPanelOpen.value;
|
||||||
final bool hasPending = hub.hasPendingQuestion.value;
|
|
||||||
final IncomingQuestion? question = hub.currentQuestion;
|
final IncomingQuestion? question = hub.currentQuestion;
|
||||||
final bool showQuestionPanel =
|
final sources = QuestionSourceConfigRepository.instance.current;
|
||||||
panelOpen && hasPending && question != null;
|
final bool marketEnabled =
|
||||||
|
QuestionSourceConfigRepository.isMarketHistoryEnabled(
|
||||||
|
sources,
|
||||||
|
);
|
||||||
|
final bool spellingEnabled =
|
||||||
|
QuestionSourceConfigRepository.isSpellingEnabled(sources);
|
||||||
|
final bool showSpellingPlaceholder = panelOpen &&
|
||||||
|
spellingEnabled &&
|
||||||
|
!marketEnabled &&
|
||||||
|
(question == null || !question.isSpelling);
|
||||||
|
final bool showMarketPlaceholder = panelOpen &&
|
||||||
|
marketEnabled &&
|
||||||
|
!spellingEnabled &&
|
||||||
|
(question == null || !question.isMarketHistory);
|
||||||
|
final bool showQuestionPanel = panelOpen &&
|
||||||
|
question != null &&
|
||||||
|
!showSpellingPlaceholder &&
|
||||||
|
!showMarketPlaceholder;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
@ -143,13 +207,14 @@ class HomeScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SwipeQuestionTile(
|
child: QuestionPresentationRouter(
|
||||||
key: ValueKey<String>(question.id),
|
question: question,
|
||||||
questionId: question.id,
|
|
||||||
busy: hub.questionActionBusy.value,
|
busy: hub.questionActionBusy.value,
|
||||||
onSwipeRight: (num answer) =>
|
onAnswer: (num answer) =>
|
||||||
hub.submitCurrentAnswer(answer: answer),
|
hub.submitCurrentAnswer(answer: answer),
|
||||||
onSwipeLeft: hub.deferCurrentQuestion,
|
onAnswerText: hub.submitCurrentSpellingAnswer,
|
||||||
|
onPrepared: hub.applyPreparedQuestion,
|
||||||
|
onDefer: hub.deferCurrentQuestion,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -157,7 +222,63 @@ class HomeScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!showQuestionPanel)
|
if (showSpellingPlaceholder)
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 4, 8, 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: <Widget>[
|
||||||
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
|
const Spacer(),
|
||||||
|
IconButton(
|
||||||
|
onPressed: hub.closeQuestionPanel,
|
||||||
|
tooltip: 'Close',
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: SpellingQuestionPlaceholder(
|
||||||
|
busy: hub.questionActionBusy.value,
|
||||||
|
onRetry: hub.openQuestionPanel,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showMarketPlaceholder)
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 4, 8, 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: <Widget>[
|
||||||
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
|
const Spacer(),
|
||||||
|
IconButton(
|
||||||
|
onPressed: hub.closeQuestionPanel,
|
||||||
|
tooltip: 'Close',
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: MarketQuestionPlaceholder(
|
||||||
|
busy: hub.questionActionBusy.value,
|
||||||
|
onRetry: hub.openQuestionPanel,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!showQuestionPanel &&
|
||||||
|
!showSpellingPlaceholder &&
|
||||||
|
!showMarketPlaceholder)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
@ -289,62 +410,6 @@ class HomeScreen extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _QuestionEnvelopeButton extends StatelessWidget {
|
|
||||||
const _QuestionEnvelopeButton({
|
|
||||||
required this.count,
|
|
||||||
required this.onPressed,
|
|
||||||
});
|
|
||||||
|
|
||||||
final int count;
|
|
||||||
final VoidCallback onPressed;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Stack(
|
|
||||||
clipBehavior: Clip.none,
|
|
||||||
alignment: Alignment.center,
|
|
||||||
children: <Widget>[
|
|
||||||
IconButton(
|
|
||||||
onPressed: onPressed,
|
|
||||||
tooltip: count == 1 ? '1 question' : '$count questions',
|
|
||||||
icon: const Icon(Icons.mail_outline, size: 22),
|
|
||||||
style: IconButton.styleFrom(
|
|
||||||
backgroundColor: AppColors.accent.withValues(alpha: 0.15),
|
|
||||||
foregroundColor: AppColors.accent,
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
minimumSize: const Size(36, 36),
|
|
||||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (count >= 1)
|
|
||||||
Positioned(
|
|
||||||
top: 4,
|
|
||||||
right: 0,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.accent,
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
border: Border.all(color: AppColors.surface, width: 1.5),
|
|
||||||
),
|
|
||||||
constraints: const BoxConstraints(minWidth: 18, minHeight: 18),
|
|
||||||
child: Text(
|
|
||||||
'$count',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: AppColors.background,
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
height: 1.1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showGuessScoreStatsDialog(BuildContext context, GuessScoreSummary summary) {
|
void _showGuessScoreStatsDialog(BuildContext context, GuessScoreSummary summary) {
|
||||||
final String totalText = _formatScoreNumber(summary.total);
|
final String totalText = _formatScoreNumber(summary.total);
|
||||||
final String percentText = _formatScoreNumber(summary.percentCorrect);
|
final String percentText = _formatScoreNumber(summary.percentCorrect);
|
||||||
|
|||||||
335
lib/screens/spelling_list_screen.dart
Normal file
335
lib/screens/spelling_list_screen.dart
Normal file
@ -0,0 +1,335 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../data/remote/spelling_words_remote_store.dart';
|
||||||
|
import '../theme/app_theme.dart';
|
||||||
|
|
||||||
|
/// Lists and manages canonical spelling words in the shared catalog.
|
||||||
|
class SpellingListScreen extends StatefulWidget {
|
||||||
|
const SpellingListScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SpellingListScreen> createState() => _SpellingListScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SpellingListScreenState extends State<SpellingListScreen> {
|
||||||
|
final SpellingWordsRemoteStore _api = SpellingWordsRemoteStore();
|
||||||
|
|
||||||
|
List<String> _words = <String>[];
|
||||||
|
bool _loading = true;
|
||||||
|
String? _error;
|
||||||
|
bool _busy = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
setState(() {
|
||||||
|
_loading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final List<String> words = await _api.fetchWords();
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_words = words;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_loading = false;
|
||||||
|
_error = 'Could not load spelling words.';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _runBusy(Future<void> Function() action) async {
|
||||||
|
if (_busy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _busy = true);
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _busy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onNew() async {
|
||||||
|
final String? word = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) => const _NewWordDialog(),
|
||||||
|
);
|
||||||
|
if (word == null || word.trim().isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _runBusy(() async {
|
||||||
|
try {
|
||||||
|
final List<String> words = await _api.addWord(word);
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _words = words);
|
||||||
|
_showSnack('Added "${word.trim().toLowerCase()}"');
|
||||||
|
} on SpellingWordsApiException catch (e) {
|
||||||
|
if (e.statusCode == 409) {
|
||||||
|
_showSnack('That word is already in the list.');
|
||||||
|
} else {
|
||||||
|
_showSnack('Could not add word.');
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
_showSnack('Could not add word.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onUpload() async {
|
||||||
|
final FilePickerResult? result = await FilePicker.platform.pickFiles(
|
||||||
|
type: FileType.custom,
|
||||||
|
allowedExtensions: <String>['txt'],
|
||||||
|
withData: true,
|
||||||
|
);
|
||||||
|
if (result == null || result.files.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final PlatformFile file = result.files.first;
|
||||||
|
final String? text = _readFileText(file);
|
||||||
|
if (text == null || text.trim().isEmpty) {
|
||||||
|
_showSnack('The file is empty or could not be read.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _runBusy(() async {
|
||||||
|
try {
|
||||||
|
final ({int added, int skipped}) stats = await _api.importText(text);
|
||||||
|
await _load();
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_showSnack('Upload complete: ${stats.added} added, ${stats.skipped} skipped');
|
||||||
|
} catch (_) {
|
||||||
|
_showSnack('Upload failed.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onDownload() async {
|
||||||
|
await _runBusy(() async {
|
||||||
|
try {
|
||||||
|
final String text = await _api.exportText();
|
||||||
|
final Uint8List bytes = Uint8List.fromList(utf8.encode(text));
|
||||||
|
final String? path = await FilePicker.platform.saveFile(
|
||||||
|
dialogTitle: 'Save spelling words',
|
||||||
|
fileName: 'spelling-words.txt',
|
||||||
|
type: FileType.custom,
|
||||||
|
allowedExtensions: <String>['txt'],
|
||||||
|
bytes: bytes,
|
||||||
|
);
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_showSnack('Saved spelling word list');
|
||||||
|
} catch (_) {
|
||||||
|
_showSnack('Download failed.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _readFileText(PlatformFile file) {
|
||||||
|
final List<int>? bytes = file.bytes;
|
||||||
|
if (bytes != null) {
|
||||||
|
return utf8.decode(bytes);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showSnack(String message) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(message)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Spelling words'),
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: <Widget>[
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: <Widget>[
|
||||||
|
FilledButton.icon(
|
||||||
|
key: const Key('spelling-words-new'),
|
||||||
|
onPressed: _busy ? null : _onNew,
|
||||||
|
icon: const Icon(Icons.add, size: 18),
|
||||||
|
label: const Text('New'),
|
||||||
|
),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
key: const Key('spelling-words-upload'),
|
||||||
|
onPressed: _busy ? null : _onUpload,
|
||||||
|
icon: const Icon(Icons.upload_file, size: 18),
|
||||||
|
label: const Text('Upload'),
|
||||||
|
),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
key: const Key('spelling-words-download'),
|
||||||
|
onPressed: _busy ? null : _onDownload,
|
||||||
|
icon: const Icon(Icons.download, size: 18),
|
||||||
|
label: const Text('Download'),
|
||||||
|
),
|
||||||
|
if (_busy)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(8),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Text(
|
||||||
|
'One word per line in upload/download files. Download includes canonical words only (no quiz history).',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Expanded(child: _buildBody()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody() {
|
||||||
|
if (_loading) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(color: AppColors.accent),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (_error != null) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: <Widget>[
|
||||||
|
Text(_error!, style: const TextStyle(color: Colors.redAccent)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
FilledButton(onPressed: _load, child: const Text('Retry')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (_words.isEmpty) {
|
||||||
|
return const Center(
|
||||||
|
child: Text(
|
||||||
|
'No spelling words yet. Add one or upload a text file.',
|
||||||
|
style: TextStyle(color: AppColors.textSecondary),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: _load,
|
||||||
|
color: AppColors.accent,
|
||||||
|
child: ListView.separated(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||||||
|
itemCount: _words.length,
|
||||||
|
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||||
|
itemBuilder: (BuildContext context, int index) {
|
||||||
|
final String word = _words[index];
|
||||||
|
return ListTile(
|
||||||
|
key: Key('spelling-word-$word'),
|
||||||
|
title: Text(
|
||||||
|
word,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NewWordDialog extends StatefulWidget {
|
||||||
|
const _NewWordDialog();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_NewWordDialog> createState() => _NewWordDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NewWordDialogState extends State<_NewWordDialog> {
|
||||||
|
final TextEditingController _controller = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('New spelling word'),
|
||||||
|
content: TextField(
|
||||||
|
key: const Key('spelling-new-word-field'),
|
||||||
|
controller: _controller,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Enter a word',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
),
|
||||||
|
actions: <Widget>[
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
key: const Key('spelling-new-word-add'),
|
||||||
|
onPressed: _submit,
|
||||||
|
child: const Text('Add'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _submit() {
|
||||||
|
final String word = _controller.text.trim();
|
||||||
|
if (word.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Navigator.of(context).pop(word);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -86,17 +86,25 @@ class QuestionsApiService {
|
|||||||
|
|
||||||
Future<QuestionSubmitResult?> submitAnswer({
|
Future<QuestionSubmitResult?> submitAnswer({
|
||||||
required String questionId,
|
required String questionId,
|
||||||
num answer = 0,
|
num? answer,
|
||||||
|
String? answerText,
|
||||||
}) async {
|
}) async {
|
||||||
final String? token = await AuthService.instance.getIdToken();
|
final String? token = await AuthService.instance.getIdToken();
|
||||||
if (token == null) {
|
if (token == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final Map<String, dynamic> requestBody = <String, dynamic>{};
|
||||||
|
if (answerText != null) {
|
||||||
|
requestBody['answerText'] = answerText;
|
||||||
|
} else {
|
||||||
|
requestBody['answer'] = answer ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
final http.Response response = await _client.post(
|
final http.Response response = await _client.post(
|
||||||
Uri.parse('$apiBaseUrl/v1/me/questions/$questionId/answer'),
|
Uri.parse('$apiBaseUrl/v1/me/questions/$questionId/answer'),
|
||||||
headers: _authHeaders(token),
|
headers: _authHeaders(token),
|
||||||
body: jsonEncode(<String, num>{'answer': answer}),
|
body: jsonEncode(requestBody),
|
||||||
);
|
);
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@ -122,6 +130,36 @@ class QuestionsApiService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Runs weighted word selection and creates spelling progress links at tile load.
|
||||||
|
Future<IncomingQuestion?> prepareSpellingQuestion({
|
||||||
|
required String questionId,
|
||||||
|
}) async {
|
||||||
|
final String? token = await AuthService.instance.getIdToken();
|
||||||
|
if (token == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final http.Response response = await _client.post(
|
||||||
|
Uri.parse('$apiBaseUrl/v1/me/questions/$questionId/spelling/prepare'),
|
||||||
|
headers: _authHeaders(token),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
debugPrint(
|
||||||
|
'prepareSpellingQuestion failed: ${response.statusCode} ${response.body}',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, dynamic> body =
|
||||||
|
jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final Map<String, dynamic>? questionJson =
|
||||||
|
body['question'] as Map<String, dynamic>?;
|
||||||
|
if (questionJson == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return IncomingQuestion.fromJson(questionJson);
|
||||||
|
}
|
||||||
|
|
||||||
Future<QuestionDeferResult?> deferQuestion({required String questionId}) async {
|
Future<QuestionDeferResult?> deferQuestion({required String questionId}) async {
|
||||||
final String? token = await AuthService.instance.getIdToken();
|
final String? token = await AuthService.instance.getIdToken();
|
||||||
if (token == null) {
|
if (token == null) {
|
||||||
|
|||||||
@ -8,7 +8,9 @@ import '../models/guess_score_reset_result.dart';
|
|||||||
import '../models/guess_score_summary.dart';
|
import '../models/guess_score_summary.dart';
|
||||||
import '../models/incoming_question.dart';
|
import '../models/incoming_question.dart';
|
||||||
import '../models/question_defer_result.dart';
|
import '../models/question_defer_result.dart';
|
||||||
|
import '../models/question_source_preference.dart';
|
||||||
import '../models/question_submit_result.dart';
|
import '../models/question_submit_result.dart';
|
||||||
|
import '../repositories/question_source_config_repository.dart';
|
||||||
import 'auth_service.dart';
|
import 'auth_service.dart';
|
||||||
import 'questions_api_service.dart';
|
import 'questions_api_service.dart';
|
||||||
|
|
||||||
@ -127,6 +129,9 @@ class QuestionsHubService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _applyIncoming(IncomingQuestion question) {
|
void _applyIncoming(IncomingQuestion question) {
|
||||||
|
if (!_acceptsQuestionSource(question)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
final int count =
|
final int count =
|
||||||
question.unansweredCount < 1 ? 1 : question.unansweredCount;
|
question.unansweredCount < 1 ? 1 : question.unansweredCount;
|
||||||
pendingQuestion.value = question;
|
pendingQuestion.value = question;
|
||||||
@ -148,27 +153,83 @@ class QuestionsHubService {
|
|||||||
Future<void> openQuestionPanel() async {
|
Future<void> openQuestionPanel() async {
|
||||||
questionActionBusy.value = true;
|
questionActionBusy.value = true;
|
||||||
try {
|
try {
|
||||||
|
// Align client filters with server-side source preferences before fetch.
|
||||||
|
await QuestionSourceConfigRepository.instance.loadFromRemote();
|
||||||
final List<IncomingQuestion> fetched = await _api.fetchUnanswered();
|
final List<IncomingQuestion> fetched = await _api.fetchUnanswered();
|
||||||
if (fetched.isNotEmpty) {
|
// GET /questions already returns only server-enabled sources.
|
||||||
questionQueue.value = fetched;
|
final List<IncomingQuestion> active = fetched;
|
||||||
pendingQuestion.value = fetched.first;
|
|
||||||
pendingQuestionCount.value = fetched.length;
|
questionPanelOpen.value = true;
|
||||||
|
|
||||||
|
if (active.isNotEmpty) {
|
||||||
|
questionQueue.value = active;
|
||||||
|
pendingQuestion.value = active.first;
|
||||||
|
pendingQuestionCount.value = active.length;
|
||||||
hasPendingQuestion.value = true;
|
hasPendingQuestion.value = true;
|
||||||
} else if (pendingQuestion.value != null) {
|
} else if (pendingQuestion.value != null &&
|
||||||
|
_acceptsQuestionSource(pendingQuestion.value!)) {
|
||||||
questionQueue.value = <IncomingQuestion>[pendingQuestion.value!];
|
questionQueue.value = <IncomingQuestion>[pendingQuestion.value!];
|
||||||
|
pendingQuestionCount.value = 1;
|
||||||
|
hasPendingQuestion.value = true;
|
||||||
} else {
|
} else {
|
||||||
|
pendingQuestion.value = null;
|
||||||
questionQueue.value = <IncomingQuestion>[];
|
questionQueue.value = <IncomingQuestion>[];
|
||||||
|
hasPendingQuestion.value = _shouldKeepPanelOpenWithoutQuestion();
|
||||||
}
|
}
|
||||||
questionPanelOpen.value = hasPendingQuestion.value;
|
|
||||||
} finally {
|
} finally {
|
||||||
questionActionBusy.value = false;
|
questionActionBusy.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _shouldKeepPanelOpenWithoutQuestion() {
|
||||||
|
final List<QuestionSourcePreference> sources =
|
||||||
|
QuestionSourceConfigRepository.instance.current;
|
||||||
|
return QuestionSourceConfigRepository.isSpellingOnlyEnabled(sources) ||
|
||||||
|
QuestionSourceConfigRepository.isMarketOnlyEnabled(sources);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _acceptsQuestionSource(IncomingQuestion question) {
|
||||||
|
final List<QuestionSourcePreference> sources =
|
||||||
|
QuestionSourceConfigRepository.instance.current;
|
||||||
|
if (QuestionSourceConfigRepository.isSpellingOnlyEnabled(sources)) {
|
||||||
|
return question.isSpelling;
|
||||||
|
}
|
||||||
|
if (QuestionSourceConfigRepository.isMarketOnlyEnabled(sources)) {
|
||||||
|
return question.isMarketHistory;
|
||||||
|
}
|
||||||
|
if (!QuestionSourceConfigRepository.isMarketHistoryEnabled(sources)) {
|
||||||
|
return question.sourceId != 'market_history';
|
||||||
|
}
|
||||||
|
if (!QuestionSourceConfigRepository.isSpellingEnabled(sources)) {
|
||||||
|
return !question.isSpelling;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<IncomingQuestion> _filterForEnabledSources(
|
||||||
|
List<IncomingQuestion> questions,
|
||||||
|
) {
|
||||||
|
return questions.where(_acceptsQuestionSource).toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
void closeQuestionPanel() {
|
void closeQuestionPanel() {
|
||||||
questionPanelOpen.value = false;
|
questionPanelOpen.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replaces a queue row after tile-load spelling prepare succeeds.
|
||||||
|
void applyPreparedQuestion(IncomingQuestion prepared) {
|
||||||
|
final List<IncomingQuestion> queue =
|
||||||
|
List<IncomingQuestion>.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.
|
/// Swipe left: move current question to end of queue without answering.
|
||||||
Future<void> deferCurrentQuestion() async {
|
Future<void> deferCurrentQuestion() async {
|
||||||
final IncomingQuestion? question = currentQuestion;
|
final IncomingQuestion? question = currentQuestion;
|
||||||
@ -193,11 +254,17 @@ class QuestionsHubService {
|
|||||||
|
|
||||||
final List<IncomingQuestion> refreshed = await _api.fetchUnanswered();
|
final List<IncomingQuestion> refreshed = await _api.fetchUnanswered();
|
||||||
if (refreshed.isEmpty) {
|
if (refreshed.isEmpty) {
|
||||||
if (result.nextQuestion != null) {
|
if (result.nextQuestion != null &&
|
||||||
|
_acceptsQuestionSource(result.nextQuestion!)) {
|
||||||
_setActiveQuestion(
|
_setActiveQuestion(
|
||||||
result.nextQuestion!,
|
result.nextQuestion!,
|
||||||
unansweredCount: result.unansweredCount,
|
unansweredCount: result.unansweredCount,
|
||||||
);
|
);
|
||||||
|
} else if (_shouldKeepPanelOpenWithoutQuestion()) {
|
||||||
|
pendingQuestion.value = null;
|
||||||
|
questionQueue.value = <IncomingQuestion>[];
|
||||||
|
hasPendingQuestion.value = true;
|
||||||
|
questionPanelOpen.value = true;
|
||||||
} else {
|
} else {
|
||||||
_clearPendingUi();
|
_clearPendingUi();
|
||||||
}
|
}
|
||||||
@ -225,6 +292,27 @@ class QuestionsHubService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Submit a typed spelling answer.
|
||||||
|
Future<void> 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.
|
/// Swipe right: submit slider value as the answer.
|
||||||
Future<void> submitCurrentAnswer({num answer = 0}) async {
|
Future<void> submitCurrentAnswer({num answer = 0}) async {
|
||||||
final IncomingQuestion? question = currentQuestion;
|
final IncomingQuestion? question = currentQuestion;
|
||||||
@ -232,20 +320,36 @@ class QuestionsHubService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final String answeredQuestionId = question.id;
|
await _submitAnswer(
|
||||||
|
answeredQuestionId: question.id,
|
||||||
|
question: question,
|
||||||
|
answer: answer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submitAnswer({
|
||||||
|
required String answeredQuestionId,
|
||||||
|
required IncomingQuestion question,
|
||||||
|
num? answer,
|
||||||
|
String? answerText,
|
||||||
|
}) async {
|
||||||
questionActionBusy.value = true;
|
questionActionBusy.value = true;
|
||||||
try {
|
try {
|
||||||
final QuestionSubmitResult? result = await _api.submitAnswer(
|
final QuestionSubmitResult? result = await _api.submitAnswer(
|
||||||
questionId: answeredQuestionId,
|
questionId: answeredQuestionId,
|
||||||
answer: answer,
|
answer: answer,
|
||||||
|
answerText: answerText,
|
||||||
);
|
);
|
||||||
if (result == null) {
|
if (result == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
guessScoreSummary.value = result.score;
|
if (question.isMarketHistory) {
|
||||||
|
guessScoreSummary.value = result.score;
|
||||||
|
}
|
||||||
|
|
||||||
if (result.nextQuestion != null) {
|
if (result.nextQuestion != null &&
|
||||||
|
_acceptsQuestionSource(result.nextQuestion!)) {
|
||||||
_setActiveQuestion(
|
_setActiveQuestion(
|
||||||
result.nextQuestion!,
|
result.nextQuestion!,
|
||||||
unansweredCount: result.unansweredCount,
|
unansweredCount: result.unansweredCount,
|
||||||
@ -254,7 +358,14 @@ class QuestionsHubService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (result.unansweredCount == 0) {
|
if (result.unansweredCount == 0) {
|
||||||
_clearPendingUi();
|
if (_shouldKeepPanelOpenWithoutQuestion()) {
|
||||||
|
pendingQuestion.value = null;
|
||||||
|
questionQueue.value = <IncomingQuestion>[];
|
||||||
|
hasPendingQuestion.value = true;
|
||||||
|
questionPanelOpen.value = true;
|
||||||
|
} else {
|
||||||
|
_clearPendingUi();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -263,7 +374,14 @@ class QuestionsHubService {
|
|||||||
.where((IncomingQuestion q) => q.id != answeredQuestionId)
|
.where((IncomingQuestion q) => q.id != answeredQuestionId)
|
||||||
.toList();
|
.toList();
|
||||||
if (remaining.isEmpty) {
|
if (remaining.isEmpty) {
|
||||||
_clearPendingUi();
|
if (_shouldKeepPanelOpenWithoutQuestion()) {
|
||||||
|
pendingQuestion.value = null;
|
||||||
|
questionQueue.value = <IncomingQuestion>[];
|
||||||
|
hasPendingQuestion.value = true;
|
||||||
|
questionPanelOpen.value = true;
|
||||||
|
} else {
|
||||||
|
_clearPendingUi();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
40
lib/services/spelling_text_to_speech.dart
Normal file
40
lib/services/spelling_text_to_speech.dart
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'spelling_tts/spelling_tts_backend.dart';
|
||||||
|
import 'spelling_tts/spelling_tts_flutter.dart'
|
||||||
|
if (dart.library.js_interop) 'spelling_tts/spelling_tts_web.dart';
|
||||||
|
|
||||||
|
/// English system text-to-speech for spelling challenges.
|
||||||
|
///
|
||||||
|
/// On web, uses the browser [speechSynthesis] API directly (avoids
|
||||||
|
/// [flutter_tts] method-channel registration issues). On IO platforms, uses
|
||||||
|
/// [flutter_tts].
|
||||||
|
class SpellingTextToSpeech {
|
||||||
|
SpellingTextToSpeech({SpellingTtsBackend? backend})
|
||||||
|
: _backend = backend ?? createSpellingTtsBackend();
|
||||||
|
|
||||||
|
final SpellingTtsBackend _backend;
|
||||||
|
|
||||||
|
/// Speaks [text] using the platform TTS engine (clears any in-progress utterance).
|
||||||
|
///
|
||||||
|
/// [title] references the question id for the utterance (logged for diagnostics).
|
||||||
|
Future<void> speak(String text, {String? title}) async {
|
||||||
|
final String trimmed = text.trim();
|
||||||
|
if (trimmed.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await _backend.speak(trimmed, title: title);
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('Spelling TTS failed: $e\n$st');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> stop() async {
|
||||||
|
try {
|
||||||
|
await _backend.stop();
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore shutdown errors (e.g. in widget tests without engine).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
lib/services/spelling_tts/spelling_tts_backend.dart
Normal file
5
lib/services/spelling_tts/spelling_tts_backend.dart
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
/// Platform TTS backend for spelling words.
|
||||||
|
abstract class SpellingTtsBackend {
|
||||||
|
Future<void> speak(String text, {String? title});
|
||||||
|
Future<void> stop();
|
||||||
|
}
|
||||||
43
lib/services/spelling_tts/spelling_tts_flutter.dart
Normal file
43
lib/services/spelling_tts/spelling_tts_flutter.dart
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_tts/flutter_tts.dart';
|
||||||
|
|
||||||
|
import 'spelling_tts_backend.dart';
|
||||||
|
|
||||||
|
SpellingTtsBackend createSpellingTtsBackend() => _FlutterTtsBackend();
|
||||||
|
|
||||||
|
class _FlutterTtsBackend implements SpellingTtsBackend {
|
||||||
|
final FlutterTts _tts = FlutterTts();
|
||||||
|
bool _configured = false;
|
||||||
|
|
||||||
|
Future<void> _ensureConfigured() async {
|
||||||
|
if (_configured) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _tts.setLanguage('en-US');
|
||||||
|
await _tts.setSpeechRate(0.42);
|
||||||
|
await _tts.setPitch(1.0);
|
||||||
|
await _tts.awaitSpeakCompletion(true);
|
||||||
|
_configured = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> speak(String text, {String? title}) async {
|
||||||
|
final String trimmed = text.trim();
|
||||||
|
if (trimmed.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _ensureConfigured();
|
||||||
|
await _tts.stop();
|
||||||
|
if (title != null && title.isNotEmpty) {
|
||||||
|
debugPrint('Spelling TTS utterance title: $title');
|
||||||
|
}
|
||||||
|
await _tts.speak(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> stop() async {
|
||||||
|
try {
|
||||||
|
await _tts.stop();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
102
lib/services/spelling_tts/spelling_tts_web.dart
Normal file
102
lib/services/spelling_tts/spelling_tts_web.dart
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:js_interop';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'spelling_tts_backend.dart';
|
||||||
|
|
||||||
|
SpellingTtsBackend createSpellingTtsBackend() => _WebSpeechBackend();
|
||||||
|
|
||||||
|
@JS('speechSynthesis')
|
||||||
|
external SpeechSynthesis get _speechSynthesis;
|
||||||
|
|
||||||
|
@JS()
|
||||||
|
extension type SpeechSynthesis._(JSObject _) implements JSObject {
|
||||||
|
external void speak(SpeechSynthesisUtterance utterance);
|
||||||
|
external void cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
@JS()
|
||||||
|
extension type SpeechSynthesisUtterance._(JSObject _) implements JSObject {
|
||||||
|
external SpeechSynthesisUtterance();
|
||||||
|
|
||||||
|
external String text;
|
||||||
|
external String lang;
|
||||||
|
external double rate;
|
||||||
|
external double pitch;
|
||||||
|
|
||||||
|
@JS('onend')
|
||||||
|
external set onEnd(JSFunction? listener);
|
||||||
|
|
||||||
|
@JS('onerror')
|
||||||
|
external set onError(JSFunction? listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WebSpeechBackend implements SpellingTtsBackend {
|
||||||
|
Completer<void>? _pending;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> speak(String text, {String? title}) async {
|
||||||
|
final String trimmed = text.trim();
|
||||||
|
if (trimmed.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (title != null && title.isNotEmpty) {
|
||||||
|
debugPrint('Spelling TTS utterance title: $title');
|
||||||
|
}
|
||||||
|
|
||||||
|
await stop();
|
||||||
|
|
||||||
|
final Completer<void> done = Completer<void>();
|
||||||
|
_pending = done;
|
||||||
|
|
||||||
|
final SpeechSynthesisUtterance utterance = SpeechSynthesisUtterance();
|
||||||
|
utterance.text = trimmed;
|
||||||
|
utterance.lang = 'en-US';
|
||||||
|
utterance.rate = 0.85;
|
||||||
|
utterance.pitch = 1.0;
|
||||||
|
utterance.onEnd = (() {
|
||||||
|
if (!done.isCompleted) {
|
||||||
|
done.complete();
|
||||||
|
}
|
||||||
|
}).toJS;
|
||||||
|
utterance.onError = ((JSAny event) {
|
||||||
|
debugPrint('Spelling web TTS error: $event');
|
||||||
|
if (!done.isCompleted) {
|
||||||
|
done.complete();
|
||||||
|
}
|
||||||
|
}).toJS;
|
||||||
|
|
||||||
|
try {
|
||||||
|
_speechSynthesis.speak(utterance);
|
||||||
|
await done.future.timeout(
|
||||||
|
const Duration(seconds: 30),
|
||||||
|
onTimeout: () {
|
||||||
|
debugPrint('Spelling web TTS timed out');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('Spelling web TTS failed: $e\n$st');
|
||||||
|
if (!done.isCompleted) {
|
||||||
|
done.complete();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (identical(_pending, done)) {
|
||||||
|
_pending = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> stop() async {
|
||||||
|
final Completer<void>? pending = _pending;
|
||||||
|
_pending = null;
|
||||||
|
try {
|
||||||
|
_speechSynthesis.cancel();
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('Spelling web TTS stop failed: $e\n$st');
|
||||||
|
}
|
||||||
|
pending?.complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
24
lib/utils/spelling_spoken_word.dart
Normal file
24
lib/utils/spelling_spoken_word.dart
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
/// Extracts the displayed spelling word from API question text.
|
||||||
|
///
|
||||||
|
/// Matches prompts like: Is 'colour' spelled correctly?
|
||||||
|
String? spellingWordToSpeak(String questionText) {
|
||||||
|
final RegExp singleQuoted = RegExp(
|
||||||
|
r"Is\s+'([^']+)'\s+spelled",
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
final Match? single = singleQuoted.firstMatch(questionText);
|
||||||
|
if (single != null) {
|
||||||
|
return single.group(1)?.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
final RegExp doubleQuoted = RegExp(
|
||||||
|
r'Is\s+"([^"]+)"\s+spelled',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
final Match? double = doubleQuoted.firstMatch(questionText);
|
||||||
|
if (double != null) {
|
||||||
|
return double.group(1)?.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
82
lib/widgets/hub_toolbar_icon_button.dart
Normal file
82
lib/widgets/hub_toolbar_icon_button.dart
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../theme/app_theme.dart';
|
||||||
|
|
||||||
|
/// Toolbar icon button shared by the signed-in home app bar and admin actions.
|
||||||
|
class HubToolbarIconButton extends StatelessWidget {
|
||||||
|
const HubToolbarIconButton({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.onPressed,
|
||||||
|
required this.tooltip,
|
||||||
|
this.iconColor = AppColors.textPrimary,
|
||||||
|
this.badgeCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
final String tooltip;
|
||||||
|
final Color iconColor;
|
||||||
|
final int? badgeCount;
|
||||||
|
|
||||||
|
static const double slotWidth = 36;
|
||||||
|
|
||||||
|
static ButtonStyle pillStyle({Color? foregroundColor}) {
|
||||||
|
final Color color = foregroundColor ?? AppColors.textPrimary;
|
||||||
|
return IconButton.styleFrom(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
foregroundColor: color,
|
||||||
|
disabledForegroundColor: color.withValues(alpha: 0.38),
|
||||||
|
iconSize: 22,
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
minimumSize: const Size(slotWidth, slotWidth),
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final Widget button = IconButton(
|
||||||
|
onPressed: onPressed,
|
||||||
|
tooltip: tooltip,
|
||||||
|
style: pillStyle(foregroundColor: iconColor),
|
||||||
|
icon: Icon(icon),
|
||||||
|
);
|
||||||
|
|
||||||
|
final int count = badgeCount ?? 0;
|
||||||
|
if (count < 1) {
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: <Widget>[
|
||||||
|
button,
|
||||||
|
Positioned(
|
||||||
|
top: 4,
|
||||||
|
right: 0,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.accent,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: AppColors.surface, width: 1.5),
|
||||||
|
),
|
||||||
|
constraints: const BoxConstraints(minWidth: 18, minHeight: 18),
|
||||||
|
child: Text(
|
||||||
|
'$count',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.background,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
height: 1.1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
lib/widgets/market_question_placeholder.dart
Normal file
65
lib/widgets/market_question_placeholder.dart
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../theme/app_theme.dart';
|
||||||
|
|
||||||
|
/// Shown when market is the only enabled source but no market question is ready.
|
||||||
|
class MarketQuestionPlaceholder extends StatelessWidget {
|
||||||
|
const MarketQuestionPlaceholder({
|
||||||
|
super.key,
|
||||||
|
this.onRetry,
|
||||||
|
this.busy = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final VoidCallback? onRetry;
|
||||||
|
final bool busy;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 360),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: <Widget>[
|
||||||
|
Icon(
|
||||||
|
Icons.show_chart,
|
||||||
|
size: 56,
|
||||||
|
color: AppColors.accent.withValues(alpha: 0.85),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'No market question ready',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Spelling is turned off. A new market question will appear when '
|
||||||
|
'prospective data is available—try again in a moment.',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
if (onRetry != null) ...<Widget>[
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
FilledButton.icon(
|
||||||
|
key: const Key('market-placeholder-retry'),
|
||||||
|
onPressed: busy ? null : onRetry,
|
||||||
|
icon: busy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.refresh, size: 18),
|
||||||
|
label: const Text('Try again'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import '../models/app_user.dart';
|
|||||||
import '../models/sync_result.dart';
|
import '../models/sync_result.dart';
|
||||||
import '../models/user_profile.dart';
|
import '../models/user_profile.dart';
|
||||||
import '../repositories/user_profile_repository.dart';
|
import '../repositories/user_profile_repository.dart';
|
||||||
|
import '../repositories/question_source_config_repository.dart';
|
||||||
import '../services/questions_hub_service.dart';
|
import '../services/questions_hub_service.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
|
|
||||||
@ -37,7 +38,11 @@ class _ProfileSessionState extends State<ProfileSession> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_sessionReady = UserProfileRepository.instance.startSession(widget.user)
|
_sessionReady = UserProfileRepository.instance.startSession(widget.user)
|
||||||
..then((_) => QuestionsHubService.instance.onLogin());
|
..then((_) async {
|
||||||
|
await QuestionSourceConfigRepository.instance
|
||||||
|
.startSession(widget.user.uid);
|
||||||
|
await QuestionsHubService.instance.onLogin();
|
||||||
|
});
|
||||||
_syncStatusSubscription =
|
_syncStatusSubscription =
|
||||||
UserProfileRepository.instance.syncStatusStream.listen((
|
UserProfileRepository.instance.syncStatusStream.listen((
|
||||||
ProfileSyncStatus status,
|
ProfileSyncStatus status,
|
||||||
@ -53,6 +58,7 @@ class _ProfileSessionState extends State<ProfileSession> {
|
|||||||
_syncStatusSubscription?.cancel();
|
_syncStatusSubscription?.cancel();
|
||||||
QuestionsHubService.instance.clearGuessScoreCache();
|
QuestionsHubService.instance.clearGuessScoreCache();
|
||||||
unawaited(QuestionsHubService.instance.disconnect());
|
unawaited(QuestionsHubService.instance.disconnect());
|
||||||
|
unawaited(QuestionSourceConfigRepository.instance.endSession());
|
||||||
UserProfileRepository.instance.endSession();
|
UserProfileRepository.instance.endSession();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
51
lib/widgets/question_presentation_router.dart
Normal file
51
lib/widgets/question_presentation_router.dart
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../models/incoming_question.dart';
|
||||||
|
import 'spelling_question_tile.dart';
|
||||||
|
import 'swipe_question_tile.dart';
|
||||||
|
|
||||||
|
/// Routes an [IncomingQuestion] to the correct presentation widget.
|
||||||
|
class QuestionPresentationRouter extends StatelessWidget {
|
||||||
|
const QuestionPresentationRouter({
|
||||||
|
super.key,
|
||||||
|
required this.question,
|
||||||
|
required this.onAnswer,
|
||||||
|
required this.onDefer,
|
||||||
|
this.onAnswerText,
|
||||||
|
this.onPrepared,
|
||||||
|
this.busy = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IncomingQuestion question;
|
||||||
|
final Future<void> Function(num answer) onAnswer;
|
||||||
|
final Future<void> Function(String answerText)? onAnswerText;
|
||||||
|
final ValueChanged<IncomingQuestion>? onPrepared;
|
||||||
|
final Future<void> Function() onDefer;
|
||||||
|
final bool busy;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (question.usesSpellingWordTile) {
|
||||||
|
return SpellingQuestionTile(
|
||||||
|
key: ValueKey<String>(question.id),
|
||||||
|
questionId: question.id,
|
||||||
|
questionText: question.text,
|
||||||
|
spokenWord: question.spokenWord,
|
||||||
|
audioTitle: question.audioTitle,
|
||||||
|
needsPrepare: question.needsPrepare,
|
||||||
|
busy: busy,
|
||||||
|
onSubmitAnswer: onAnswerText ?? (_) async {},
|
||||||
|
onPrepared: onPrepared,
|
||||||
|
onDefer: onDefer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SwipeQuestionTile(
|
||||||
|
key: ValueKey<String>(question.id),
|
||||||
|
questionId: question.id,
|
||||||
|
busy: busy,
|
||||||
|
onSwipeRight: onAnswer,
|
||||||
|
onSwipeLeft: onDefer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
337
lib/widgets/question_source_settings_sheet.dart
Normal file
337
lib/widgets/question_source_settings_sheet.dart
Normal file
@ -0,0 +1,337 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../models/question_source_preference.dart';
|
||||||
|
import '../repositories/question_source_config_repository.dart';
|
||||||
|
import '../routes/app_routes.dart';
|
||||||
|
import '../screens/spelling_list_screen.dart';
|
||||||
|
import '../theme/app_theme.dart';
|
||||||
|
|
||||||
|
/// Configure which question API sources are active and their relative mix.
|
||||||
|
class QuestionSourceSettingsSheet extends StatefulWidget {
|
||||||
|
const QuestionSourceSettingsSheet({
|
||||||
|
super.key,
|
||||||
|
required this.initialPreferences,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<QuestionSourcePreference> initialPreferences;
|
||||||
|
|
||||||
|
static Future<void> show(BuildContext context) {
|
||||||
|
return showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return Dialog(
|
||||||
|
key: const Key('question-source-settings-dialog'),
|
||||||
|
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 520),
|
||||||
|
child: QuestionSourceSettingsSheet(
|
||||||
|
initialPreferences:
|
||||||
|
QuestionSourceConfigRepository.instance.current,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<QuestionSourceSettingsSheet> createState() =>
|
||||||
|
_QuestionSourceSettingsSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QuestionSourceSettingsSheetState extends State<QuestionSourceSettingsSheet> {
|
||||||
|
late List<QuestionSourcePreference> _draft;
|
||||||
|
String? _validationMessage;
|
||||||
|
bool _saving = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_draft = QuestionSourceConfigRepository.mergeWithDefaults(
|
||||||
|
widget.initialPreferences,
|
||||||
|
).map((QuestionSourcePreference source) => source.copyWith()).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateSource(int index, QuestionSourcePreference next) {
|
||||||
|
setState(() {
|
||||||
|
_draft[index] = next;
|
||||||
|
_validationMessage = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openSpellingWordList(BuildContext context) {
|
||||||
|
Navigator.of(context).pushNamed(AppRoutes.spellingWordList);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save() async {
|
||||||
|
final bool hasEnabled =
|
||||||
|
_draft.any((QuestionSourcePreference source) => source.enabled);
|
||||||
|
if (!hasEnabled) {
|
||||||
|
setState(() {
|
||||||
|
_validationMessage = 'Enable at least one question source.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_saving = true;
|
||||||
|
_validationMessage = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final List<QuestionSourcePreference> normalized =
|
||||||
|
QuestionSourceConfigRepository.mergeWithDefaults(_draft);
|
||||||
|
await QuestionSourceConfigRepository.instance.saveAll(normalized);
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_saving = false;
|
||||||
|
_validationMessage = 'Could not save question sources.';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final Map<String, int> mix =
|
||||||
|
QuestionSourceConfigRepository.effectiveMix(_draft);
|
||||||
|
final int enabledCount =
|
||||||
|
_draft.where((QuestionSourcePreference s) => s.enabled).length;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 8, 16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: <Widget>[
|
||||||
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
|
const Icon(Icons.settings, color: AppColors.accent, size: 22),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Question sources',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
key: const Key('question-source-settings-close'),
|
||||||
|
tooltip: 'Close',
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Choose which pipelines may deliver questions and set their relative mix.',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Flexible(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
children: <Widget>[
|
||||||
|
for (int index = 0; index < _draft.length; index++)
|
||||||
|
_QuestionSourceRow(
|
||||||
|
key: Key('question-source-row-${_draft[index].id}'),
|
||||||
|
source: _draft[index],
|
||||||
|
effectivePercent: mix[_draft[index].id],
|
||||||
|
onChanged: (QuestionSourcePreference next) =>
|
||||||
|
_updateSource(index, next),
|
||||||
|
onEditSpelling: _draft[index].id == 'spelling'
|
||||||
|
? () => _openSpellingWordList(context)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
enabledCount == 0
|
||||||
|
? 'No sources enabled.'
|
||||||
|
: 'Effective mix: ${mix.entries.map((MapEntry<String, int> entry) {
|
||||||
|
final QuestionSourcePreference source = _draft.firstWhere(
|
||||||
|
(QuestionSourcePreference s) => s.id == entry.key,
|
||||||
|
);
|
||||||
|
return '${source.label} ${entry.value}%';
|
||||||
|
}).join(', ')}',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
|
color: AppColors.accent,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_validationMessage != null) ...<Widget>[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_validationMessage!,
|
||||||
|
style: const TextStyle(color: Colors.redAccent),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: <Widget>[
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FilledButton(
|
||||||
|
key: const Key('question-source-settings-save'),
|
||||||
|
onPressed: _saving ? null : _save,
|
||||||
|
child: _saving
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Text('Save'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QuestionSourceRow extends StatelessWidget {
|
||||||
|
const _QuestionSourceRow({
|
||||||
|
super.key,
|
||||||
|
required this.source,
|
||||||
|
required this.effectivePercent,
|
||||||
|
required this.onChanged,
|
||||||
|
this.onEditSpelling,
|
||||||
|
});
|
||||||
|
|
||||||
|
final QuestionSourcePreference source;
|
||||||
|
final int? effectivePercent;
|
||||||
|
final ValueChanged<QuestionSourcePreference> onChanged;
|
||||||
|
final VoidCallback? onEditSpelling;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.surfaceElevated,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: AppColors.accent.withValues(alpha: 0.15)),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: <Widget>[
|
||||||
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
source.label,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (source.comingSoon) ...<Widget>[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.accent.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Coming soon',
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.bodyLarge
|
||||||
|
?.copyWith(
|
||||||
|
color: AppColors.accent,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
source.description,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (onEditSpelling != null)
|
||||||
|
IconButton(
|
||||||
|
key: const Key('question-source-edit-spelling'),
|
||||||
|
tooltip: 'Edit spelling word list',
|
||||||
|
onPressed: onEditSpelling,
|
||||||
|
icon: const Icon(Icons.edit, size: 20),
|
||||||
|
),
|
||||||
|
Switch(
|
||||||
|
key: Key('question-source-enabled-${source.id}'),
|
||||||
|
value: source.enabled,
|
||||||
|
onChanged: source.comingSoon
|
||||||
|
? null
|
||||||
|
: (bool enabled) =>
|
||||||
|
onChanged(source.copyWith(enabled: enabled)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (source.enabled && !source.comingSoon) ...<Widget>[
|
||||||
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
|
const Text('Weight'),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
effectivePercent == null
|
||||||
|
? '${source.ratio}'
|
||||||
|
: '${source.ratio} (${effectivePercent}%)',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.accent,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Slider(
|
||||||
|
key: Key('question-source-ratio-${source.id}'),
|
||||||
|
value: source.ratio.toDouble(),
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
divisions: 20,
|
||||||
|
label: '${source.ratio}',
|
||||||
|
onChanged: (double value) =>
|
||||||
|
onChanged(source.copyWith(ratio: value.round())),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
lib/widgets/spelling_question_placeholder.dart
Normal file
65
lib/widgets/spelling_question_placeholder.dart
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../theme/app_theme.dart';
|
||||||
|
|
||||||
|
/// Shown when spelling is the only enabled source but no spelling question is ready.
|
||||||
|
class SpellingQuestionPlaceholder extends StatelessWidget {
|
||||||
|
const SpellingQuestionPlaceholder({
|
||||||
|
super.key,
|
||||||
|
this.onRetry,
|
||||||
|
this.busy = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final VoidCallback? onRetry;
|
||||||
|
final bool busy;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 360),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: <Widget>[
|
||||||
|
Icon(
|
||||||
|
Icons.spellcheck,
|
||||||
|
size: 56,
|
||||||
|
color: AppColors.accent.withValues(alpha: 0.85),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'No spelling question ready',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Market questions are turned off. Add words in spelling settings '
|
||||||
|
'or try again to load a spelling quiz.',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
if (onRetry != null) ...<Widget>[
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
FilledButton.icon(
|
||||||
|
key: const Key('spelling-placeholder-retry'),
|
||||||
|
onPressed: busy ? null : onRetry,
|
||||||
|
icon: busy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.refresh, size: 18),
|
||||||
|
label: const Text('Try again'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
230
lib/widgets/spelling_question_tile.dart
Normal file
230
lib/widgets/spelling_question_tile.dart
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../models/incoming_question.dart';
|
||||||
|
import '../services/questions_api_service.dart';
|
||||||
|
import '../services/spelling_text_to_speech.dart';
|
||||||
|
import '../theme/app_theme.dart';
|
||||||
|
import '../utils/spelling_spoken_word.dart';
|
||||||
|
|
||||||
|
/// Spelling quiz: hear the word, type your spelling, and submit.
|
||||||
|
class SpellingQuestionTile extends StatefulWidget {
|
||||||
|
const SpellingQuestionTile({
|
||||||
|
super.key,
|
||||||
|
required this.questionId,
|
||||||
|
required this.questionText,
|
||||||
|
required this.onSubmitAnswer,
|
||||||
|
required this.onDefer,
|
||||||
|
this.busy = false,
|
||||||
|
this.spokenWord,
|
||||||
|
this.audioTitle,
|
||||||
|
this.needsPrepare = false,
|
||||||
|
this.onPrepared,
|
||||||
|
this.prepareQuestion,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String questionId;
|
||||||
|
final String questionText;
|
||||||
|
final Future<void> Function(String answerText) onSubmitAnswer;
|
||||||
|
final Future<void> Function() onDefer;
|
||||||
|
final bool busy;
|
||||||
|
|
||||||
|
/// Word read aloud; filled by [prepareQuestion] when omitted.
|
||||||
|
final String? spokenWord;
|
||||||
|
|
||||||
|
/// Question id used as the TTS utterance title.
|
||||||
|
final String? audioTitle;
|
||||||
|
|
||||||
|
/// When true, calls the prepare API to select a word at tile load.
|
||||||
|
final bool needsPrepare;
|
||||||
|
|
||||||
|
/// Called when prepare returns an updated question payload.
|
||||||
|
final ValueChanged<IncomingQuestion>? onPrepared;
|
||||||
|
|
||||||
|
/// Visible for widget tests (defaults to API prepare).
|
||||||
|
final Future<IncomingQuestion?> Function(String questionId)? prepareQuestion;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SpellingQuestionTile> createState() => _SpellingQuestionTileState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
|
||||||
|
final SpellingTextToSpeech _speech = SpellingTextToSpeech();
|
||||||
|
final QuestionsApiService _api = QuestionsApiService();
|
||||||
|
final TextEditingController _answerController = TextEditingController();
|
||||||
|
|
||||||
|
String? _spokenWord;
|
||||||
|
String? _audioTitle;
|
||||||
|
bool _preparing = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_spokenWord = widget.spokenWord;
|
||||||
|
_audioTitle = widget.audioTitle ?? widget.questionId;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => unawaited(_prepareAndSpeak()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(SpellingQuestionTile oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.questionId != widget.questionId) {
|
||||||
|
_answerController.clear();
|
||||||
|
_spokenWord = widget.spokenWord;
|
||||||
|
_audioTitle = widget.audioTitle ?? widget.questionId;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => unawaited(_prepareAndSpeak()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_answerController.dispose();
|
||||||
|
unawaited(_speech.stop());
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _prepareAndSpeak() async {
|
||||||
|
if (_preparing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!widget.needsPrepare) {
|
||||||
|
await _speakWord();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_preparing = true;
|
||||||
|
setState(() {});
|
||||||
|
try {
|
||||||
|
final IncomingQuestion? prepared = widget.prepareQuestion != null
|
||||||
|
? await widget.prepareQuestion!(widget.questionId)
|
||||||
|
: await _api.prepareSpellingQuestion(questionId: widget.questionId);
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (prepared != null) {
|
||||||
|
setState(() {
|
||||||
|
_spokenWord = prepared.spokenWord ?? _spokenWord;
|
||||||
|
_audioTitle = prepared.audioTitle ?? prepared.id;
|
||||||
|
});
|
||||||
|
widget.onPrepared?.call(prepared);
|
||||||
|
}
|
||||||
|
await _speakWord();
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _preparing = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? get _wordToSpeak =>
|
||||||
|
_spokenWord ?? spellingWordToSpeak(widget.questionText);
|
||||||
|
|
||||||
|
bool get _wordReady {
|
||||||
|
final String? word = _wordToSpeak;
|
||||||
|
return word != null && word.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _speakWord() async {
|
||||||
|
final String? word = _wordToSpeak;
|
||||||
|
if (word == null || word.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _speech.speak(
|
||||||
|
word,
|
||||||
|
title: _audioTitle ?? widget.questionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submitAnswer() async {
|
||||||
|
final String answer = _answerController.text.trim();
|
||||||
|
if (answer.isEmpty || widget.busy || _preparing || !_wordReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await widget.onSubmitAnswer(answer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final bool canInteract = _wordReady && !widget.busy && !_preparing;
|
||||||
|
final bool canSpeak = _wordReady;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: <Widget>[
|
||||||
|
if (_preparing)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: 24),
|
||||||
|
child: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Center(
|
||||||
|
child: IconButton.filled(
|
||||||
|
key: const Key('spelling-speak-word'),
|
||||||
|
onPressed: canSpeak && !widget.busy
|
||||||
|
? () => unawaited(_speakWord())
|
||||||
|
: null,
|
||||||
|
tooltip: 'Hear the word',
|
||||||
|
iconSize: 36,
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.surfaceElevated,
|
||||||
|
foregroundColor: canSpeak ? AppColors.accent : AppColors.textSecondary,
|
||||||
|
disabledForegroundColor: AppColors.textSecondary,
|
||||||
|
minimumSize: const Size(72, 72),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.volume_up_rounded),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
TextField(
|
||||||
|
key: const Key('spelling-answer-field'),
|
||||||
|
controller: _answerController,
|
||||||
|
enabled: canInteract,
|
||||||
|
textCapitalization: TextCapitalization.none,
|
||||||
|
autocorrect: false,
|
||||||
|
enableSuggestions: false,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: canInteract ? 'Type the spelling' : 'Loading word…',
|
||||||
|
hintStyle: const TextStyle(color: AppColors.textSecondary),
|
||||||
|
filled: true,
|
||||||
|
fillColor: AppColors.surfaceElevated,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onSubmitted: canInteract ? (_) => unawaited(_submitAnswer()) : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton(
|
||||||
|
key: const Key('spelling-submit-answer'),
|
||||||
|
onPressed: canInteract ? () => unawaited(_submitAnswer()) : null,
|
||||||
|
child: const Text('Submit'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextButton(
|
||||||
|
key: const Key('spelling-skip-question'),
|
||||||
|
onPressed: widget.busy || _preparing ? null : widget.onDefer,
|
||||||
|
child: const Text('Skip for now'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -35,6 +35,7 @@ class SwipeQuestionTile extends StatefulWidget {
|
|||||||
required this.onSwipeLeft,
|
required this.onSwipeLeft,
|
||||||
this.busy = false,
|
this.busy = false,
|
||||||
this.swipeAxisToleranceDegrees = 25,
|
this.swipeAxisToleranceDegrees = 25,
|
||||||
|
this.showGuidGlyph = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String questionId;
|
final String questionId;
|
||||||
@ -45,6 +46,9 @@ class SwipeQuestionTile extends StatefulWidget {
|
|||||||
/// Max deviation from pure horizontal (0°/180°) or vertical (±90°) to lock axis.
|
/// Max deviation from pure horizontal (0°/180°) or vertical (±90°) to lock axis.
|
||||||
final double swipeAxisToleranceDegrees;
|
final double swipeAxisToleranceDegrees;
|
||||||
|
|
||||||
|
/// When false, hides the GUID glyph (used by spelling presentation).
|
||||||
|
final bool showGuidGlyph;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<SwipeQuestionTile> createState() => _SwipeQuestionTileState();
|
State<SwipeQuestionTile> createState() => _SwipeQuestionTileState();
|
||||||
}
|
}
|
||||||
@ -723,22 +727,23 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
if (widget.showGuidGlyph)
|
||||||
top: 8,
|
Positioned(
|
||||||
bottom: 8,
|
top: 8,
|
||||||
left: _centerSideInset,
|
bottom: 8,
|
||||||
right: _centerSideInset,
|
left: _centerSideInset,
|
||||||
child: Center(
|
right: _centerSideInset,
|
||||||
child: Transform.translate(
|
child: Center(
|
||||||
offset: Offset(0, -_snappedVerticalOffset),
|
child: Transform.translate(
|
||||||
child: QuestionGuidGlyph(
|
offset: Offset(0, -_snappedVerticalOffset),
|
||||||
guid: widget.questionId,
|
child: QuestionGuidGlyph(
|
||||||
size: _glyphSize,
|
guid: widget.questionId,
|
||||||
displayValue: _snappedSliderValue,
|
size: _glyphSize,
|
||||||
|
displayValue: _snappedSliderValue,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0,
|
left: 0,
|
||||||
top: 10,
|
top: 10,
|
||||||
|
|||||||
@ -6,8 +6,10 @@ import FlutterMacOS
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
import connectivity_plus
|
import connectivity_plus
|
||||||
|
import file_picker
|
||||||
import firebase_auth
|
import firebase_auth
|
||||||
import firebase_core
|
import firebase_core
|
||||||
|
import flutter_tts
|
||||||
import google_sign_in_ios
|
import google_sign_in_ios
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import sqlite3_flutter_libs
|
import sqlite3_flutter_libs
|
||||||
@ -15,8 +17,10 @@ import url_launcher_macos
|
|||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
||||||
|
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||||
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
|
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
|
||||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||||
|
FlutterTtsPlugin.register(with: registry.registrar(forPlugin: "FlutterTtsPlugin"))
|
||||||
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
|
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin"))
|
Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin"))
|
||||||
|
|||||||
40
pubspec.lock
40
pubspec.lock
@ -185,6 +185,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.2"
|
version: "3.1.2"
|
||||||
|
cross_file:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cross_file
|
||||||
|
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.5+2"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -273,6 +281,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
version: "7.0.1"
|
||||||
|
file_picker:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: file_picker
|
||||||
|
sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.3.7"
|
||||||
firebase_auth:
|
firebase_auth:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -342,11 +358,27 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "6.0.0"
|
||||||
|
flutter_plugin_android_lifecycle:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_plugin_android_lifecycle
|
||||||
|
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.35"
|
||||||
flutter_test:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
flutter_tts:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_tts
|
||||||
|
sha256: ce5eb209b40e95f2f4a1397116c87ab2fcdff32257d04ed7a764e75894c03775
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.2.5"
|
||||||
flutter_web_plugins:
|
flutter_web_plugins:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
@ -1109,6 +1141,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.3"
|
version: "3.0.3"
|
||||||
|
win32:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: win32
|
||||||
|
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.15.0"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -47,6 +47,8 @@ dependencies:
|
|||||||
connectivity_plus: ^6.1.4
|
connectivity_plus: ^6.1.4
|
||||||
signalr_netcore: ^1.4.4
|
signalr_netcore: ^1.4.4
|
||||||
logging: ^1.3.0
|
logging: ^1.3.0
|
||||||
|
file_picker: ^8.3.7
|
||||||
|
flutter_tts: ^4.2.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@ -63,9 +63,14 @@ if [ "${SKIP_PUB_GET:-0}" = "1" ]; then
|
|||||||
_flutter_pub_flag=(--no-pub)
|
_flutter_pub_flag=(--no-pub)
|
||||||
echo "SKIP_PUB_GET=1 — not running flutter pub get"
|
echo "SKIP_PUB_GET=1 — not running flutter pub get"
|
||||||
fi
|
fi
|
||||||
flutter build web --release "${_flutter_pub_flag[@]}" \
|
# --no-tree-shake-icons: keep the full MaterialIcons font so its bytes are
|
||||||
--dart-define=API_BASE_URL="$API_BASE_URL" \
|
# build-invariant. Tree-shaking re-subsets MaterialIcons-Regular.otf every build
|
||||||
--dart-define=AUTH_VERBOSE_LOG=true
|
# (different glyphs, same filename), and stale browser caches of that file make
|
||||||
|
# newly-referenced icons (e.g. settings, admin_panel_settings) render blank.
|
||||||
|
flutter build web --release "${_flutter_pub_flag[@]}" #\
|
||||||
|
# --no-tree-shake-icons \
|
||||||
|
# --dart-define=API_BASE_URL="$API_BASE_URL" \
|
||||||
|
# --dart-define=AUTH_VERBOSE_LOG=true
|
||||||
|
|
||||||
if [ -x "$ROOT/scripts/copy_flutter_js_source_map.sh" ]; then
|
if [ -x "$ROOT/scripts/copy_flutter_js_source_map.sh" ]; then
|
||||||
"$ROOT/scripts/copy_flutter_js_source_map.sh" "$ROOT/build/web" || true
|
"$ROOT/scripts/copy_flutter_js_source_map.sh" "$ROOT/build/web" || true
|
||||||
|
|||||||
@ -25,5 +25,20 @@ if [ -f "$BOOT" ]; then
|
|||||||
sed -i "s|\"mainJsPath\":\"main.dart.js\"|\"mainJsPath\":\"main.dart.js?v=${STAMP}\"|g" "$BOOT"
|
sed -i "s|\"mainJsPath\":\"main.dart.js\"|\"mainJsPath\":\"main.dart.js?v=${STAMP}\"|g" "$BOOT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Icon font: in production the .otf is cached for a day, and historically the
|
||||||
|
# tree-shaken MaterialIcons subset changed between builds under the SAME filename
|
||||||
|
# — so stale caches made newly-used glyphs (settings, admin_panel_settings)
|
||||||
|
# render blank. Rename the font per build and rewrite FontManifest.json so a new
|
||||||
|
# URL is always fetched. Pairs with --no-tree-shake-icons (full, stable font).
|
||||||
|
FONT_DIR="$WEB/assets/fonts"
|
||||||
|
FONT_MANIFEST="$WEB/assets/FontManifest.json"
|
||||||
|
SRC_FONT="$FONT_DIR/MaterialIcons-Regular.otf"
|
||||||
|
if [ -f "$SRC_FONT" ] && [ -f "$FONT_MANIFEST" ]; then
|
||||||
|
NEW_FONT="MaterialIcons-Regular.${STAMP}.otf"
|
||||||
|
cp -f "$SRC_FONT" "$FONT_DIR/$NEW_FONT"
|
||||||
|
sed -i "s|fonts/MaterialIcons-Regular[^\"]*\.otf|fonts/${NEW_FONT}|g" "$FONT_MANIFEST"
|
||||||
|
echo "Stamped MaterialIcons font URL: fonts/$NEW_FONT"
|
||||||
|
fi
|
||||||
|
|
||||||
printf '%s\n' "$STAMP" >"$WEB/BUILD_STAMP.txt"
|
printf '%s\n' "$STAMP" >"$WEB/BUILD_STAMP.txt"
|
||||||
echo "Stamped: $INDEX and flutter_bootstrap.js (if present)"
|
echo "Stamped: $INDEX and flutter_bootstrap.js (if present)"
|
||||||
|
|||||||
207
server/README.md
207
server/README.md
@ -43,33 +43,34 @@ dart pub get
|
|||||||
dart test
|
dart test
|
||||||
```
|
```
|
||||||
|
|
||||||
Integration tests apply migrations `001`–`009` on `cyberhybridhub_test` and truncate
|
Integration tests apply migrations `001`–`009` on `cyberhybridhub_test` and
|
||||||
trading tables between cases. Optional override: `TEST_DATABASE_URL`.
|
truncate trading tables between cases. Optional override: `TEST_DATABASE_URL`.
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
| Method | Path | Auth |
|
| Method | Path | Auth |
|
||||||
|--------|------|------|
|
| ------ | ------------------------------ | ------------------------------------------------------------------------------- |
|
||||||
| `GET` | `/v1/me/profile` | `Authorization: Bearer <Firebase ID token>` |
|
| `GET` | `/v1/me/profile` | `Authorization: Bearer <Firebase ID token>` |
|
||||||
| `PUT` | `/v1/me/profile` | same |
|
| `PUT` | `/v1/me/profile` | same |
|
||||||
| `POST` | `/v1/me/incoming-question` | same — pushes a question to the client via SignalR |
|
| `POST` | `/v1/me/incoming-question` | same — pushes a question to the client via SignalR |
|
||||||
| `POST` | `/v1/me/questions/bootstrap` | create one random prospective question on login (fallback to oldest unanswered) |
|
| `POST` | `/v1/me/questions/bootstrap` | create one random prospective question on login (fallback to oldest unanswered) |
|
||||||
| `GET` | `/v1/me/questions` | list unanswered questions (queue order) |
|
| `GET` | `/v1/me/questions` | list unanswered questions (queue order) |
|
||||||
| `GET` | `/v1/me/questions/score` | current cumulative prospective-question score |
|
| `GET` | `/v1/me/questions/score` | current cumulative prospective-question score |
|
||||||
| `POST` | `/v1/me/questions/score/reset` | reset guess score and answer statistics to zero |
|
| `POST` | `/v1/me/questions/score/reset` | reset guess score and answer statistics to zero |
|
||||||
| `POST` | `/v1/me/questions/{id}/answer` | submit answer (`{"answer": 0}` default) |
|
| `POST` | `/v1/me/questions/{id}/answer` | submit answer (`{"answer": 0}` default) |
|
||||||
| `POST` | `/v1/me/questions/{id}/defer` | move question to end of queue |
|
| `POST` | `/v1/me/questions/{id}/defer` | move question to end of queue |
|
||||||
|
|
||||||
## SignalR — incoming questions
|
## SignalR — incoming questions
|
||||||
|
|
||||||
Hub URL: `http://localhost:3000/hubs/questions`
|
Hub URL: `http://localhost:3000/hubs/questions`
|
||||||
|
|
||||||
The Flutter app calls `POST /v1/me/questions/bootstrap` once at login. The API ensures a
|
The Flutter app calls `POST /v1/me/questions/bootstrap` once at login. The API
|
||||||
`users` row exists, picks a random row from `market_history_prospective_questions`, and
|
ensures a `users` row exists, picks a random row from
|
||||||
creates a user question linked by `metadata.prospective_question_id` (falls back to
|
`market_history_prospective_questions`, and creates a user question linked by
|
||||||
oldest unanswered when no prospective rows exist). After sign-in it connects to SignalR
|
`metadata.prospective_question_id` (falls back to oldest unanswered when no
|
||||||
and listens for `ReceiveQuestion`. On each new WebSocket connection the API only delivers
|
prospective rows exist). After sign-in it connects to SignalR and listens for
|
||||||
an existing unanswered question — it does not create new rows.
|
`ReceiveQuestion`. On each new WebSocket connection the API only delivers an
|
||||||
|
existing unanswered question — it does not create new rows.
|
||||||
|
|
||||||
Client payload (correct answer is not sent):
|
Client payload (correct answer is not sent):
|
||||||
|
|
||||||
@ -83,25 +84,28 @@ Client payload (correct answer is not sent):
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`unansweredCount` is the number of unanswered rows for that user (shown in the app when greater than 1).
|
`unansweredCount` is the number of unanswered rows for that user (shown in the
|
||||||
|
app when greater than 1).
|
||||||
|
|
||||||
`questions` table: `id` (UUID), `assigned_user_id`, `question_text`, `user_response`
|
`questions` table: `id` (UUID), `assigned_user_id`, `question_text`,
|
||||||
(nullable), `correct_answer`, `created_at`, `modified_at`.
|
`user_response` (nullable), `correct_answer`, `created_at`, `modified_at`.
|
||||||
|
|
||||||
`market_history_prospective_answers` table snapshots answered prospective-question context:
|
`market_history_prospective_answers` table snapshots answered
|
||||||
`question_id`, `prospective_question_id`, `symbol`, `older_slot_start`,
|
prospective-question context: `question_id`, `prospective_question_id`,
|
||||||
`newer_slot_start`, `expected_percent_increase_price`, and `user_slider_value`.
|
`symbol`, `older_slot_start`, `newer_slot_start`,
|
||||||
|
`expected_percent_increase_price`, and `user_slider_value`.
|
||||||
|
|
||||||
Guess score (`user_trading_state.context.guess_score`, keyed by Firebase UID):
|
Guess score (`user_trading_state.context.guess_score`, keyed by Firebase UID):
|
||||||
|
|
||||||
- Persists across devices and logins for the same `firebase_uid`.
|
- Persists across devices and logins for the same `firebase_uid`.
|
||||||
- `GET /v1/me/questions/score` and login bootstrap `score` repair counters/total
|
- `GET /v1/me/questions/score` and login bootstrap `score` repair counters/total
|
||||||
from `market_history_prospective_answers` when stored JSON is missing or stale.
|
from `market_history_prospective_answers` when stored JSON is missing or
|
||||||
|
stale.
|
||||||
|
|
||||||
Prospective guess progression (`user_trading_state.context.guess_score`):
|
Prospective guess progression (`user_trading_state.context.guess_score`):
|
||||||
|
|
||||||
- `slot_start` — older session-half edge for the active pair; advances when every
|
- `slot_start` — older session-half edge for the active pair; advances when
|
||||||
top-50% volume symbol in `(slot_start → next slot)` has been answered.
|
every top-50% volume symbol in `(slot_start → next slot)` has been answered.
|
||||||
- Reset sets `slot_start` to the earliest slot in the rolling history window and
|
- Reset sets `slot_start` to the earliest slot in the rolling history window and
|
||||||
clears that user's `market_history_prospective_answers` and
|
clears that user's `market_history_prospective_answers` and
|
||||||
`market_history_prospective_assignments` rows.
|
`market_history_prospective_assignments` rows.
|
||||||
@ -109,14 +113,15 @@ Prospective guess progression (`user_trading_state.context.guess_score`):
|
|||||||
one row per `(user, older_slot_start, newer_slot_start)` written when the
|
one row per `(user, older_slot_start, newer_slot_start)` written when the
|
||||||
question is created (`pending` until answered). Survives logins; unique
|
question is created (`pending` until answered). Survives logins; unique
|
||||||
constraint blocks a second asset for the same slot pair before answer.
|
constraint blocks a second asset for the same slot pair before answer.
|
||||||
- Question pick uses bar audit data for that slot pair (not a global random row).
|
- Question pick uses bar audit data for that slot pair (not a global random
|
||||||
|
row).
|
||||||
|
|
||||||
Prospective answer scoring (`user_trading_state.context.guess_score`):
|
Prospective answer scoring (`user_trading_state.context.guess_score`):
|
||||||
|
|
||||||
| `PROSPECTIVE_ANSWER_CLOSENESS_ENABLED` | Correct sign | Wrong sign |
|
| `PROSPECTIVE_ANSWER_CLOSENESS_ENABLED` | Correct sign | Wrong sign |
|
||||||
|----------------------------------------|--------------|------------|
|
| -------------------------------------- | ------------------------------------------------------------------------------------ | ---------- |
|
||||||
| `false` (default) | `+1` | `-1` |
|
| `false` (default) | `+1` | `-1` |
|
||||||
| `true` | `+1` plus up to `+1` closeness (full bonus within ±1 of expected %, else fractional) | `-2` |
|
| `true` | `+1` plus up to `+1` closeness (full bonus within ±1 of expected %, else fractional) | `-2` |
|
||||||
|
|
||||||
## Background question pipeline
|
## Background question pipeline
|
||||||
|
|
||||||
@ -124,11 +129,11 @@ A background worker runs inside the API process (enabled by default). On each
|
|||||||
interval it walks registered users, fetches data from public web APIs, and
|
interval it walks registered users, fetches data from public web APIs, and
|
||||||
enqueues pipeline questions when the user's queue is not full.
|
enqueues pipeline questions when the user's queue is not full.
|
||||||
|
|
||||||
| Env var | Default | Purpose |
|
| Env var | Default | Purpose |
|
||||||
|---------|---------|---------|
|
| -------------------------------------- | ------- | ------------------------------------------------------------------------------------------- |
|
||||||
| `QUESTION_WORKER_ENABLED` | `true` | Set to `false` to disable the worker |
|
| `QUESTION_WORKER_ENABLED` | `true` | Set to `false` to disable the worker |
|
||||||
| `QUESTION_WORKER_INTERVAL_SECONDS` | `60` | Seconds between maintenance cycles |
|
| `QUESTION_WORKER_INTERVAL_SECONDS` | `60` | Seconds between maintenance cycles |
|
||||||
| `QUESTION_PIPELINE_TEST_MODE` | `false` | Use random -10..10 starter-style questions instead of API copy |
|
| `QUESTION_PIPELINE_TEST_MODE` | `false` | Use random -10..10 starter-style questions instead of API copy |
|
||||||
| `PROSPECTIVE_ANSWER_CLOSENESS_ENABLED` | `false` | Enable closeness bonus scoring (`+1`/`-2` with magnitude bonus) instead of simple `+1`/`-1` |
|
| `PROSPECTIVE_ANSWER_CLOSENESS_ENABLED` | `false` | Enable closeness bonus scoring (`+1`/`-2` with magnitude bonus) instead of simple `+1`/`-1` |
|
||||||
|
|
||||||
**External APIs used**
|
**External APIs used**
|
||||||
@ -139,8 +144,8 @@ enqueues pipeline questions when the user's queue is not full.
|
|||||||
**Branching flow**
|
**Branching flow**
|
||||||
|
|
||||||
1. **Track choice** — user swipes toward +10 (weather) or -10 (geography).
|
1. **Track choice** — user swipes toward +10 (weather) or -10 (geography).
|
||||||
2. **Geography** — yes/no population threshold, then capital confirmation;
|
2. **Geography** — yes/no population threshold, then capital confirmation; wrong
|
||||||
wrong population guess triggers a recovery question.
|
population guess triggers a recovery question.
|
||||||
3. **Weather** — yes/no warm/cool for a random European city, then a follow-up
|
3. **Weather** — yes/no warm/cool for a random European city, then a follow-up
|
||||||
to continue weather or switch to geography.
|
to continue weather or switch to geography.
|
||||||
|
|
||||||
@ -163,66 +168,80 @@ curl -s -X POST http://localhost:3000/v1/me/incoming-question \
|
|||||||
|
|
||||||
## Market history
|
## Market history
|
||||||
|
|
||||||
Alpaca **`1Min`** bars aggregated into two **US regular-session** half-days per trading day
|
Alpaca **`1Min`** bars aggregated into two **US regular-session** half-days per
|
||||||
(morning **9:30–12:45 ET**, afternoon **12:45–16:00 ET**, ~195 minutes each).
|
trading day (morning **9:30–12:45 ET**, afternoon **12:45–16:00 ET**, ~195
|
||||||
Stored as `metric=bar`, `timeframe=sessionHalf`. Rolling window: `MARKET_HISTORY_WINDOW_DAYS` (default **5 US trading days**, two session halves each; weekends/holidays skipped).
|
minutes each). Stored as `metric=bar`, `timeframe=sessionHalf`. Rolling window:
|
||||||
|
`MARKET_HISTORY_WINDOW_DAYS` (default **5 US trading days**, two session halves
|
||||||
|
each; weekends/holidays skipped).
|
||||||
|
|
||||||
**Backfill** (`kind=backfill`): fetches each **ended** slot still missing in DB; skips the open slot.
|
**Backfill** (`kind=backfill`): fetches each **ended** slot still missing in DB;
|
||||||
Throttled to `MARKET_HISTORY_API_REQUESTS_PER_MINUTE` (default 200/min). On HTTP 429: wait 1 minute, retry once; if still limited, save partial rows and resume next tick.
|
skips the open slot. Throttled to `MARKET_HISTORY_API_REQUESTS_PER_MINUTE`
|
||||||
Runs when `hasPendingSlots` is true (worker tick). One bars request per slot × symbol batch.
|
(default 200/min). On HTTP 429: wait 1 minute, retry once; if still limited,
|
||||||
Before each worker or admin pipeline, orphaned `market_data_sync_runs` rows with
|
save partial rows and resume next tick. Runs when `hasPendingSlots` is true
|
||||||
`finished_at IS NULL` are closed (stale rows first, then any remaining orphans) so a
|
(worker tick). One bars request per slot × symbol batch. Before each worker or
|
||||||
crashed prior sync cannot block new work.
|
admin pipeline, orphaned `market_data_sync_runs` rows with `finished_at IS NULL`
|
||||||
**Universe** / **cleanup** keep hour-based cadence. `guess_weekly_move` reads these bars.
|
are closed (stale rows first, then any remaining orphans) so a crashed prior
|
||||||
|
sync cannot block new work. **Universe** / **cleanup** keep hour-based cadence.
|
||||||
|
`guess_weekly_move` reads these bars.
|
||||||
|
|
||||||
**Worker pipeline** (each tick when sync enabled): `universe` → `backfill` → `cleanup` →
|
**Worker pipeline** (each tick when sync enabled): `universe` → `backfill` →
|
||||||
prospective-question refresh (silent; not logged to `market_data_sync_runs`).
|
`cleanup` → prospective-question refresh (silent; not logged to
|
||||||
|
`market_data_sync_runs`).
|
||||||
|
|
||||||
**Prospective questions** (`market_history_prospective_questions`, migration `012`):
|
**Prospective questions** (`market_history_prospective_questions`, migration
|
||||||
- **Identity:** `UNIQUE (symbol, older_slot_start, newer_slot_start)` — one row per
|
`012`):
|
||||||
asset per canonical slot pair (slot starts snapped to session-half boundaries).
|
|
||||||
- **Refresh:** top 50% by mean dollar volume for the latest completed pair; `INSERT …
|
- **Identity:** `UNIQUE (symbol, older_slot_start, newer_slot_start)` — one row
|
||||||
ON CONFLICT DO UPDATE`; prune symbols that dropped out of the top half for that pair;
|
per asset per canonical slot pair (slot starts snapped to session-half
|
||||||
`pg_advisory_xact_lock` so overlapping ticks cannot duplicate rows.
|
boundaries).
|
||||||
- **Answer:** `correct_answer` = percent change in average OHLC price (older → newer).
|
- **Refresh:** top 50% by mean dollar volume for the latest completed pair;
|
||||||
- **Cleanup:** `cleanup` deletes rows with `older_slot_start` before the retention
|
`INSERT …
|
||||||
cutoff (`MARKET_HISTORY_RETENTION_DAYS`, same as `market_data_snapshots`). Refresh
|
ON CONFLICT DO UPDATE`; prune symbols that dropped out of the top
|
||||||
also purges expired rows before upserting.
|
half for that pair; `pg_advisory_xact_lock` so overlapping ticks cannot
|
||||||
|
duplicate rows.
|
||||||
|
- **Answer:** `correct_answer` = percent change in average OHLC price (older →
|
||||||
|
newer).
|
||||||
|
- **Cleanup:** `cleanup` deletes rows with `older_slot_start` before the
|
||||||
|
retention cutoff (`MARKET_HISTORY_RETENTION_DAYS`, same as
|
||||||
|
`market_data_snapshots`). Refresh also purges expired rows before upserting.
|
||||||
|
|
||||||
Requires `TRADING_ENABLED=true` when `MARKET_HISTORY_SYNC_ENABLED=true`.
|
Requires `TRADING_ENABLED=true` when `MARKET_HISTORY_SYNC_ENABLED=true`.
|
||||||
|
|
||||||
**Migration `008`:** legacy `1Day` / `4Hour` history cleanup and `slots_synced` on sync runs.
|
**Migration `008`:** legacy `1Day` / `4Hour` history cleanup and `slots_synced`
|
||||||
|
on sync runs.
|
||||||
|
|
||||||
**Migration `010`:** deletes `4Hour` bars, adds `sessionHalf` timeframe + partial index.
|
**Migration `010`:** deletes `4Hour` bars, adds `sessionHalf` timeframe +
|
||||||
|
partial index.
|
||||||
|
|
||||||
**Migration `009`:** adds `market_data_sync_runs.backfill_items` JSONB — per-slot UTC start + symbol list for each backfill run.
|
**Migration `009`:** adds `market_data_sync_runs.backfill_items` JSONB —
|
||||||
|
per-slot UTC start + symbol list for each backfill run.
|
||||||
|
|
||||||
| Env var | Default | Purpose |
|
| Env var | Default | Purpose |
|
||||||
|---------|---------|---------|
|
| ---------------------------------------- | --------- | ----------------------------------------------------------------- |
|
||||||
| `MARKET_HISTORY_SYNC_ENABLED` | `false` | Worker: universe → backfill → cleanup |
|
| `MARKET_HISTORY_SYNC_ENABLED` | `false` | Worker: universe → backfill → cleanup |
|
||||||
| `MARKET_HISTORY_WINDOW_DAYS` | `5` | Oldest slot start to retain / backfill |
|
| `MARKET_HISTORY_WINDOW_DAYS` | `5` | Oldest slot start to retain / backfill |
|
||||||
| `MARKET_HISTORY_RETENTION_DAYS` | `5` | Delete `as_of` older than this |
|
| `MARKET_HISTORY_RETENTION_DAYS` | `5` | Delete `as_of` older than this |
|
||||||
| `MARKET_HISTORY_ARCHIVE_ENABLED` | `false` | Archive before delete |
|
| `MARKET_HISTORY_ARCHIVE_ENABLED` | `false` | Archive before delete |
|
||||||
| `MARKET_UNIVERSE_REFRESH_HOURS` | `24` | Min hours between universe syncs |
|
| `MARKET_UNIVERSE_REFRESH_HOURS` | `24` | Min hours between universe syncs |
|
||||||
| `MARKET_HISTORY_SYNC_HOURS` | `24` | Unused for backfill (slot-gated); fallback if no slot gate |
|
| `MARKET_HISTORY_SYNC_HOURS` | `24` | Unused for backfill (slot-gated); fallback if no slot gate |
|
||||||
| `MARKET_HISTORY_CLEANUP_HOURS` | `24` | Min hours between cleanups |
|
| `MARKET_HISTORY_CLEANUP_HOURS` | `24` | Min hours between cleanups |
|
||||||
| `MARKET_HISTORY_SYNC_HOUR_UTC` | *(unset)* | UTC hour floor; same-day cap applies to universe/cleanup only |
|
| `MARKET_HISTORY_SYNC_HOUR_UTC` | _(unset)_ | UTC hour floor; same-day cap applies to universe/cleanup only |
|
||||||
| `HISTORY_SYNC_BATCH_SIZE` | `50` | Symbols per bars request |
|
| `HISTORY_SYNC_BATCH_SIZE` | `50` | Symbols per bars request |
|
||||||
| `HISTORY_SYNC_MAX_SYMBOLS` | `2000` | Max symbols per backfill run |
|
| `HISTORY_SYNC_MAX_SYMBOLS` | `2000` | Max symbols per backfill run |
|
||||||
| `MARKET_HISTORY_API_REQUESTS_PER_MINUTE` | `200` | Max Alpaca HTTP calls per rolling minute during backfill |
|
| `MARKET_HISTORY_API_REQUESTS_PER_MINUTE` | `200` | Max Alpaca HTTP calls per rolling minute during backfill |
|
||||||
| `MARKET_HISTORY_SYNC_STALE_MINUTES` | `30` | Abort in-progress sync rows older than this before a new pipeline |
|
| `MARKET_HISTORY_SYNC_STALE_MINUTES` | `30` | Abort in-progress sync rows older than this before a new pipeline |
|
||||||
| `MIN_BARS_FOR_GUESS` | `5` | Min 4-hour bars for guess eligibility |
|
| `MIN_BARS_FOR_GUESS` | `5` | Min 4-hour bars for guess eligibility |
|
||||||
| `GUESS_COOLDOWN_HOURS` | `24` | Per-symbol guess cooldown |
|
| `GUESS_COOLDOWN_HOURS` | `24` | Per-symbol guess cooldown |
|
||||||
|
|
||||||
## Admin portal (market history log)
|
## Admin portal (market history log)
|
||||||
|
|
||||||
Read-only audit log and on-demand triggers for universe sync, backfill, and
|
Read-only audit log and on-demand triggers for universe sync, backfill, and
|
||||||
retention cleanup. Mounted when `ADMIN_PORTAL_ENABLED=true`.
|
retention cleanup. Mounted when `ADMIN_PORTAL_ENABLED=true`.
|
||||||
|
|
||||||
| Env var | Default | Purpose |
|
| Env var | Default | Purpose |
|
||||||
|---------|---------|---------|
|
| ---------------------- | --------- | ---------------------------------------------------------------- |
|
||||||
| `ADMIN_PORTAL_ENABLED` | `false` | Mount `/v1/admin/market-history/*` and `/v1/admin/market-data/*` |
|
| `ADMIN_PORTAL_ENABLED` | `false` | Mount `/v1/admin/market-history/*` and `/v1/admin/market-data/*` |
|
||||||
| `ADMIN_FIREBASE_UIDS` | *(empty)* | Comma-separated Firebase UIDs allowed to call admin routes |
|
| `ADMIN_FIREBASE_UIDS` | _(empty)_ | Comma-separated Firebase UIDs allowed to call admin routes |
|
||||||
|
|
||||||
Requires `TRADING_ENABLED=true`, Alpaca credentials, and
|
Requires `TRADING_ENABLED=true`, Alpaca credentials, and
|
||||||
`QUESTION_PIPELINE_TEST_MODE=false` for on-demand resync/cleanup when
|
`QUESTION_PIPELINE_TEST_MODE=false` for on-demand resync/cleanup when
|
||||||
@ -230,16 +249,22 @@ Requires `TRADING_ENABLED=true`, Alpaca credentials, and
|
|||||||
week-coverage calendar remain available read-only; resync/cleanup return `503`.
|
week-coverage calendar remain available read-only; resync/cleanup return `503`.
|
||||||
Scheduled worker sync also requires `MARKET_HISTORY_SYNC_ENABLED=true`.
|
Scheduled worker sync also requires `MARKET_HISTORY_SYNC_ENABLED=true`.
|
||||||
|
|
||||||
| Method | Path | Auth |
|
| Method | Path | Auth |
|
||||||
|--------|------|------|
|
| ------ | --------------------------------------------------- | ------------------ |
|
||||||
| `GET` | `/v1/admin/market-history/sync-runs` | Admin Firebase UID |
|
| `GET` | `/v1/admin/market-history/sync-runs` | Admin Firebase UID |
|
||||||
| `POST` | `/v1/admin/market-data/resync` | Admin Firebase UID |
|
| `POST` | `/v1/admin/market-data/resync` | Admin Firebase UID |
|
||||||
| `POST` | `/v1/admin/market-data/cleanup?archive=true\|false` | Admin Firebase UID |
|
| `POST` | `/v1/admin/market-data/cleanup?archive=true\|false` | Admin Firebase UID |
|
||||||
|
|
||||||
`sync-runs` includes optional `config` when the server has market-history env loaded:
|
`sync-runs` includes optional `config` when the server has market-history env
|
||||||
|
loaded:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "archiveEnabled": true, "windowDays": 5, "retentionDays": 5, "syncEnabled": true }
|
{
|
||||||
|
"archiveEnabled": true,
|
||||||
|
"windowDays": 5,
|
||||||
|
"retentionDays": 5,
|
||||||
|
"syncEnabled": true
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Flutter uses `config.archiveEnabled` to show the archive checkbox in the cleanup
|
Flutter uses `config.archiveEnabled` to show the archive checkbox in the cleanup
|
||||||
|
|||||||
@ -13,11 +13,16 @@ import '../lib/firebase_auth.dart';
|
|||||||
import '../lib/handlers/incoming_question_handler.dart';
|
import '../lib/handlers/incoming_question_handler.dart';
|
||||||
import '../lib/handlers/market_history_admin_handler.dart';
|
import '../lib/handlers/market_history_admin_handler.dart';
|
||||||
import '../lib/handlers/profile_handler.dart';
|
import '../lib/handlers/profile_handler.dart';
|
||||||
|
import '../lib/handlers/question_source_handler.dart';
|
||||||
import '../lib/handlers/questions_handler.dart';
|
import '../lib/handlers/questions_handler.dart';
|
||||||
import '../lib/handlers/questions_hub_handler.dart';
|
import '../lib/handlers/questions_hub_handler.dart';
|
||||||
|
import '../lib/handlers/spelling_words_handler.dart';
|
||||||
import '../lib/handlers/trading_dev_handler.dart';
|
import '../lib/handlers/trading_dev_handler.dart';
|
||||||
import '../lib/pipeline/question_pipeline.dart';
|
import '../lib/pipeline/question_pipeline.dart';
|
||||||
|
import '../lib/question_dispatcher.dart';
|
||||||
import '../lib/question_service.dart';
|
import '../lib/question_service.dart';
|
||||||
|
import '../lib/spelling/spelling_items_db.dart';
|
||||||
|
import '../lib/question_source_preferences_db.dart';
|
||||||
import '../lib/questions_db.dart';
|
import '../lib/questions_db.dart';
|
||||||
import '../lib/trading/prospective_answer_scoring.dart';
|
import '../lib/trading/prospective_answer_scoring.dart';
|
||||||
import '../lib/trading/guardrails.dart';
|
import '../lib/trading/guardrails.dart';
|
||||||
@ -61,9 +66,14 @@ Future<void> main() async {
|
|||||||
|
|
||||||
final FirebaseAuthVerifier auth = FirebaseAuthVerifier(env.firebaseWebApiKey);
|
final FirebaseAuthVerifier auth = FirebaseAuthVerifier(env.firebaseWebApiKey);
|
||||||
final QuestionsDb questionsDb = QuestionsDb(db.connection);
|
final QuestionsDb questionsDb = QuestionsDb(db.connection);
|
||||||
|
final QuestionSourcePreferencesDb questionSourcePreferencesDb =
|
||||||
|
QuestionSourcePreferencesDb(db.connection);
|
||||||
|
final SpellingItemsDb spellingItemsDb = SpellingItemsDb(db.connection);
|
||||||
|
await spellingItemsDb.ensureCatalogSeeded();
|
||||||
final QuestionService questionService = QuestionService(
|
final QuestionService questionService = QuestionService(
|
||||||
questionsDb: questionsDb,
|
questionsDb: questionsDb,
|
||||||
hubConnections: questionsHubConnections,
|
hubConnections: questionsHubConnections,
|
||||||
|
questionSourcePreferencesDb: questionSourcePreferencesDb,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Trading wiring (gated by TRADING_ENABLED). The trading pipeline plugs
|
// Trading wiring (gated by TRADING_ENABLED). The trading pipeline plugs
|
||||||
@ -225,8 +235,17 @@ Future<void> main() async {
|
|||||||
questionsDb: questionsDb,
|
questionsDb: questionsDb,
|
||||||
questionService: questionService,
|
questionService: questionService,
|
||||||
tradingPipeline: tradingPipeline,
|
tradingPipeline: tradingPipeline,
|
||||||
|
questionSourcePreferencesDb: questionSourcePreferencesDb,
|
||||||
|
spellingItemsDb: spellingItemsDb,
|
||||||
testMode: env.questionPipelineTestMode,
|
testMode: env.questionPipelineTestMode,
|
||||||
);
|
);
|
||||||
|
final QuestionDispatcher questionDispatcher = QuestionDispatcher(
|
||||||
|
questionsDb: questionsDb,
|
||||||
|
questionService: questionService,
|
||||||
|
questionPipeline: questionPipeline,
|
||||||
|
preferencesDb: questionSourcePreferencesDb,
|
||||||
|
);
|
||||||
|
questionPipeline.attachQuestionDispatcher(questionDispatcher);
|
||||||
QuestionBackgroundWorker? backgroundWorker;
|
QuestionBackgroundWorker? backgroundWorker;
|
||||||
if (env.questionWorkerEnabled) {
|
if (env.questionWorkerEnabled) {
|
||||||
backgroundWorker = QuestionBackgroundWorker(
|
backgroundWorker = QuestionBackgroundWorker(
|
||||||
@ -238,6 +257,14 @@ Future<void> main() async {
|
|||||||
backgroundWorker.start();
|
backgroundWorker.start();
|
||||||
}
|
}
|
||||||
final Handler profile = profileHandler(db: db, auth: auth);
|
final Handler profile = profileHandler(db: db, auth: auth);
|
||||||
|
final Handler questionSources = questionSourceHandler(
|
||||||
|
db: questionSourcePreferencesDb,
|
||||||
|
auth: auth,
|
||||||
|
);
|
||||||
|
final Handler spellingWords = spellingWordsHandler(
|
||||||
|
auth: auth,
|
||||||
|
spellingItemsDb: spellingItemsDb,
|
||||||
|
);
|
||||||
final Handler questionsHub = questionsHubHandler(
|
final Handler questionsHub = questionsHubHandler(
|
||||||
auth: auth,
|
auth: auth,
|
||||||
questionService: questionService,
|
questionService: questionService,
|
||||||
@ -250,6 +277,7 @@ Future<void> main() async {
|
|||||||
auth: auth,
|
auth: auth,
|
||||||
questionsDb: questionsDb,
|
questionsDb: questionsDb,
|
||||||
questionService: questionService,
|
questionService: questionService,
|
||||||
|
questionDispatcher: questionDispatcher,
|
||||||
questionPipeline: questionPipeline,
|
questionPipeline: questionPipeline,
|
||||||
);
|
);
|
||||||
final Handler? tradingDev = tradingDevActions == null
|
final Handler? tradingDev = tradingDevActions == null
|
||||||
@ -289,6 +317,12 @@ Future<void> main() async {
|
|||||||
if (path.startsWith(questionsBasePath)) {
|
if (path.startsWith(questionsBasePath)) {
|
||||||
return questions(request);
|
return questions(request);
|
||||||
}
|
}
|
||||||
|
if (path == questionSourcesBasePath) {
|
||||||
|
return questionSources(request);
|
||||||
|
}
|
||||||
|
if (path.startsWith(spellingWordsBasePath)) {
|
||||||
|
return spellingWords(request);
|
||||||
|
}
|
||||||
return profile(request);
|
return profile(request);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
82
server/lib/handlers/question_source_handler.dart
Normal file
82
server/lib/handlers/question_source_handler.dart
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:shelf/shelf.dart';
|
||||||
|
|
||||||
|
import '../cors_headers.dart';
|
||||||
|
import '../firebase_auth.dart';
|
||||||
|
import '../question_source_preferences.dart';
|
||||||
|
import '../question_source_preferences_db.dart';
|
||||||
|
|
||||||
|
const String questionSourcesBasePath = '/v1/me/question-sources';
|
||||||
|
|
||||||
|
Handler questionSourceHandler({
|
||||||
|
required QuestionSourcePreferencesDb db,
|
||||||
|
required FirebaseAuthVerifier auth,
|
||||||
|
}) {
|
||||||
|
return (Request request) async {
|
||||||
|
if (request.method == 'OPTIONS') {
|
||||||
|
return Response.ok(
|
||||||
|
'',
|
||||||
|
headers: apiCorsHeaders(methods: 'GET, PUT, OPTIONS'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.requestedUri.path != questionSourcesBasePath) {
|
||||||
|
return _jsonResponse(404, <String, dynamic>{'error': 'Not found'});
|
||||||
|
}
|
||||||
|
|
||||||
|
final String? firebaseUid = await auth.verifyBearerToken(
|
||||||
|
request.headers['Authorization'] ?? request.headers['authorization'],
|
||||||
|
);
|
||||||
|
if (firebaseUid == null) {
|
||||||
|
return _jsonResponse(401, <String, dynamic>{'error': 'Unauthorized'});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (request.method == 'GET') {
|
||||||
|
final List<ResolvedQuestionSourcePreference> sources =
|
||||||
|
await db.getResolved(firebaseUid);
|
||||||
|
return _jsonResponse(
|
||||||
|
200,
|
||||||
|
QuestionSourcePreferences.toApiPayload(sources: sources),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method == 'PUT') {
|
||||||
|
final String body = await request.readAsString();
|
||||||
|
final Map<String, dynamic> json =
|
||||||
|
jsonDecode(body) as Map<String, dynamic>;
|
||||||
|
final List<Map<String, dynamic>> stored =
|
||||||
|
QuestionSourcePreferences.parsePutBody(json);
|
||||||
|
final List<ResolvedQuestionSourcePreference> saved =
|
||||||
|
await db.save(firebaseUid, stored);
|
||||||
|
return _jsonResponse(
|
||||||
|
200,
|
||||||
|
QuestionSourcePreferences.toApiPayload(
|
||||||
|
sources: saved,
|
||||||
|
updatedAt: DateTime.now().toUtc(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _jsonResponse(405, <String, dynamic>{'error': 'Method not allowed'});
|
||||||
|
} on QuestionSourceValidationException catch (e) {
|
||||||
|
return _jsonResponse(400, <String, dynamic>{'error': e.message});
|
||||||
|
} catch (e, st) {
|
||||||
|
stderr.writeln('Question source handler error: $e\n$st');
|
||||||
|
return _jsonResponse(500, <String, dynamic>{'error': 'Internal error'});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Response _jsonResponse(int status, Map<String, dynamic> body) {
|
||||||
|
return Response(
|
||||||
|
status,
|
||||||
|
body: jsonEncode(body),
|
||||||
|
headers: <String, String>{
|
||||||
|
...apiCorsHeaders(methods: 'GET, PUT, OPTIONS'),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import 'package:shelf_router/shelf_router.dart';
|
|||||||
import '../cors_headers.dart';
|
import '../cors_headers.dart';
|
||||||
import '../firebase_auth.dart';
|
import '../firebase_auth.dart';
|
||||||
import '../pipeline/question_pipeline.dart';
|
import '../pipeline/question_pipeline.dart';
|
||||||
|
import '../question_dispatcher.dart';
|
||||||
import '../question_service.dart';
|
import '../question_service.dart';
|
||||||
import '../questions_db.dart';
|
import '../questions_db.dart';
|
||||||
|
|
||||||
@ -16,6 +17,7 @@ Handler questionsHandler({
|
|||||||
required FirebaseAuthVerifier auth,
|
required FirebaseAuthVerifier auth,
|
||||||
required QuestionsDb questionsDb,
|
required QuestionsDb questionsDb,
|
||||||
required QuestionService questionService,
|
required QuestionService questionService,
|
||||||
|
required QuestionDispatcher questionDispatcher,
|
||||||
QuestionPipeline? questionPipeline,
|
QuestionPipeline? questionPipeline,
|
||||||
}) {
|
}) {
|
||||||
final Router router = Router();
|
final Router router = Router();
|
||||||
@ -26,8 +28,9 @@ Handler questionsHandler({
|
|||||||
return _jsonResponse(401, <String, dynamic>{'error': 'Unauthorized'});
|
return _jsonResponse(401, <String, dynamic>{'error': 'Unauthorized'});
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
await questionService.bootstrapOnLogin(firebaseUid);
|
||||||
final Map<String, dynamic>? question =
|
final Map<String, dynamic>? question =
|
||||||
await questionService.bootstrapOnLogin(firebaseUid);
|
await questionDispatcher.dispatchNextQuestion(firebaseUid);
|
||||||
final int unansweredCount =
|
final int unansweredCount =
|
||||||
await questionsDb.countUnansweredQuestions(firebaseUid);
|
await questionsDb.countUnansweredQuestions(firebaseUid);
|
||||||
final Map<String, dynamic> score =
|
final Map<String, dynamic> score =
|
||||||
@ -49,16 +52,8 @@ Handler questionsHandler({
|
|||||||
return _jsonResponse(401, <String, dynamic>{'error': 'Unauthorized'});
|
return _jsonResponse(401, <String, dynamic>{'error': 'Unauthorized'});
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final List<Map<String, dynamic>> rows =
|
final List<Map<String, dynamic>> questions =
|
||||||
await questionsDb.listUnansweredQuestions(firebaseUid);
|
await questionDispatcher.listPanelQuestions(firebaseUid);
|
||||||
final List<Map<String, dynamic>> questions = rows
|
|
||||||
.map(
|
|
||||||
(Map<String, dynamic> row) => questionsDb.toClientPayload(
|
|
||||||
row,
|
|
||||||
unansweredCount: rows.length,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList();
|
|
||||||
return _jsonResponse(200, <String, dynamic>{
|
return _jsonResponse(200, <String, dynamic>{
|
||||||
'questions': questions,
|
'questions': questions,
|
||||||
'unansweredCount': questions.length,
|
'unansweredCount': questions.length,
|
||||||
@ -95,7 +90,7 @@ Handler questionsHandler({
|
|||||||
final Map<String, dynamic> score =
|
final Map<String, dynamic> score =
|
||||||
await questionsDb.resetGuessScoreSummary(firebaseUid);
|
await questionsDb.resetGuessScoreSummary(firebaseUid);
|
||||||
final Map<String, dynamic>? question =
|
final Map<String, dynamic>? question =
|
||||||
await questionService.ensureProspectiveQuestionQueued(firebaseUid);
|
await questionDispatcher.dispatchNextQuestion(firebaseUid);
|
||||||
final int unansweredCount =
|
final int unansweredCount =
|
||||||
await questionsDb.countUnansweredQuestions(firebaseUid);
|
await questionsDb.countUnansweredQuestions(firebaseUid);
|
||||||
return _jsonResponse(200, <String, dynamic>{
|
return _jsonResponse(200, <String, dynamic>{
|
||||||
@ -121,12 +116,14 @@ Handler questionsHandler({
|
|||||||
final Map<String, dynamic> json = body.isEmpty
|
final Map<String, dynamic> json = body.isEmpty
|
||||||
? <String, dynamic>{}
|
? <String, dynamic>{}
|
||||||
: jsonDecode(body) as Map<String, dynamic>;
|
: jsonDecode(body) as Map<String, dynamic>;
|
||||||
final num answer = (json['answer'] as num?) ?? 0;
|
final String? answerText = json['answerText'] as String?;
|
||||||
|
final num? answer = json['answer'] as num?;
|
||||||
|
|
||||||
final Map<String, dynamic>? updated = await questionsDb.submitAnswer(
|
final Map<String, dynamic>? updated = await questionsDb.submitAnswer(
|
||||||
questionId: id,
|
questionId: id,
|
||||||
assignedUserId: firebaseUid,
|
assignedUserId: firebaseUid,
|
||||||
userResponse: answer,
|
userResponse: answer,
|
||||||
|
userResponseText: answerText,
|
||||||
);
|
);
|
||||||
if (updated == null) {
|
if (updated == null) {
|
||||||
return _jsonResponse(404, <String, dynamic>{'error': 'Not found'});
|
return _jsonResponse(404, <String, dynamic>{'error': 'Not found'});
|
||||||
@ -135,36 +132,30 @@ Handler questionsHandler({
|
|||||||
await questionPipeline.onAnswerSubmitted(
|
await questionPipeline.onAnswerSubmitted(
|
||||||
firebaseUid: firebaseUid,
|
firebaseUid: firebaseUid,
|
||||||
answeredQuestion: updated,
|
answeredQuestion: updated,
|
||||||
userResponse: answer,
|
userResponse: updated['userResponse'] as num? ?? answer ?? 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
var unansweredCount =
|
var unansweredCount =
|
||||||
await questionsDb.countUnansweredQuestions(firebaseUid);
|
await questionsDb.countUnansweredQuestions(firebaseUid);
|
||||||
Map<String, dynamic>? nextQuestion;
|
final Map<String, dynamic>? nextQuestion =
|
||||||
if (unansweredCount == 0) {
|
await questionDispatcher.nextQuestionAfterAnswer(
|
||||||
nextQuestion =
|
firebaseUid: firebaseUid,
|
||||||
await questionService.ensureProspectiveQuestionQueued(firebaseUid);
|
answeredQuestion: updated,
|
||||||
if (nextQuestion != null) {
|
);
|
||||||
unansweredCount =
|
if (nextQuestion != null) {
|
||||||
(nextQuestion['unansweredCount'] as num?)?.toInt() ??
|
unansweredCount =
|
||||||
await questionsDb.countUnansweredQuestions(firebaseUid);
|
(nextQuestion['unansweredCount'] as num?)?.toInt() ??
|
||||||
}
|
unansweredCount;
|
||||||
} else {
|
|
||||||
final Map<String, dynamic>? nextRow =
|
|
||||||
await questionsDb.findUnansweredQuestion(firebaseUid);
|
|
||||||
if (nextRow != null) {
|
|
||||||
nextQuestion = questionsDb.toClientPayload(
|
|
||||||
nextRow,
|
|
||||||
unansweredCount: unansweredCount,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
final Map<String, dynamic> score =
|
final bool isMarketAnswer =
|
||||||
await questionsDb.getGuessScoreSummary(firebaseUid);
|
QuestionsDb.resolveSourceId(updated) == 'market_history';
|
||||||
|
final Map<String, dynamic>? score = isMarketAnswer
|
||||||
|
? await questionsDb.getGuessScoreSummary(firebaseUid)
|
||||||
|
: null;
|
||||||
return _jsonResponse(200, <String, dynamic>{
|
return _jsonResponse(200, <String, dynamic>{
|
||||||
'question': updated,
|
'question': updated,
|
||||||
'unansweredCount': unansweredCount,
|
'unansweredCount': unansweredCount,
|
||||||
'score': score,
|
if (score != null) 'score': score,
|
||||||
if (nextQuestion != null) 'nextQuestion': nextQuestion,
|
if (nextQuestion != null) 'nextQuestion': nextQuestion,
|
||||||
});
|
});
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
@ -174,6 +165,33 @@ Handler questionsHandler({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'$questionsBasePath/<id>/spelling/prepare',
|
||||||
|
(Request request, String id) async {
|
||||||
|
final String? firebaseUid = await _verify(auth, request);
|
||||||
|
if (firebaseUid == null) {
|
||||||
|
return _jsonResponse(401, <String, dynamic>{'error': 'Unauthorized'});
|
||||||
|
}
|
||||||
|
if (questionPipeline == null) {
|
||||||
|
return _jsonResponse(503, <String, dynamic>{'error': 'Unavailable'});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final Map<String, dynamic>? question =
|
||||||
|
await questionPipeline.prepareSpellingQuestion(
|
||||||
|
firebaseUid: firebaseUid,
|
||||||
|
questionId: id,
|
||||||
|
);
|
||||||
|
if (question == null) {
|
||||||
|
return _jsonResponse(404, <String, dynamic>{'error': 'Not found'});
|
||||||
|
}
|
||||||
|
return _jsonResponse(200, <String, dynamic>{'question': question});
|
||||||
|
} catch (e, st) {
|
||||||
|
stderr.writeln('Prepare spelling question error: $e\n$st');
|
||||||
|
return _jsonResponse(500, <String, dynamic>{'error': 'Internal error'});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'$questionsBasePath/<id>/defer',
|
'$questionsBasePath/<id>/defer',
|
||||||
(Request request, String id) async {
|
(Request request, String id) async {
|
||||||
|
|||||||
137
server/lib/handlers/spelling_words_handler.dart
Normal file
137
server/lib/handlers/spelling_words_handler.dart
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:shelf/shelf.dart';
|
||||||
|
import 'package:shelf_router/shelf_router.dart';
|
||||||
|
|
||||||
|
import '../cors_headers.dart';
|
||||||
|
import '../firebase_auth.dart';
|
||||||
|
import '../spelling/spelling_items_db.dart';
|
||||||
|
|
||||||
|
const String spellingWordsBasePath = '/v1/me/spelling-words';
|
||||||
|
|
||||||
|
Handler spellingWordsHandler({
|
||||||
|
required FirebaseAuthVerifier auth,
|
||||||
|
required SpellingItemsDb spellingItemsDb,
|
||||||
|
}) {
|
||||||
|
final Router router = Router();
|
||||||
|
|
||||||
|
router.get(spellingWordsBasePath, (Request request) async {
|
||||||
|
final String? firebaseUid = await _verify(auth, request);
|
||||||
|
if (firebaseUid == null) {
|
||||||
|
return _jsonResponse(401, <String, dynamic>{'error': 'Unauthorized'});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final List<String> words = await spellingItemsDb.listCanonicalWords();
|
||||||
|
return _jsonResponse(200, <String, dynamic>{'words': words});
|
||||||
|
} catch (e, st) {
|
||||||
|
stderr.writeln('List spelling words error: $e\n$st');
|
||||||
|
return _jsonResponse(500, <String, dynamic>{'error': 'Internal error'});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('$spellingWordsBasePath/import', (Request request) async {
|
||||||
|
final String? firebaseUid = await _verify(auth, request);
|
||||||
|
if (firebaseUid == null) {
|
||||||
|
return _jsonResponse(401, <String, dynamic>{'error': 'Unauthorized'});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final String body = await request.readAsString();
|
||||||
|
final List<String> lines = body.split(RegExp(r'\r?\n'));
|
||||||
|
final ({int added, int skipped}) result =
|
||||||
|
await spellingItemsDb.importCanonicalWords(lines);
|
||||||
|
return _jsonResponse(200, <String, dynamic>{
|
||||||
|
'added': result.added,
|
||||||
|
'skipped': result.skipped,
|
||||||
|
});
|
||||||
|
} catch (e, st) {
|
||||||
|
stderr.writeln('Import spelling words error: $e\n$st');
|
||||||
|
return _jsonResponse(500, <String, dynamic>{'error': 'Internal error'});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('$spellingWordsBasePath/export', (Request request) async {
|
||||||
|
final String? firebaseUid = await _verify(auth, request);
|
||||||
|
if (firebaseUid == null) {
|
||||||
|
return Response(401, body: 'Unauthorized', headers: _textCorsHeaders());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final List<String> words = await spellingItemsDb.listCanonicalWords();
|
||||||
|
return Response.ok(
|
||||||
|
words.join('\n'),
|
||||||
|
headers: <String, String>{
|
||||||
|
..._textCorsHeaders(),
|
||||||
|
'Content-Type': 'text/plain; charset=utf-8',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e, st) {
|
||||||
|
stderr.writeln('Export spelling words error: $e\n$st');
|
||||||
|
return Response(500, body: 'Internal error', headers: _textCorsHeaders());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post(spellingWordsBasePath, (Request request) async {
|
||||||
|
final String? firebaseUid = await _verify(auth, request);
|
||||||
|
if (firebaseUid == null) {
|
||||||
|
return _jsonResponse(401, <String, dynamic>{'error': 'Unauthorized'});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final String body = await request.readAsString();
|
||||||
|
final Map<String, dynamic> json = body.isEmpty
|
||||||
|
? <String, dynamic>{}
|
||||||
|
: jsonDecode(body) as Map<String, dynamic>;
|
||||||
|
final String? word = json['word'] as String?;
|
||||||
|
if (word == null || word.trim().isEmpty) {
|
||||||
|
return _jsonResponse(400, <String, dynamic>{'error': 'word is required'});
|
||||||
|
}
|
||||||
|
final bool added = await spellingItemsDb.addCanonicalWord(word);
|
||||||
|
if (!added) {
|
||||||
|
return _jsonResponse(409, <String, dynamic>{'error': 'Word already exists'});
|
||||||
|
}
|
||||||
|
final List<String> words = await spellingItemsDb.listCanonicalWords();
|
||||||
|
return _jsonResponse(201, <String, dynamic>{
|
||||||
|
'word': word.trim().toLowerCase(),
|
||||||
|
'words': words,
|
||||||
|
});
|
||||||
|
} catch (e, st) {
|
||||||
|
stderr.writeln('Add spelling word error: $e\n$st');
|
||||||
|
return _jsonResponse(500, <String, dynamic>{'error': 'Internal error'});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (Request request) async {
|
||||||
|
if (request.method == 'OPTIONS' &&
|
||||||
|
request.requestedUri.path.startsWith(spellingWordsBasePath)) {
|
||||||
|
return Response.ok('', headers: _jsonCorsHeaders());
|
||||||
|
}
|
||||||
|
|
||||||
|
final Response response = await router.call(request);
|
||||||
|
if (response.statusCode != 404) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
return _jsonResponse(404, <String, dynamic>{'error': 'Not found'});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _verify(FirebaseAuthVerifier auth, Request request) {
|
||||||
|
return auth.verifyBearerToken(
|
||||||
|
request.headers['Authorization'] ?? request.headers['authorization'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> _jsonCorsHeaders() =>
|
||||||
|
apiCorsHeaders(methods: 'GET, POST, OPTIONS');
|
||||||
|
|
||||||
|
Map<String, String> _textCorsHeaders() =>
|
||||||
|
apiCorsHeaders(methods: 'GET, OPTIONS');
|
||||||
|
|
||||||
|
Response _jsonResponse(int status, Map<String, dynamic> body) {
|
||||||
|
return Response(
|
||||||
|
status,
|
||||||
|
body: jsonEncode(body),
|
||||||
|
headers: <String, String>{
|
||||||
|
..._jsonCorsHeaders(),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,8 +2,17 @@ import 'dart:async';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
|
import '../question_dispatcher.dart';
|
||||||
import '../question_service.dart';
|
import '../question_service.dart';
|
||||||
|
import '../question_source_preferences.dart';
|
||||||
|
import '../question_source_preferences_db.dart';
|
||||||
import '../questions_db.dart';
|
import '../questions_db.dart';
|
||||||
|
import '../spelling/spelling_answer_policy.dart';
|
||||||
|
import '../spelling/spelling_item_record.dart';
|
||||||
|
import '../spelling/spelling_item_selector.dart';
|
||||||
|
import '../spelling/spelling_items_db.dart';
|
||||||
|
import '../spelling/spelling_question_links_db.dart';
|
||||||
|
import '../spelling/user_spelling_progress_db.dart';
|
||||||
import '../trading/trading_pipeline.dart';
|
import '../trading/trading_pipeline.dart';
|
||||||
import 'branch_decision.dart';
|
import 'branch_decision.dart';
|
||||||
import 'external_data_fetcher.dart';
|
import 'external_data_fetcher.dart';
|
||||||
@ -22,6 +31,7 @@ abstract final class PipelineKeys {
|
|||||||
static const String geography = 'geography';
|
static const String geography = 'geography';
|
||||||
static const String weather = 'weather';
|
static const String weather = 'weather';
|
||||||
static const String trading = 'trading';
|
static const String trading = 'trading';
|
||||||
|
static const String spelling = 'spelling';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Steps within each pipeline branch.
|
/// Steps within each pipeline branch.
|
||||||
@ -32,6 +42,8 @@ abstract final class PipelineSteps {
|
|||||||
static const String capitalRecovery = 'capital_recovery';
|
static const String capitalRecovery = 'capital_recovery';
|
||||||
static const String temperatureQuiz = 'temperature_quiz';
|
static const String temperatureQuiz = 'temperature_quiz';
|
||||||
static const String temperatureFollowUp = 'temperature_follow_up';
|
static const String temperatureFollowUp = 'temperature_follow_up';
|
||||||
|
static const String spellingWordQuiz = 'spelling_word_quiz';
|
||||||
|
static const String spellingFollowUp = 'spelling_follow_up';
|
||||||
static const String idle = 'idle';
|
static const String idle = 'idle';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,20 +72,49 @@ class QuestionPipeline {
|
|||||||
required QuestionService questionService,
|
required QuestionService questionService,
|
||||||
ExternalDataFetcher? fetcher,
|
ExternalDataFetcher? fetcher,
|
||||||
TradingPipeline? tradingPipeline,
|
TradingPipeline? tradingPipeline,
|
||||||
|
QuestionSourcePreferencesDb? questionSourcePreferencesDb,
|
||||||
|
SpellingItemsDb? spellingItemsDb,
|
||||||
|
SpellingItemSelector? spellingItemSelector,
|
||||||
|
UserSpellingProgressDb? spellingProgressDb,
|
||||||
|
SpellingQuestionLinksDb? spellingQuestionLinksDb,
|
||||||
this.maxQueuedQuestions = 3,
|
this.maxQueuedQuestions = 3,
|
||||||
this.testMode = false,
|
this.testMode = false,
|
||||||
}) : _questionsDb = questionsDb,
|
}) : _questionsDb = questionsDb,
|
||||||
_questionService = questionService,
|
_questionService = questionService,
|
||||||
_fetcher = fetcher ?? ExternalDataFetcher(),
|
_fetcher = fetcher ?? ExternalDataFetcher(),
|
||||||
_tradingPipeline = tradingPipeline;
|
_tradingPipeline = tradingPipeline,
|
||||||
|
_questionSourcePreferencesDb = questionSourcePreferencesDb,
|
||||||
|
_spellingItemsDb =
|
||||||
|
spellingItemsDb ?? SpellingItemsDb(questionsDb.connection),
|
||||||
|
_spellingProgressDb =
|
||||||
|
spellingProgressDb ?? UserSpellingProgressDb(questionsDb.connection),
|
||||||
|
_spellingQuestionLinksDb = spellingQuestionLinksDb ??
|
||||||
|
SpellingQuestionLinksDb(questionsDb.connection),
|
||||||
|
_spellingSelector = spellingItemSelector ??
|
||||||
|
SpellingItemSelector(
|
||||||
|
itemsDb:
|
||||||
|
spellingItemsDb ?? SpellingItemsDb(questionsDb.connection),
|
||||||
|
);
|
||||||
|
|
||||||
final QuestionsDb _questionsDb;
|
final QuestionsDb _questionsDb;
|
||||||
final QuestionService _questionService;
|
final QuestionService _questionService;
|
||||||
final ExternalDataFetcher _fetcher;
|
final ExternalDataFetcher _fetcher;
|
||||||
final TradingPipeline? _tradingPipeline;
|
final TradingPipeline? _tradingPipeline;
|
||||||
|
final QuestionSourcePreferencesDb? _questionSourcePreferencesDb;
|
||||||
|
final SpellingItemsDb _spellingItemsDb;
|
||||||
|
final SpellingItemSelector _spellingSelector;
|
||||||
|
final UserSpellingProgressDb _spellingProgressDb;
|
||||||
|
final SpellingQuestionLinksDb _spellingQuestionLinksDb;
|
||||||
final int maxQueuedQuestions;
|
final int maxQueuedQuestions;
|
||||||
final bool testMode;
|
final bool testMode;
|
||||||
|
|
||||||
|
QuestionDispatcher? _questionDispatcher;
|
||||||
|
|
||||||
|
/// Wired after construction in [bin/server.dart] to avoid a circular dependency.
|
||||||
|
void attachQuestionDispatcher(QuestionDispatcher dispatcher) {
|
||||||
|
_questionDispatcher = dispatcher;
|
||||||
|
}
|
||||||
|
|
||||||
/// Periodic tick: start pipelines for idle users and top up shallow queues.
|
/// Periodic tick: start pipelines for idle users and top up shallow queues.
|
||||||
Future<void> runMaintenanceCycle() async {
|
Future<void> runMaintenanceCycle() async {
|
||||||
final List<String> userIds = await _questionsDb.listAllUserFirebaseUids();
|
final List<String> userIds = await _questionsDb.listAllUserFirebaseUids();
|
||||||
@ -87,7 +128,12 @@ class QuestionPipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _maintainUser(String firebaseUid) async {
|
Future<void> _maintainUser(String firebaseUid) async {
|
||||||
final int queued = await _questionsDb.countUnansweredQuestions(firebaseUid);
|
final QuestionDispatcher? dispatcher = _questionDispatcher;
|
||||||
|
if (dispatcher != null) {
|
||||||
|
await dispatcher.topUpQueue(firebaseUid);
|
||||||
|
}
|
||||||
|
|
||||||
|
var queued = await _questionsDb.countUnansweredQuestions(firebaseUid);
|
||||||
if (queued >= maxQueuedQuestions) {
|
if (queued >= maxQueuedQuestions) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -95,9 +141,6 @@ class QuestionPipeline {
|
|||||||
final Map<String, dynamic>? state =
|
final Map<String, dynamic>? state =
|
||||||
await _questionsDb.getPipelineState(firebaseUid);
|
await _questionsDb.getPipelineState(firebaseUid);
|
||||||
if (state == null) {
|
if (state == null) {
|
||||||
if (queued == 0) {
|
|
||||||
await _startRootPipeline(firebaseUid);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -114,6 +157,7 @@ class QuestionPipeline {
|
|||||||
required String sourceTag,
|
required String sourceTag,
|
||||||
required String pipelineKey,
|
required String pipelineKey,
|
||||||
required String pipelineStep,
|
required String pipelineStep,
|
||||||
|
Map<String, dynamic>? metadata,
|
||||||
}) async {
|
}) async {
|
||||||
await _questionService.createAndDeliverQuestion(
|
await _questionService.createAndDeliverQuestion(
|
||||||
assignedUserId: firebaseUid,
|
assignedUserId: firebaseUid,
|
||||||
@ -124,6 +168,7 @@ class QuestionPipeline {
|
|||||||
sourceTag: sourceTag,
|
sourceTag: sourceTag,
|
||||||
pipelineKey: pipelineKey,
|
pipelineKey: pipelineKey,
|
||||||
pipelineStep: pipelineStep,
|
pipelineStep: pipelineStep,
|
||||||
|
metadata: metadata,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,10 +186,28 @@ class QuestionPipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _canEnqueue(String firebaseUid) async {
|
Future<bool> _canEnqueue(String firebaseUid) async {
|
||||||
final int queued = await _questionsDb.countUnansweredQuestions(firebaseUid);
|
final int queued = await _enabledUnansweredCount(firebaseUid);
|
||||||
return queued < maxQueuedQuestions;
|
return queued < maxQueuedQuestions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<int> _enabledUnansweredCount(String firebaseUid) async {
|
||||||
|
final QuestionSourcePreferencesDb? preferencesDb =
|
||||||
|
_questionSourcePreferencesDb;
|
||||||
|
if (preferencesDb == null) {
|
||||||
|
return _questionsDb.countUnansweredQuestions(firebaseUid);
|
||||||
|
}
|
||||||
|
final List<ResolvedQuestionSourcePreference> prefs =
|
||||||
|
await preferencesDb.getResolved(firebaseUid);
|
||||||
|
final Set<String> allowed = prefs
|
||||||
|
.where(
|
||||||
|
(ResolvedQuestionSourcePreference s) =>
|
||||||
|
s.enabled && !s.comingSoon,
|
||||||
|
)
|
||||||
|
.map((ResolvedQuestionSourcePreference s) => s.id)
|
||||||
|
.toSet();
|
||||||
|
return _questionsDb.countUnansweredForAllowedSources(firebaseUid, allowed);
|
||||||
|
}
|
||||||
|
|
||||||
/// Called after a question is answered; may enqueue the next branched question.
|
/// Called after a question is answered; may enqueue the next branched question.
|
||||||
Future<void> onAnswerSubmitted({
|
Future<void> onAnswerSubmitted({
|
||||||
required String firebaseUid,
|
required String firebaseUid,
|
||||||
@ -199,9 +262,217 @@ class QuestionPipeline {
|
|||||||
);
|
);
|
||||||
case PipelineKeys.trading:
|
case PipelineKeys.trading:
|
||||||
break;
|
break;
|
||||||
|
case PipelineKeys.spelling:
|
||||||
|
await _handleSpellingAnswer(
|
||||||
|
firebaseUid: firebaseUid,
|
||||||
|
step: pipelineStep,
|
||||||
|
userResponse: userResponse,
|
||||||
|
correctAnswer: correctAnswer,
|
||||||
|
context: context,
|
||||||
|
answeredQuestion: answeredQuestion,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Selects the next word, links progress, and returns the prepared client payload.
|
||||||
|
///
|
||||||
|
/// Called when the spelling tile loads (word selection runs here, not at enqueue).
|
||||||
|
Future<Map<String, dynamic>?> prepareSpellingQuestion({
|
||||||
|
required String firebaseUid,
|
||||||
|
required String questionId,
|
||||||
|
}) async {
|
||||||
|
if (!await _isSpellingEnabled(firebaseUid)) {
|
||||||
|
stderr.writeln(
|
||||||
|
'prepareSpelling: spelling source disabled for $firebaseUid',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic>? question = await _questionsDb.findQuestionById(
|
||||||
|
questionId: questionId,
|
||||||
|
assignedUserId: firebaseUid,
|
||||||
|
);
|
||||||
|
if (question == null) {
|
||||||
|
stderr.writeln(
|
||||||
|
'prepareSpelling: question not found id=$questionId uid=$firebaseUid',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (QuestionsDb.resolveSourceId(question) != 'spelling') {
|
||||||
|
stderr.writeln(
|
||||||
|
'prepareSpelling: not a spelling question id=$questionId',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (question['userResponse'] != null) {
|
||||||
|
stderr.writeln(
|
||||||
|
'prepareSpelling: already answered id=$questionId',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final String? step = question['pipelineStep'] as String?;
|
||||||
|
final String? sourceTag = question['sourceTag'] as String?;
|
||||||
|
if (step == PipelineSteps.spellingFollowUp) {
|
||||||
|
stderr.writeln(
|
||||||
|
'prepareSpelling: follow-up question is not preparable id=$questionId',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final bool preparable = step == PipelineSteps.spellingWordQuiz ||
|
||||||
|
sourceTag == 'spelling:word_quiz';
|
||||||
|
if (!preparable) {
|
||||||
|
stderr.writeln(
|
||||||
|
'prepareSpelling: unsupported step=$step sourceTag=$sourceTag id=$questionId',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, dynamic> metadata = Map<String, dynamic>.from(
|
||||||
|
question['metadata'] as Map<String, dynamic>? ?? <String, dynamic>{},
|
||||||
|
);
|
||||||
|
|
||||||
|
final String? existingWord = metadata['displayed_word'] as String?;
|
||||||
|
if (existingWord != null && existingWord.isNotEmpty) {
|
||||||
|
return _finalizePreparedSpellingQuestion(
|
||||||
|
firebaseUid: firebaseUid,
|
||||||
|
questionId: questionId,
|
||||||
|
question: question,
|
||||||
|
metadata: metadata,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final bool needsPick =
|
||||||
|
metadata['spelling_pending'] == true ||
|
||||||
|
metadata['spelling_item_id'] == null;
|
||||||
|
|
||||||
|
SpellingItemRecord? item;
|
||||||
|
if (needsPick) {
|
||||||
|
await _spellingItemsDb.ensureCatalogSeeded();
|
||||||
|
item = await _spellingSelector.pickForUser(firebaseUid);
|
||||||
|
if (item == null) {
|
||||||
|
stderr.writeln(
|
||||||
|
'prepareSpelling: no spelling items available for $firebaseUid',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, dynamic> itemMetadata = item.toQuestionMetadata();
|
||||||
|
final String questionText = testMode
|
||||||
|
? (question['text'] as String? ?? PipelineTestQuestions.text)
|
||||||
|
: 'Spell the word you hear.';
|
||||||
|
|
||||||
|
question = await _questionsDb.updateSpellingQuizQuestion(
|
||||||
|
questionId: questionId,
|
||||||
|
assignedUserId: firebaseUid,
|
||||||
|
questionText: questionText,
|
||||||
|
correctAnswer: item.correctAnswer,
|
||||||
|
metadata: itemMetadata,
|
||||||
|
);
|
||||||
|
if (question == null) {
|
||||||
|
stderr.writeln(
|
||||||
|
'prepareSpelling: update failed id=$questionId step=$step tag=$sourceTag',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _questionsDb.upsertPipelineState(
|
||||||
|
assignedUserId: firebaseUid,
|
||||||
|
pipelineKey: PipelineKeys.spelling,
|
||||||
|
step: PipelineSteps.spellingWordQuiz,
|
||||||
|
context: item.toPipelineContext(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
item = _itemFromMetadata(metadata, question);
|
||||||
|
if (item == null) {
|
||||||
|
stderr.writeln(
|
||||||
|
'prepareSpelling: incomplete metadata id=$questionId',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _finalizePreparedSpellingQuestion(
|
||||||
|
firebaseUid: firebaseUid,
|
||||||
|
questionId: questionId,
|
||||||
|
question: question,
|
||||||
|
metadata: metadata,
|
||||||
|
item: item,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
SpellingItemRecord? _itemFromMetadata(
|
||||||
|
Map<String, dynamic> metadata,
|
||||||
|
Map<String, dynamic> question,
|
||||||
|
) {
|
||||||
|
final String? itemId = metadata['spelling_item_id'] as String?;
|
||||||
|
final String? wordId = metadata['spelling_word_id'] as String?;
|
||||||
|
final String? canonical = metadata['canonical_word'] as String?;
|
||||||
|
final String? displayed = metadata['displayed_word'] as String?;
|
||||||
|
final Object? isCorrect = metadata['is_correctly_spelled'];
|
||||||
|
if (itemId == null ||
|
||||||
|
itemId.isEmpty ||
|
||||||
|
wordId == null ||
|
||||||
|
canonical == null ||
|
||||||
|
displayed == null ||
|
||||||
|
isCorrect is! bool) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return SpellingItemRecord(
|
||||||
|
id: itemId,
|
||||||
|
spellingWordId: wordId,
|
||||||
|
canonicalWord: canonical,
|
||||||
|
displayedWord: displayed,
|
||||||
|
isCorrectlySpelled: isCorrect,
|
||||||
|
correctAnswer: question['correctAnswer'] as num,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>?> _finalizePreparedSpellingQuestion({
|
||||||
|
required String firebaseUid,
|
||||||
|
required String questionId,
|
||||||
|
required Map<String, dynamic> question,
|
||||||
|
required Map<String, dynamic> metadata,
|
||||||
|
SpellingItemRecord? item,
|
||||||
|
}) async {
|
||||||
|
final SpellingItemRecord? resolved =
|
||||||
|
item ?? _itemFromMetadata(metadata, question);
|
||||||
|
if (resolved == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _spellingProgressDb.ensureLink(
|
||||||
|
firebaseUid: firebaseUid,
|
||||||
|
spellingItemId: resolved.id,
|
||||||
|
);
|
||||||
|
await _spellingQuestionLinksDb.linkQuestion(
|
||||||
|
questionId: questionId,
|
||||||
|
firebaseUid: firebaseUid,
|
||||||
|
spellingItemId: resolved.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
final int unansweredCount =
|
||||||
|
await _questionsDb.countUnansweredQuestions(firebaseUid);
|
||||||
|
return _questionsDb.toClientPayload(
|
||||||
|
question,
|
||||||
|
unansweredCount: unansweredCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensures an unanswered spelling word quiz exists when the source is enabled.
|
||||||
|
Future<void> maintainSpellingForUser(String firebaseUid) async {
|
||||||
|
if (!await _isSpellingEnabled(firebaseUid)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (await _questionsDb.hasUnansweredSpellingWordQuiz(firebaseUid)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!await _canEnqueue(firebaseUid)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _startSpellingPipeline(firebaseUid);
|
||||||
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>> _loadContext(
|
Future<Map<String, dynamic>> _loadContext(
|
||||||
String firebaseUid,
|
String firebaseUid,
|
||||||
String pipelineKey,
|
String pipelineKey,
|
||||||
@ -452,6 +723,101 @@ class QuestionPipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reserves a spelling quiz slot; word selection runs at tile load via [prepareSpellingQuestion].
|
||||||
|
Future<void> _startSpellingPipeline(String firebaseUid) async {
|
||||||
|
if (!await _canEnqueue(firebaseUid)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!await _isSpellingEnabled(firebaseUid)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _questionsDb.upsertPipelineState(
|
||||||
|
assignedUserId: firebaseUid,
|
||||||
|
pipelineKey: PipelineKeys.spelling,
|
||||||
|
step: PipelineSteps.spellingWordQuiz,
|
||||||
|
context: <String, dynamic>{},
|
||||||
|
);
|
||||||
|
await _deliverPipelineQuestion(
|
||||||
|
firebaseUid: firebaseUid,
|
||||||
|
questionText: testMode
|
||||||
|
? PipelineTestQuestions.text
|
||||||
|
: 'Spelling question loading…',
|
||||||
|
correctAnswer: 0,
|
||||||
|
sourceTag: 'spelling:word_quiz',
|
||||||
|
pipelineKey: PipelineKeys.spelling,
|
||||||
|
pipelineStep: PipelineSteps.spellingWordQuiz,
|
||||||
|
metadata: <String, dynamic>{'spelling_pending': true},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleSpellingAnswer({
|
||||||
|
required String firebaseUid,
|
||||||
|
required String step,
|
||||||
|
required num userResponse,
|
||||||
|
required num correctAnswer,
|
||||||
|
required Map<String, dynamic> context,
|
||||||
|
required Map<String, dynamic> answeredQuestion,
|
||||||
|
}) async {
|
||||||
|
final String canonicalWord =
|
||||||
|
context['canonicalWord'] as String? ?? 'the word';
|
||||||
|
|
||||||
|
switch (step) {
|
||||||
|
case PipelineSteps.spellingWordQuiz:
|
||||||
|
final String typed =
|
||||||
|
(answeredQuestion['userResponseText'] as String?)?.trim() ?? '';
|
||||||
|
final BranchOutcome outcome =
|
||||||
|
SpellingAnswerPolicy.matches(typed, canonicalWord)
|
||||||
|
? BranchOutcome.match
|
||||||
|
: BranchOutcome.miss;
|
||||||
|
final String? spellingItemId = context['spellingItemId'] as String?;
|
||||||
|
if (spellingItemId != null) {
|
||||||
|
await _spellingProgressDb.recordQuizOutcome(
|
||||||
|
firebaseUid: firebaseUid,
|
||||||
|
spellingItemId: spellingItemId,
|
||||||
|
correct: outcome == BranchOutcome.match,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final String followUp = switch (outcome) {
|
||||||
|
BranchOutcome.match =>
|
||||||
|
"Correct — '$canonicalWord' is the proper spelling. "
|
||||||
|
'Enter +1 for another word, -1 to pause.',
|
||||||
|
_ =>
|
||||||
|
"The proper spelling is '$canonicalWord'. "
|
||||||
|
'Enter +1 to try another word, -1 to pause.',
|
||||||
|
};
|
||||||
|
await _questionsDb.upsertPipelineState(
|
||||||
|
assignedUserId: firebaseUid,
|
||||||
|
pipelineKey: PipelineKeys.spelling,
|
||||||
|
step: PipelineSteps.spellingFollowUp,
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
await _deliverPipelineQuestion(
|
||||||
|
firebaseUid: firebaseUid,
|
||||||
|
questionText: followUp,
|
||||||
|
correctAnswer: 1,
|
||||||
|
sourceTag: 'spelling:follow_up',
|
||||||
|
pipelineKey: PipelineKeys.spelling,
|
||||||
|
pipelineStep: PipelineSteps.spellingFollowUp,
|
||||||
|
);
|
||||||
|
case PipelineSteps.spellingFollowUp:
|
||||||
|
if (userResponse > 0) {
|
||||||
|
await _startSpellingPipeline(firebaseUid);
|
||||||
|
} else {
|
||||||
|
await _setIdle(firebaseUid, PipelineKeys.spelling);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _isSpellingEnabled(String firebaseUid) async {
|
||||||
|
final QuestionSourcePreferencesDb? preferencesDb =
|
||||||
|
_questionSourcePreferencesDb;
|
||||||
|
if (preferencesDb == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return preferencesDb.isSourceEnabled(firebaseUid, 'spelling');
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _advanceFromIdle(
|
Future<void> _advanceFromIdle(
|
||||||
String firebaseUid,
|
String firebaseUid,
|
||||||
Map<String, dynamic> state,
|
Map<String, dynamic> state,
|
||||||
@ -462,8 +828,10 @@ class QuestionPipeline {
|
|||||||
await _startGeographyPipeline(firebaseUid);
|
await _startGeographyPipeline(firebaseUid);
|
||||||
case PipelineKeys.weather:
|
case PipelineKeys.weather:
|
||||||
await _startWeatherPipeline(firebaseUid);
|
await _startWeatherPipeline(firebaseUid);
|
||||||
|
case PipelineKeys.spelling:
|
||||||
|
await _startSpellingPipeline(firebaseUid);
|
||||||
default:
|
default:
|
||||||
await _startRootPipeline(firebaseUid);
|
await _questionDispatcher?.topUpQueue(firebaseUid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
68
server/lib/pipeline/spelling_word_list.dart
Normal file
68
server/lib/pipeline/spelling_word_list.dart
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
/// One spelling challenge: a displayed word and whether it is spelled correctly.
|
||||||
|
class SpellingChallenge {
|
||||||
|
const SpellingChallenge({
|
||||||
|
required this.displayedWord,
|
||||||
|
required this.isCorrectlySpelled,
|
||||||
|
required this.canonicalWord,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String displayedWord;
|
||||||
|
final bool isCorrectlySpelled;
|
||||||
|
final String canonicalWord;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Curated commonly misspelled words for spelling quizzes.
|
||||||
|
abstract final class SpellingWordList {
|
||||||
|
static const List<({String correct, String misspelled})> pairs =
|
||||||
|
<({String correct, String misspelled})>[
|
||||||
|
(correct: 'accommodate', misspelled: 'acommodate'),
|
||||||
|
(correct: 'separate', misspelled: 'seperate'),
|
||||||
|
(correct: 'definitely', misspelled: 'definately'),
|
||||||
|
(correct: 'occurrence', misspelled: 'occurence'),
|
||||||
|
(correct: 'embarrass', misspelled: 'embarass'),
|
||||||
|
(correct: 'maintenance', misspelled: 'maintainance'),
|
||||||
|
(correct: 'recommend', misspelled: 'reccomend'),
|
||||||
|
(correct: 'privilege', misspelled: 'priviledge'),
|
||||||
|
(correct: 'necessary', misspelled: 'neccessary'),
|
||||||
|
(correct: 'rhythm', misspelled: 'rythm'),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Known misspelling from the curated list, if any.
|
||||||
|
static String? misspellingFor(String canonicalWord) {
|
||||||
|
final String normalized = canonicalWord.trim().toLowerCase();
|
||||||
|
for (final ({String correct, String misspelled}) pair in pairs) {
|
||||||
|
if (pair.correct == normalized) {
|
||||||
|
return pair.misspelled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fallback typo when [misspellingFor] has no curated entry.
|
||||||
|
static String generatedMisspelling(String canonicalWord) {
|
||||||
|
return misspellingFor(canonicalWord) ?? _naiveMisspelling(canonicalWord);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _naiveMisspelling(String word) {
|
||||||
|
final String lower = word.trim().toLowerCase();
|
||||||
|
if (lower.length <= 2) {
|
||||||
|
return '${lower}x';
|
||||||
|
}
|
||||||
|
final int dropAt = lower.length ~/ 2;
|
||||||
|
return lower.substring(0, dropAt) + lower.substring(dropAt + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
static SpellingChallenge randomChallenge([Random? random]) {
|
||||||
|
final Random rng = random ?? Random();
|
||||||
|
final ({String correct, String misspelled}) pair =
|
||||||
|
pairs[rng.nextInt(pairs.length)];
|
||||||
|
final bool showCorrect = rng.nextBool();
|
||||||
|
return SpellingChallenge(
|
||||||
|
displayedWord: showCorrect ? pair.correct : pair.misspelled,
|
||||||
|
isCorrectlySpelled: showCorrect,
|
||||||
|
canonicalWord: pair.correct,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
288
server/lib/question_dispatcher.dart
Normal file
288
server/lib/question_dispatcher.dart
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
import 'pipeline/question_pipeline.dart';
|
||||||
|
import 'question_service.dart';
|
||||||
|
import 'question_source_mix.dart';
|
||||||
|
import 'question_source_preferences.dart';
|
||||||
|
import 'question_source_preferences_db.dart';
|
||||||
|
import 'questions_db.dart';
|
||||||
|
|
||||||
|
/// Ratio-aware entry point for topping up a user's unanswered question queue.
|
||||||
|
class QuestionDispatcher {
|
||||||
|
QuestionDispatcher({
|
||||||
|
required QuestionsDb questionsDb,
|
||||||
|
required QuestionService questionService,
|
||||||
|
required QuestionPipeline questionPipeline,
|
||||||
|
required QuestionSourcePreferencesDb preferencesDb,
|
||||||
|
this.maxQueuedQuestions = 3,
|
||||||
|
}) : _questionsDb = questionsDb,
|
||||||
|
_questionService = questionService,
|
||||||
|
_questionPipeline = questionPipeline,
|
||||||
|
_preferencesDb = preferencesDb;
|
||||||
|
|
||||||
|
final QuestionsDb _questionsDb;
|
||||||
|
final QuestionService _questionService;
|
||||||
|
final QuestionPipeline _questionPipeline;
|
||||||
|
final QuestionSourcePreferencesDb _preferencesDb;
|
||||||
|
final int maxQueuedQuestions;
|
||||||
|
|
||||||
|
/// Ensures at least one enabled-source question exists; returns mix-ordered head.
|
||||||
|
Future<Map<String, dynamic>?> dispatchNextQuestion(String firebaseUid) async {
|
||||||
|
await _questionsDb.ensureUserExists(firebaseUid);
|
||||||
|
|
||||||
|
var rows = await _listEnabledUnansweredOrdered(firebaseUid);
|
||||||
|
if (rows.isEmpty) {
|
||||||
|
if (!await _enqueueOneFromMix(firebaseUid)) {
|
||||||
|
await _ensureSpellingQuestionWhenNeeded(firebaseUid);
|
||||||
|
}
|
||||||
|
rows = await _listEnabledUnansweredOrdered(firebaseUid);
|
||||||
|
}
|
||||||
|
if (rows.isEmpty) {
|
||||||
|
await _ensureSpellingQuestionWhenNeeded(firebaseUid);
|
||||||
|
rows = await _listEnabledUnansweredOrdered(firebaseUid);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return _toClientPayload(rows.first, rows.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensures an enabled-source question exists before the question panel opens.
|
||||||
|
Future<void> preparePanelQueue(String firebaseUid) async {
|
||||||
|
await _questionsDb.ensureUserExists(firebaseUid);
|
||||||
|
var rows = await _listEnabledUnansweredOrdered(firebaseUid);
|
||||||
|
if (rows.isEmpty) {
|
||||||
|
await _enqueueOneFromMix(firebaseUid);
|
||||||
|
rows = await _listEnabledUnansweredOrdered(firebaseUid);
|
||||||
|
}
|
||||||
|
if (rows.isEmpty) {
|
||||||
|
await _ensureSpellingQuestionWhenNeeded(firebaseUid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unanswered questions for enabled sources only, ordered by configured mix.
|
||||||
|
Future<List<Map<String, dynamic>>> listPanelQuestions(
|
||||||
|
String firebaseUid,
|
||||||
|
) async {
|
||||||
|
await preparePanelQueue(firebaseUid);
|
||||||
|
var rows = await _listEnabledUnansweredOrdered(firebaseUid);
|
||||||
|
if (rows.isEmpty) {
|
||||||
|
await _enqueueOneFromMix(firebaseUid);
|
||||||
|
rows = await _listEnabledUnansweredOrdered(firebaseUid);
|
||||||
|
}
|
||||||
|
if (rows.isEmpty) {
|
||||||
|
await _ensureSpellingQuestionWhenNeeded(firebaseUid);
|
||||||
|
rows = await _listEnabledUnansweredOrdered(firebaseUid);
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
.map(
|
||||||
|
(Map<String, dynamic> row) =>
|
||||||
|
_toClientPayload(row, rows.length),
|
||||||
|
)
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Next unanswered enabled-source question after an answer.
|
||||||
|
Future<Map<String, dynamic>?> nextQuestionAfterAnswer({
|
||||||
|
required String firebaseUid,
|
||||||
|
required Map<String, dynamic> answeredQuestion,
|
||||||
|
}) async {
|
||||||
|
var rows = await _listEnabledUnansweredOrdered(firebaseUid);
|
||||||
|
if (rows.isEmpty) {
|
||||||
|
return dispatchNextQuestion(firebaseUid);
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<ResolvedQuestionSourcePreference> prefs =
|
||||||
|
await _preferencesDb.getResolved(firebaseUid);
|
||||||
|
final Map<String, int> mix = QuestionSourceMix.effectiveMixFromResolved(prefs);
|
||||||
|
final Map<String, int> delivered =
|
||||||
|
await _questionsDb.countDeliveredBySourceId(firebaseUid);
|
||||||
|
final Set<String> allowed = _enabledSourceIds(prefs);
|
||||||
|
final Map<String, dynamic>? row = await _questionsDb.findHeadUnansweredForMix(
|
||||||
|
firebaseUid,
|
||||||
|
effectiveMixPercent: mix,
|
||||||
|
deliveredCounts: delivered,
|
||||||
|
afterAnsweringSourceId:
|
||||||
|
QuestionsDb.resolveSourceId(answeredQuestion),
|
||||||
|
allowedSourceIds: allowed,
|
||||||
|
);
|
||||||
|
if (row == null) {
|
||||||
|
return dispatchNextQuestion(firebaseUid);
|
||||||
|
}
|
||||||
|
return _toClientPayload(row, rows.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fills the queue up to [maxQueuedQuestions] using mix-sequential source picks.
|
||||||
|
Future<void> topUpQueue(String firebaseUid) async {
|
||||||
|
await _questionsDb.ensureUserExists(firebaseUid);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
final int enabledQueued =
|
||||||
|
(await _listEnabledUnansweredOrdered(firebaseUid)).length;
|
||||||
|
if (enabledQueued >= maxQueuedQuestions) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!await _enqueueOneFromMix(firebaseUid)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _enqueueOneFromMix(String firebaseUid) async {
|
||||||
|
final List<ResolvedQuestionSourcePreference> prefs =
|
||||||
|
await _preferencesDb.getResolved(firebaseUid);
|
||||||
|
final Map<String, int> mix = QuestionSourceMix.effectiveMixFromResolved(prefs);
|
||||||
|
if (mix.isEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, int> delivered =
|
||||||
|
await _questionsDb.countDeliveredBySourceId(firebaseUid);
|
||||||
|
final String primary = QuestionSourceMix.pickNextSourceId(
|
||||||
|
effectiveMixPercent: mix,
|
||||||
|
deliveredCounts: delivered,
|
||||||
|
);
|
||||||
|
final List<String> order = _sourceTryOrderForEnqueue(
|
||||||
|
prefs: prefs,
|
||||||
|
primarySourceId: primary,
|
||||||
|
);
|
||||||
|
|
||||||
|
final int? marketBatchCap = _marketBatchCapForMix(prefs);
|
||||||
|
|
||||||
|
for (final String sourceId in order) {
|
||||||
|
final bool added = await _produceFromSource(
|
||||||
|
firebaseUid,
|
||||||
|
sourceId,
|
||||||
|
marketBatchCap: marketBatchCap,
|
||||||
|
);
|
||||||
|
if (added) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> _listEnabledUnansweredOrdered(
|
||||||
|
String firebaseUid,
|
||||||
|
) async {
|
||||||
|
final List<ResolvedQuestionSourcePreference> prefs =
|
||||||
|
await _preferencesDb.getResolved(firebaseUid);
|
||||||
|
final Map<String, int> mix = QuestionSourceMix.effectiveMixFromResolved(prefs);
|
||||||
|
final Set<String> allowed = _enabledSourceIds(prefs);
|
||||||
|
if (allowed.isEmpty) {
|
||||||
|
return <Map<String, dynamic>>[];
|
||||||
|
}
|
||||||
|
return _questionsDb.listUnansweredOrderedForMix(
|
||||||
|
firebaseUid,
|
||||||
|
effectiveMixPercent: mix,
|
||||||
|
allowedSourceIds: allowed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> _enabledSourceIds(
|
||||||
|
List<ResolvedQuestionSourcePreference> prefs,
|
||||||
|
) {
|
||||||
|
return prefs
|
||||||
|
.where(
|
||||||
|
(ResolvedQuestionSourcePreference s) =>
|
||||||
|
s.enabled && !s.comingSoon,
|
||||||
|
)
|
||||||
|
.map((ResolvedQuestionSourcePreference s) => s.id)
|
||||||
|
.toSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> _sourceTryOrderForEnqueue({
|
||||||
|
required List<ResolvedQuestionSourcePreference> prefs,
|
||||||
|
required String primarySourceId,
|
||||||
|
}) {
|
||||||
|
final List<ResolvedQuestionSourcePreference> enabled = prefs
|
||||||
|
.where(
|
||||||
|
(ResolvedQuestionSourcePreference s) =>
|
||||||
|
s.enabled && !s.comingSoon,
|
||||||
|
)
|
||||||
|
.toList(growable: false);
|
||||||
|
final List<String> rest = enabled
|
||||||
|
.map((ResolvedQuestionSourcePreference s) => s.id)
|
||||||
|
.where((String id) => id != primarySourceId)
|
||||||
|
.toList()
|
||||||
|
..sort(
|
||||||
|
(
|
||||||
|
String a,
|
||||||
|
String b,
|
||||||
|
) {
|
||||||
|
final int ratioA = enabled
|
||||||
|
.firstWhere((ResolvedQuestionSourcePreference s) => s.id == a)
|
||||||
|
.ratio;
|
||||||
|
final int ratioB = enabled
|
||||||
|
.firstWhere((ResolvedQuestionSourcePreference s) => s.id == b)
|
||||||
|
.ratio;
|
||||||
|
return ratioB.compareTo(ratioA);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return <String>[primarySourceId, ...rest];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Last-resort spelling enqueue when mix dispatch did not produce a row.
|
||||||
|
Future<void> _ensureSpellingQuestionWhenNeeded(String firebaseUid) async {
|
||||||
|
final List<ResolvedQuestionSourcePreference> prefs =
|
||||||
|
await _preferencesDb.getResolved(firebaseUid);
|
||||||
|
if (!QuestionSourcePreferences.isEnabled(prefs, 'spelling')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _questionPipeline.maintainSpellingForUser(firebaseUid);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _produceFromSource(
|
||||||
|
String firebaseUid,
|
||||||
|
String sourceId, {
|
||||||
|
int? marketBatchCap,
|
||||||
|
}) async {
|
||||||
|
if (!await _isSourceEnabled(firebaseUid, sourceId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final int before =
|
||||||
|
(await _listEnabledUnansweredOrdered(firebaseUid)).length;
|
||||||
|
switch (sourceId) {
|
||||||
|
case 'market_history':
|
||||||
|
await _questionService.tryEnqueueMarketQuestions(
|
||||||
|
firebaseUid,
|
||||||
|
maxCreates: marketBatchCap,
|
||||||
|
);
|
||||||
|
case 'spelling':
|
||||||
|
await _questionPipeline.maintainSpellingForUser(firebaseUid);
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final int after =
|
||||||
|
(await _listEnabledUnansweredOrdered(firebaseUid)).length;
|
||||||
|
return after > before;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _isSourceEnabled(String firebaseUid, String sourceId) async {
|
||||||
|
final List<ResolvedQuestionSourcePreference> prefs =
|
||||||
|
await _preferencesDb.getResolved(firebaseUid);
|
||||||
|
return QuestionSourcePreferences.isEnabled(prefs, sourceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _marketBatchCapForMix(List<ResolvedQuestionSourcePreference> prefs) {
|
||||||
|
if (!QuestionSourcePreferences.isEnabled(prefs, 'market_history') ||
|
||||||
|
!QuestionSourcePreferences.isEnabled(prefs, 'spelling')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final Map<String, int> mix = QuestionSourcePreferences.effectiveMix(prefs);
|
||||||
|
final int spellingShare = mix['spelling'] ?? 0;
|
||||||
|
if (spellingShare <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _toClientPayload(
|
||||||
|
Map<String, dynamic> row,
|
||||||
|
int unansweredCount,
|
||||||
|
) {
|
||||||
|
return _questionsDb.toClientPayload(
|
||||||
|
row,
|
||||||
|
unansweredCount: unansweredCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'question_source_preferences_db.dart';
|
||||||
import 'questions_db.dart';
|
import 'questions_db.dart';
|
||||||
import 'signalr/questions_hub_connections.dart';
|
import 'signalr/questions_hub_connections.dart';
|
||||||
import 'trading/prospective_guess_selection.dart';
|
import 'trading/prospective_guess_selection.dart';
|
||||||
@ -12,21 +13,21 @@ class QuestionService {
|
|||||||
QuestionService({
|
QuestionService({
|
||||||
required QuestionsDb questionsDb,
|
required QuestionsDb questionsDb,
|
||||||
required QuestionsHubConnections hubConnections,
|
required QuestionsHubConnections hubConnections,
|
||||||
|
QuestionSourcePreferencesDb? questionSourcePreferencesDb,
|
||||||
}) : _questionsDb = questionsDb,
|
}) : _questionsDb = questionsDb,
|
||||||
_hubConnections = hubConnections;
|
_hubConnections = hubConnections,
|
||||||
|
_questionSourcePreferencesDb = questionSourcePreferencesDb;
|
||||||
|
|
||||||
final QuestionsDb _questionsDb;
|
final QuestionsDb _questionsDb;
|
||||||
final QuestionsHubConnections _hubConnections;
|
final QuestionsHubConnections _hubConnections;
|
||||||
|
final QuestionSourcePreferencesDb? _questionSourcePreferencesDb;
|
||||||
|
|
||||||
ProspectiveGuessAssignmentsDb get _assignmentsDb =>
|
ProspectiveGuessAssignmentsDb get _assignmentsDb =>
|
||||||
ProspectiveGuessAssignmentsDb(_questionsDb.connection);
|
ProspectiveGuessAssignmentsDb(_questionsDb.connection);
|
||||||
|
|
||||||
/// Called at login: ensures one unanswered prospective question when possible.
|
/// Called at login: ensures the user row exists (queue top-up uses [QuestionDispatcher]).
|
||||||
Future<Map<String, dynamic>?> bootstrapOnLogin(
|
Future<void> bootstrapOnLogin(String firebaseUid) async {
|
||||||
String firebaseUid,
|
|
||||||
) async {
|
|
||||||
await _questionsDb.ensureUserExists(firebaseUid);
|
await _questionsDb.ensureUserExists(firebaseUid);
|
||||||
return ensureProspectiveQuestionQueued(firebaseUid);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ensures the active slot's top-half batch exists, then returns the FIFO head.
|
/// Ensures the active slot's top-half batch exists, then returns the FIFO head.
|
||||||
@ -54,11 +55,36 @@ class QuestionService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enqueues market questions when enabled; returns whether the queue grew.
|
||||||
|
Future<bool> tryEnqueueMarketQuestions(
|
||||||
|
String firebaseUid, {
|
||||||
|
DateTime? now,
|
||||||
|
int? maxCreates,
|
||||||
|
}) async {
|
||||||
|
if (!await _isMarketHistoryEnabled(firebaseUid)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final int before =
|
||||||
|
await _questionsDb.countUnansweredQuestions(firebaseUid);
|
||||||
|
await _ensureCurrentSlotBatch(
|
||||||
|
firebaseUid,
|
||||||
|
now: now,
|
||||||
|
maxCreates: maxCreates,
|
||||||
|
);
|
||||||
|
final int after = await _questionsDb.countUnansweredQuestions(firebaseUid);
|
||||||
|
return after > before;
|
||||||
|
}
|
||||||
|
|
||||||
/// Creates every top-half question for the user's active slot pair.
|
/// Creates every top-half question for the user's active slot pair.
|
||||||
Future<void> _ensureCurrentSlotBatch(
|
Future<void> _ensureCurrentSlotBatch(
|
||||||
String firebaseUid, {
|
String firebaseUid, {
|
||||||
DateTime? now,
|
DateTime? now,
|
||||||
|
int? maxCreates,
|
||||||
}) async {
|
}) async {
|
||||||
|
if (!await _isMarketHistoryEnabled(firebaseUid)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final ProspectiveGuessSelection selection = ProspectiveGuessSelection(
|
final ProspectiveGuessSelection selection = ProspectiveGuessSelection(
|
||||||
connection: _questionsDb.connection,
|
connection: _questionsDb.connection,
|
||||||
);
|
);
|
||||||
@ -72,8 +98,12 @@ class QuestionService {
|
|||||||
|
|
||||||
final DateTime baseOrder = DateTime.now().toUtc();
|
final DateTime baseOrder = DateTime.now().toUtc();
|
||||||
var createdAny = false;
|
var createdAny = false;
|
||||||
|
var createdCount = 0;
|
||||||
|
|
||||||
for (var index = 0; index < batch.unassignedAssets.length; index++) {
|
for (var index = 0; index < batch.unassignedAssets.length; index++) {
|
||||||
|
if (maxCreates != null && createdCount >= maxCreates) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
final QuestionAuditAsset asset = batch.unassignedAssets[index];
|
final QuestionAuditAsset asset = batch.unassignedAssets[index];
|
||||||
if (await _assignmentsDb.hasAssignmentForSymbolSlotPair(
|
if (await _assignmentsDb.hasAssignmentForSymbolSlotPair(
|
||||||
firebaseUid: firebaseUid,
|
firebaseUid: firebaseUid,
|
||||||
@ -123,6 +153,7 @@ class QuestionService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
createdAny = true;
|
createdAny = true;
|
||||||
|
createdCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (createdAny) {
|
if (createdAny) {
|
||||||
@ -131,6 +162,15 @@ class QuestionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> _isMarketHistoryEnabled(String firebaseUid) async {
|
||||||
|
final QuestionSourcePreferencesDb? preferencesDb =
|
||||||
|
_questionSourcePreferencesDb;
|
||||||
|
if (preferencesDb == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return preferencesDb.isSourceEnabled(firebaseUid, 'market_history');
|
||||||
|
}
|
||||||
|
|
||||||
/// Inserts a question and pushes it to connected SignalR clients.
|
/// Inserts a question and pushes it to connected SignalR clients.
|
||||||
Future<Map<String, dynamic>> createAndDeliverQuestion({
|
Future<Map<String, dynamic>> createAndDeliverQuestion({
|
||||||
required String assignedUserId,
|
required String assignedUserId,
|
||||||
|
|||||||
70
server/lib/question_source_mix.dart
Normal file
70
server/lib/question_source_mix.dart
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import 'question_source_preferences.dart';
|
||||||
|
import 'questions_db.dart';
|
||||||
|
|
||||||
|
/// Picks the next question source to match configured mix percentages over time.
|
||||||
|
abstract final class QuestionSourceMix {
|
||||||
|
/// Source id with the largest deficit vs configured effective mix (sequential balance).
|
||||||
|
static String pickNextSourceId({
|
||||||
|
required Map<String, int> effectiveMixPercent,
|
||||||
|
required Map<String, int> deliveredCounts,
|
||||||
|
}) {
|
||||||
|
if (effectiveMixPercent.isEmpty) {
|
||||||
|
return 'market_history';
|
||||||
|
}
|
||||||
|
|
||||||
|
final int totalDelivered =
|
||||||
|
deliveredCounts.values.fold<int>(0, (int a, int b) => a + b);
|
||||||
|
if (totalDelivered == 0) {
|
||||||
|
return effectiveMixPercent.entries
|
||||||
|
.reduce(
|
||||||
|
(
|
||||||
|
MapEntry<String, int> a,
|
||||||
|
MapEntry<String, int> b,
|
||||||
|
) =>
|
||||||
|
a.value >= b.value ? a : b,
|
||||||
|
)
|
||||||
|
.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? pick;
|
||||||
|
var maxDeficit = double.negativeInfinity;
|
||||||
|
for (final MapEntry<String, int> entry in effectiveMixPercent.entries) {
|
||||||
|
final double target = entry.value / 100.0;
|
||||||
|
final double actual =
|
||||||
|
(deliveredCounts[entry.key] ?? 0) / totalDelivered;
|
||||||
|
final double deficit = target - actual;
|
||||||
|
if (deficit > maxDeficit) {
|
||||||
|
maxDeficit = deficit;
|
||||||
|
pick = entry.key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pick ?? effectiveMixPercent.keys.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, int> effectiveMixFromResolved(
|
||||||
|
List<ResolvedQuestionSourcePreference> prefs,
|
||||||
|
) {
|
||||||
|
return QuestionSourcePreferences.effectiveMix(prefs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Orders unanswered rows so [preferredSourceId] is shown first (stable FIFO within source).
|
||||||
|
static List<Map<String, dynamic>> orderUnansweredByPreferredSource(
|
||||||
|
List<Map<String, dynamic>> rows, {
|
||||||
|
required String preferredSourceId,
|
||||||
|
}) {
|
||||||
|
if (rows.length <= 1) {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<Map<String, dynamic>> preferred = <Map<String, dynamic>>[];
|
||||||
|
final List<Map<String, dynamic>> rest = <Map<String, dynamic>>[];
|
||||||
|
for (final Map<String, dynamic> row in rows) {
|
||||||
|
if (QuestionsDb.resolveSourceId(row) == preferredSourceId) {
|
||||||
|
preferred.add(row);
|
||||||
|
} else {
|
||||||
|
rest.add(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return <Map<String, dynamic>>[...preferred, ...rest];
|
||||||
|
}
|
||||||
|
}
|
||||||
215
server/lib/question_source_preferences.dart
Normal file
215
server/lib/question_source_preferences.dart
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
/// Canonical question source definitions and merge/validation helpers.
|
||||||
|
class QuestionSourcePreferenceDefinition {
|
||||||
|
const QuestionSourcePreferenceDefinition({
|
||||||
|
required this.id,
|
||||||
|
required this.label,
|
||||||
|
required this.description,
|
||||||
|
this.defaultEnabled = true,
|
||||||
|
this.defaultRatio = 50,
|
||||||
|
this.comingSoon = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String label;
|
||||||
|
final String description;
|
||||||
|
final bool defaultEnabled;
|
||||||
|
final int defaultRatio;
|
||||||
|
final bool comingSoon;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ResolvedQuestionSourcePreference {
|
||||||
|
const ResolvedQuestionSourcePreference({
|
||||||
|
required this.id,
|
||||||
|
required this.label,
|
||||||
|
required this.description,
|
||||||
|
required this.enabled,
|
||||||
|
required this.ratio,
|
||||||
|
required this.comingSoon,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String label;
|
||||||
|
final String description;
|
||||||
|
final bool enabled;
|
||||||
|
final int ratio;
|
||||||
|
final bool comingSoon;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return <String, dynamic>{
|
||||||
|
'id': id,
|
||||||
|
'label': label,
|
||||||
|
'description': description,
|
||||||
|
'enabled': enabled,
|
||||||
|
'ratio': ratio,
|
||||||
|
'comingSoon': comingSoon,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toStoredJson() {
|
||||||
|
return <String, dynamic>{
|
||||||
|
'id': id,
|
||||||
|
'enabled': enabled,
|
||||||
|
'ratio': ratio,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class QuestionSourceValidationException implements Exception {
|
||||||
|
QuestionSourceValidationException(this.message);
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => message;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract final class QuestionSourcePreferences {
|
||||||
|
static const List<QuestionSourcePreferenceDefinition> definitions =
|
||||||
|
<QuestionSourcePreferenceDefinition>[
|
||||||
|
QuestionSourcePreferenceDefinition(
|
||||||
|
id: 'market_history',
|
||||||
|
label: 'Market question',
|
||||||
|
description: 'Prospective price-move guessing game',
|
||||||
|
),
|
||||||
|
QuestionSourcePreferenceDefinition(
|
||||||
|
id: 'spelling',
|
||||||
|
label: 'Spelling',
|
||||||
|
description: 'Spell-check quiz from curated word lists',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
static List<ResolvedQuestionSourcePreference> mergeStored(
|
||||||
|
List<Map<String, dynamic>>? stored,
|
||||||
|
) {
|
||||||
|
final Map<String, Map<String, dynamic>> byId =
|
||||||
|
<String, Map<String, dynamic>>{
|
||||||
|
if (stored != null)
|
||||||
|
for (final Map<String, dynamic> row in stored) row['id']! as String: row,
|
||||||
|
};
|
||||||
|
|
||||||
|
return definitions
|
||||||
|
.map((QuestionSourcePreferenceDefinition definition) {
|
||||||
|
final Map<String, dynamic>? saved = byId[definition.id];
|
||||||
|
final bool enabled = definition.comingSoon
|
||||||
|
? false
|
||||||
|
: saved?['enabled'] as bool? ?? definition.defaultEnabled;
|
||||||
|
final int ratio =
|
||||||
|
(saved?['ratio'] as num?)?.round().clamp(0, 100) ??
|
||||||
|
definition.defaultRatio;
|
||||||
|
return ResolvedQuestionSourcePreference(
|
||||||
|
id: definition.id,
|
||||||
|
label: definition.label,
|
||||||
|
description: definition.description,
|
||||||
|
enabled: enabled,
|
||||||
|
ratio: ratio,
|
||||||
|
comingSoon: definition.comingSoon,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<Map<String, dynamic>> parsePutBody(Map<String, dynamic> body) {
|
||||||
|
final Object? rawSources = body['sources'];
|
||||||
|
if (rawSources is! List<dynamic>) {
|
||||||
|
throw QuestionSourceValidationException('Expected sources array.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, QuestionSourcePreferenceDefinition> known =
|
||||||
|
<String, QuestionSourcePreferenceDefinition>{
|
||||||
|
for (final QuestionSourcePreferenceDefinition definition in definitions)
|
||||||
|
definition.id: definition,
|
||||||
|
};
|
||||||
|
|
||||||
|
final List<Map<String, dynamic>> parsed = <Map<String, dynamic>>[];
|
||||||
|
for (final Object? entry in rawSources) {
|
||||||
|
if (entry is! Map<String, dynamic>) {
|
||||||
|
throw QuestionSourceValidationException('Invalid source entry.');
|
||||||
|
}
|
||||||
|
final String? id = entry['id'] as String?;
|
||||||
|
if (id == null || !known.containsKey(id)) {
|
||||||
|
throw QuestionSourceValidationException('Unknown source id: $id');
|
||||||
|
}
|
||||||
|
final QuestionSourcePreferenceDefinition definition = known[id]!;
|
||||||
|
final bool enabled = definition.comingSoon
|
||||||
|
? false
|
||||||
|
: entry['enabled'] as bool? ?? definition.defaultEnabled;
|
||||||
|
if (definition.comingSoon && entry['enabled'] == true) {
|
||||||
|
throw QuestionSourceValidationException(
|
||||||
|
'Source $id is not available yet.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final int ratio =
|
||||||
|
(entry['ratio'] as num?)?.round().clamp(0, 100) ??
|
||||||
|
definition.defaultRatio;
|
||||||
|
parsed.add(<String, dynamic>{
|
||||||
|
'id': id,
|
||||||
|
'enabled': enabled,
|
||||||
|
'ratio': ratio,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.length != definitions.length) {
|
||||||
|
throw QuestionSourceValidationException(
|
||||||
|
'Provide every known question source.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final bool hasEnabled = parsed.any((Map<String, dynamic> row) {
|
||||||
|
final QuestionSourcePreferenceDefinition definition =
|
||||||
|
known[row['id']! as String]!;
|
||||||
|
return row['enabled'] == true && !definition.comingSoon;
|
||||||
|
});
|
||||||
|
if (!hasEnabled) {
|
||||||
|
throw QuestionSourceValidationException(
|
||||||
|
'Enable at least one question source.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Effective share (0–100) for each enabled source; empty when none enabled.
|
||||||
|
static Map<String, int> effectiveMix(
|
||||||
|
List<ResolvedQuestionSourcePreference> sources,
|
||||||
|
) {
|
||||||
|
final Iterable<ResolvedQuestionSourcePreference> enabled = sources.where(
|
||||||
|
(ResolvedQuestionSourcePreference s) => s.enabled && !s.comingSoon,
|
||||||
|
);
|
||||||
|
final int totalWeight = enabled.fold<int>(
|
||||||
|
0,
|
||||||
|
(int sum, ResolvedQuestionSourcePreference s) => sum + s.ratio,
|
||||||
|
);
|
||||||
|
if (totalWeight <= 0) {
|
||||||
|
return <String, int>{};
|
||||||
|
}
|
||||||
|
|
||||||
|
return <String, int>{
|
||||||
|
for (final ResolvedQuestionSourcePreference source in enabled)
|
||||||
|
source.id: ((source.ratio / totalWeight) * 100).round(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool isEnabled(
|
||||||
|
List<ResolvedQuestionSourcePreference> resolved,
|
||||||
|
String sourceId,
|
||||||
|
) {
|
||||||
|
for (final ResolvedQuestionSourcePreference source in resolved) {
|
||||||
|
if (source.id == sourceId) {
|
||||||
|
return source.enabled && !source.comingSoon;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, dynamic> toApiPayload({
|
||||||
|
required List<ResolvedQuestionSourcePreference> sources,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
}) {
|
||||||
|
return <String, dynamic>{
|
||||||
|
'sources': sources
|
||||||
|
.map((ResolvedQuestionSourcePreference s) => s.toJson())
|
||||||
|
.toList(),
|
||||||
|
if (updatedAt != null) 'updatedAt': updatedAt.toUtc().toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
78
server/lib/question_source_preferences_db.dart
Normal file
78
server/lib/question_source_preferences_db.dart
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:postgres/postgres.dart';
|
||||||
|
|
||||||
|
import 'question_source_preferences.dart';
|
||||||
|
|
||||||
|
class QuestionSourcePreferencesDb {
|
||||||
|
QuestionSourcePreferencesDb(this._connection);
|
||||||
|
|
||||||
|
final Connection _connection;
|
||||||
|
|
||||||
|
Future<List<ResolvedQuestionSourcePreference>> getResolved(
|
||||||
|
String firebaseUid,
|
||||||
|
) async {
|
||||||
|
final List<Map<String, dynamic>>? stored = await _readStored(firebaseUid);
|
||||||
|
return QuestionSourcePreferences.mergeStored(stored);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> isSourceEnabled(String firebaseUid, String sourceId) async {
|
||||||
|
final List<ResolvedQuestionSourcePreference> resolved =
|
||||||
|
await getResolved(firebaseUid);
|
||||||
|
return QuestionSourcePreferences.isEnabled(resolved, sourceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<ResolvedQuestionSourcePreference>> save(
|
||||||
|
String firebaseUid,
|
||||||
|
List<Map<String, dynamic>> stored,
|
||||||
|
) async {
|
||||||
|
final DateTime updatedAt = DateTime.now().toUtc();
|
||||||
|
await _connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
INSERT INTO users (firebase_uid, question_source_preferences, updated_at)
|
||||||
|
VALUES (@uid, @preferences::jsonb, @updated_at)
|
||||||
|
ON CONFLICT (firebase_uid) DO UPDATE SET
|
||||||
|
question_source_preferences = EXCLUDED.question_source_preferences,
|
||||||
|
updated_at = EXCLUDED.updated_at
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{
|
||||||
|
'uid': firebaseUid,
|
||||||
|
'preferences': jsonEncode(stored),
|
||||||
|
'updated_at': updatedAt,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return QuestionSourcePreferences.mergeStored(stored);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>?> _readStored(String firebaseUid) async {
|
||||||
|
final Result result = await _connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
SELECT question_source_preferences
|
||||||
|
FROM users
|
||||||
|
WHERE firebase_uid = @uid
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'uid': firebaseUid},
|
||||||
|
);
|
||||||
|
if (result.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final Object? value = result.first[0];
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value is List<dynamic>) {
|
||||||
|
return value.cast<Map<String, dynamic>>();
|
||||||
|
}
|
||||||
|
if (value is String) {
|
||||||
|
final Object? decoded = jsonDecode(value);
|
||||||
|
if (decoded is List<dynamic>) {
|
||||||
|
return decoded.cast<Map<String, dynamic>>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ import 'dart:convert';
|
|||||||
import 'package:postgres/postgres.dart';
|
import 'package:postgres/postgres.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
import 'question_source_mix.dart';
|
||||||
import 'trading/market_history_config.dart';
|
import 'trading/market_history_config.dart';
|
||||||
import 'trading/prospective_answer_scoring.dart';
|
import 'trading/prospective_answer_scoring.dart';
|
||||||
import 'trading/guess_score_store.dart';
|
import 'trading/guess_score_store.dart';
|
||||||
@ -36,7 +37,7 @@ class QuestionsDb {
|
|||||||
static const String _questionsSelectColumns = '''
|
static const String _questionsSelectColumns = '''
|
||||||
q.id, q.assigned_user_id, q.question_text, q.user_response, q.correct_answer,
|
q.id, q.assigned_user_id, q.question_text, q.user_response, q.correct_answer,
|
||||||
q.created_at, q.modified_at, q.source_tag, q.pipeline_key, q.pipeline_step,
|
q.created_at, q.modified_at, q.source_tag, q.pipeline_key, q.pipeline_step,
|
||||||
q.metadata''';
|
q.metadata, q.user_response_text''';
|
||||||
|
|
||||||
static const String _unansweredQuestionsJoin = '''
|
static const String _unansweredQuestionsJoin = '''
|
||||||
FROM questions q
|
FROM questions q
|
||||||
@ -151,26 +152,47 @@ class QuestionsDb {
|
|||||||
Future<Map<String, dynamic>?> submitAnswer({
|
Future<Map<String, dynamic>?> submitAnswer({
|
||||||
required String questionId,
|
required String questionId,
|
||||||
required String assignedUserId,
|
required String assignedUserId,
|
||||||
required num userResponse,
|
num? userResponse,
|
||||||
|
String? userResponseText,
|
||||||
}) async {
|
}) async {
|
||||||
|
if (userResponse == null && userResponseText == null) {
|
||||||
|
throw ArgumentError('userResponse or userResponseText is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
num effectiveResponse = userResponse ?? 0;
|
||||||
|
String? textToStore = userResponseText?.trim();
|
||||||
|
|
||||||
|
if (textToStore != null) {
|
||||||
|
final Map<String, dynamic>? existing = await findQuestionById(
|
||||||
|
questionId: questionId,
|
||||||
|
assignedUserId: assignedUserId,
|
||||||
|
);
|
||||||
|
if (existing == null || resolveSourceId(existing) != 'spelling') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final DateTime now = DateTime.now().toUtc();
|
final DateTime now = DateTime.now().toUtc();
|
||||||
final Result result = await _connection.execute(
|
final Result result = await _connection.execute(
|
||||||
Sql.named(
|
Sql.named(
|
||||||
'''
|
'''
|
||||||
UPDATE questions
|
UPDATE questions
|
||||||
SET user_response = @user_response, modified_at = @modified_at
|
SET user_response = @user_response,
|
||||||
|
user_response_text = @user_response_text,
|
||||||
|
modified_at = @modified_at
|
||||||
WHERE id = @id::uuid
|
WHERE id = @id::uuid
|
||||||
AND assigned_user_id = @uid
|
AND assigned_user_id = @uid
|
||||||
AND user_response IS NULL
|
AND user_response IS NULL
|
||||||
RETURNING id, assigned_user_id, question_text, user_response, correct_answer,
|
RETURNING id, assigned_user_id, question_text, user_response, correct_answer,
|
||||||
created_at, modified_at, source_tag, pipeline_key, pipeline_step,
|
created_at, modified_at, source_tag, pipeline_key, pipeline_step,
|
||||||
metadata
|
metadata, user_response_text
|
||||||
''',
|
''',
|
||||||
),
|
),
|
||||||
parameters: <String, dynamic>{
|
parameters: <String, dynamic>{
|
||||||
'id': questionId,
|
'id': questionId,
|
||||||
'uid': assignedUserId,
|
'uid': assignedUserId,
|
||||||
'user_response': userResponse,
|
'user_response': effectiveResponse,
|
||||||
|
'user_response_text': textToStore,
|
||||||
'modified_at': now,
|
'modified_at': now,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -178,15 +200,122 @@ class QuestionsDb {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
final Map<String, dynamic> updated = _rowFromResult(result.first);
|
final Map<String, dynamic> updated = _rowFromResult(result.first);
|
||||||
await _recordProspectiveAnswer(updated);
|
if (resolveSourceId(updated) == 'market_history') {
|
||||||
await ProspectiveGuessAssignmentsDb(_connection).markAnsweredByQuestionId(
|
await _recordProspectiveAnswer(updated);
|
||||||
questionId: questionId,
|
await ProspectiveGuessAssignmentsDb(_connection).markAnsweredByQuestionId(
|
||||||
answeredAt: now,
|
questionId: questionId,
|
||||||
);
|
answeredAt: now,
|
||||||
await _gradeProspectiveAnswerIfNeeded(updated);
|
);
|
||||||
|
await _gradeProspectiveAnswerIfNeeded(updated);
|
||||||
|
}
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Answered questions grouped by client [resolveSourceId].
|
||||||
|
Future<Map<String, int>> countDeliveredBySourceId(String assignedUserId) async {
|
||||||
|
final Result result = await _connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
SELECT source_tag, pipeline_key
|
||||||
|
FROM questions
|
||||||
|
WHERE assigned_user_id = @uid AND user_response IS NOT NULL
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'uid': assignedUserId},
|
||||||
|
);
|
||||||
|
|
||||||
|
final Map<String, int> counts = <String, int>{};
|
||||||
|
for (final ResultRow row in result) {
|
||||||
|
final String sourceId = resolveSourceId(<String, dynamic>{
|
||||||
|
'sourceTag': row[0],
|
||||||
|
'pipelineKey': row[1],
|
||||||
|
});
|
||||||
|
counts[sourceId] = (counts[sourceId] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mix-ordered head of the unanswered queue for display after an answer or panel open.
|
||||||
|
Future<Map<String, dynamic>?> findHeadUnansweredForMix(
|
||||||
|
String assignedUserId, {
|
||||||
|
required Map<String, int> effectiveMixPercent,
|
||||||
|
Map<String, int>? deliveredCounts,
|
||||||
|
String? afterAnsweringSourceId,
|
||||||
|
Set<String>? allowedSourceIds,
|
||||||
|
}) async {
|
||||||
|
List<Map<String, dynamic>> rows =
|
||||||
|
await listUnansweredQuestions(assignedUserId);
|
||||||
|
if (allowedSourceIds != null) {
|
||||||
|
rows = filterByAllowedSources(rows, allowedSourceIds);
|
||||||
|
}
|
||||||
|
if (rows.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (afterAnsweringSourceId == 'spelling') {
|
||||||
|
for (final Map<String, dynamic> row in rows) {
|
||||||
|
if (resolveSourceId(row) == 'spelling') {
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, int> delivered =
|
||||||
|
deliveredCounts ?? await countDeliveredBySourceId(assignedUserId);
|
||||||
|
final String preferred = QuestionSourceMix.pickNextSourceId(
|
||||||
|
effectiveMixPercent: effectiveMixPercent,
|
||||||
|
deliveredCounts: delivered,
|
||||||
|
);
|
||||||
|
final List<Map<String, dynamic>> ordered =
|
||||||
|
QuestionSourceMix.orderUnansweredByPreferredSource(
|
||||||
|
rows,
|
||||||
|
preferredSourceId: preferred,
|
||||||
|
);
|
||||||
|
return ordered.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full unanswered list ordered for panel display (preferred mix source first).
|
||||||
|
Future<List<Map<String, dynamic>>> listUnansweredOrderedForMix(
|
||||||
|
String assignedUserId, {
|
||||||
|
required Map<String, int> effectiveMixPercent,
|
||||||
|
Set<String>? allowedSourceIds,
|
||||||
|
}) async {
|
||||||
|
List<Map<String, dynamic>> rows =
|
||||||
|
await listUnansweredQuestions(assignedUserId);
|
||||||
|
if (allowedSourceIds != null) {
|
||||||
|
rows = filterByAllowedSources(rows, allowedSourceIds);
|
||||||
|
}
|
||||||
|
if (rows.isEmpty) {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
final Map<String, int> delivered =
|
||||||
|
await countDeliveredBySourceId(assignedUserId);
|
||||||
|
final String preferred = QuestionSourceMix.pickNextSourceId(
|
||||||
|
effectiveMixPercent: effectiveMixPercent,
|
||||||
|
deliveredCounts: delivered,
|
||||||
|
);
|
||||||
|
return QuestionSourceMix.orderUnansweredByPreferredSource(
|
||||||
|
rows,
|
||||||
|
preferredSourceId: preferred,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps only rows whose [resolveSourceId] is in [allowedSourceIds].
|
||||||
|
static List<Map<String, dynamic>> filterByAllowedSources(
|
||||||
|
List<Map<String, dynamic>> rows,
|
||||||
|
Set<String> allowedSourceIds,
|
||||||
|
) {
|
||||||
|
if (allowedSourceIds.isEmpty) {
|
||||||
|
return <Map<String, dynamic>>[];
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
.where(
|
||||||
|
(Map<String, dynamic> row) =>
|
||||||
|
allowedSourceIds.contains(resolveSourceId(row)),
|
||||||
|
)
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>?> findQuestionById({
|
Future<Map<String, dynamic>?> findQuestionById({
|
||||||
required String questionId,
|
required String questionId,
|
||||||
required String assignedUserId,
|
required String assignedUserId,
|
||||||
@ -196,7 +325,7 @@ class QuestionsDb {
|
|||||||
'''
|
'''
|
||||||
SELECT id, assigned_user_id, question_text, user_response, correct_answer,
|
SELECT id, assigned_user_id, question_text, user_response, correct_answer,
|
||||||
created_at, modified_at, source_tag, pipeline_key, pipeline_step,
|
created_at, modified_at, source_tag, pipeline_key, pipeline_step,
|
||||||
metadata
|
metadata, user_response_text
|
||||||
FROM questions
|
FROM questions
|
||||||
WHERE id = @id::uuid AND assigned_user_id = @uid
|
WHERE id = @id::uuid AND assigned_user_id = @uid
|
||||||
''',
|
''',
|
||||||
@ -370,6 +499,41 @@ class QuestionsDb {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Count of unanswered questions assigned to [assignedUserId].
|
/// Count of unanswered questions assigned to [assignedUserId].
|
||||||
|
/// Unanswered [spelling_word_quiz] row for [assignedUserId], if any.
|
||||||
|
Future<bool> hasUnansweredSpellingWordQuiz(String assignedUserId) async {
|
||||||
|
final Result result = await _connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
SELECT 1
|
||||||
|
FROM questions
|
||||||
|
WHERE assigned_user_id = @uid
|
||||||
|
AND user_response IS NULL
|
||||||
|
AND pipeline_step = 'spelling_word_quiz'
|
||||||
|
AND (
|
||||||
|
source_tag LIKE 'spelling:%'
|
||||||
|
OR pipeline_key = 'spelling'
|
||||||
|
)
|
||||||
|
LIMIT 1
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'uid': assignedUserId},
|
||||||
|
);
|
||||||
|
return result.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Counts unanswered rows whose [resolveSourceId] is in [allowedSourceIds].
|
||||||
|
Future<int> countUnansweredForAllowedSources(
|
||||||
|
String assignedUserId,
|
||||||
|
Set<String> allowedSourceIds,
|
||||||
|
) async {
|
||||||
|
if (allowedSourceIds.isEmpty) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
final List<Map<String, dynamic>> rows =
|
||||||
|
await listUnansweredQuestions(assignedUserId);
|
||||||
|
return filterByAllowedSources(rows, allowedSourceIds).length;
|
||||||
|
}
|
||||||
|
|
||||||
Future<int> countUnansweredQuestions(String assignedUserId) async {
|
Future<int> countUnansweredQuestions(String assignedUserId) async {
|
||||||
final Result result = await _connection.execute(
|
final Result result = await _connection.execute(
|
||||||
Sql.named(
|
Sql.named(
|
||||||
@ -488,20 +652,114 @@ class QuestionsDb {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stable client source id derived from pipeline metadata.
|
||||||
|
static String resolveSourceId(Map<String, dynamic> question) {
|
||||||
|
final String? sourceTag = question['sourceTag'] as String?;
|
||||||
|
if (sourceTag != null && sourceTag.startsWith('spelling:')) {
|
||||||
|
return 'spelling';
|
||||||
|
}
|
||||||
|
if (sourceTag != null && sourceTag.startsWith('market_history:')) {
|
||||||
|
return 'market_history';
|
||||||
|
}
|
||||||
|
final String? pipelineKey = question['pipelineKey'] as String?;
|
||||||
|
if (pipelineKey == 'spelling') {
|
||||||
|
return 'spelling';
|
||||||
|
}
|
||||||
|
return 'market_history';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UI presentation key for [resolveSourceId].
|
||||||
|
static String resolvePresentation(Map<String, dynamic> question) {
|
||||||
|
final String? step = question['pipelineStep'] as String?;
|
||||||
|
final String? sourceTag = question['sourceTag'] as String?;
|
||||||
|
if (step == 'spelling_follow_up' || sourceTag == 'spelling:follow_up') {
|
||||||
|
return 'market_glyph_slider';
|
||||||
|
}
|
||||||
|
if (resolveSourceId(question) == 'spelling') {
|
||||||
|
return 'spelling_word';
|
||||||
|
}
|
||||||
|
return 'market_glyph_slider';
|
||||||
|
}
|
||||||
|
|
||||||
/// Payload sent to the Flutter client over SignalR (excludes correct answer).
|
/// Payload sent to the Flutter client over SignalR (excludes correct answer).
|
||||||
Map<String, dynamic> toClientPayload(
|
Map<String, dynamic> toClientPayload(
|
||||||
Map<String, dynamic> question, {
|
Map<String, dynamic> question, {
|
||||||
required int unansweredCount,
|
required int unansweredCount,
|
||||||
}) {
|
}) {
|
||||||
|
final Map<String, dynamic> metadata = Map<String, dynamic>.from(
|
||||||
|
question['metadata'] as Map<String, dynamic>? ?? <String, dynamic>{},
|
||||||
|
);
|
||||||
|
final String? spokenWord = metadata['displayed_word'] as String?;
|
||||||
|
final bool isSpelling = resolveSourceId(question) == 'spelling';
|
||||||
|
final bool needsPrepare = isSpelling &&
|
||||||
|
(spokenWord == null || spokenWord.isEmpty) &&
|
||||||
|
metadata['spelling_pending'] == true;
|
||||||
|
final bool isPreparedSpelling =
|
||||||
|
isSpelling &&
|
||||||
|
spokenWord != null &&
|
||||||
|
spokenWord.isNotEmpty &&
|
||||||
|
!needsPrepare;
|
||||||
|
|
||||||
return <String, dynamic>{
|
return <String, dynamic>{
|
||||||
'id': question['id'],
|
'id': question['id'],
|
||||||
'assignedUserId': question['assignedUserId'],
|
'assignedUserId': question['assignedUserId'],
|
||||||
'text': question['text'],
|
'text': question['text'],
|
||||||
'sentAt': question['createdAt'],
|
'sentAt': question['createdAt'],
|
||||||
'unansweredCount': unansweredCount,
|
'unansweredCount': unansweredCount,
|
||||||
|
'sourceId': resolveSourceId(question),
|
||||||
|
'presentation': resolvePresentation(question),
|
||||||
|
if (needsPrepare) 'needsPrepare': true,
|
||||||
|
if (isPreparedSpelling) ...<String, dynamic>{
|
||||||
|
'spokenWord': spokenWord,
|
||||||
|
'audioTitle': question['id'],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fills in a reserved spelling quiz after word selection at tile load.
|
||||||
|
Future<Map<String, dynamic>?> updateSpellingQuizQuestion({
|
||||||
|
required String questionId,
|
||||||
|
required String assignedUserId,
|
||||||
|
required String questionText,
|
||||||
|
required num correctAnswer,
|
||||||
|
required Map<String, dynamic> metadata,
|
||||||
|
}) async {
|
||||||
|
final DateTime now = DateTime.now().toUtc();
|
||||||
|
final Result result = await _connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
UPDATE questions
|
||||||
|
SET question_text = @text,
|
||||||
|
correct_answer = @answer,
|
||||||
|
metadata = @metadata::jsonb,
|
||||||
|
modified_at = @now
|
||||||
|
WHERE id = @id::uuid
|
||||||
|
AND assigned_user_id = @uid
|
||||||
|
AND user_response IS NULL
|
||||||
|
AND (
|
||||||
|
pipeline_step = 'spelling_word_quiz'
|
||||||
|
OR source_tag = 'spelling:word_quiz'
|
||||||
|
)
|
||||||
|
RETURNING id, assigned_user_id, question_text, user_response, correct_answer,
|
||||||
|
created_at, modified_at, source_tag, pipeline_key, pipeline_step,
|
||||||
|
metadata, user_response_text
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{
|
||||||
|
'id': questionId,
|
||||||
|
'uid': assignedUserId,
|
||||||
|
'text': questionText,
|
||||||
|
'answer': correctAnswer,
|
||||||
|
'metadata': jsonEncode(metadata),
|
||||||
|
'now': now,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (result.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return _rowFromResult(result.first);
|
||||||
|
}
|
||||||
|
|
||||||
Map<String, dynamic> _rowFromResult(ResultRow row) {
|
Map<String, dynamic> _rowFromResult(ResultRow row) {
|
||||||
final Object idValue = row[0]!;
|
final Object idValue = row[0]!;
|
||||||
final String id = idValue is String ? idValue : idValue.toString();
|
final String id = idValue is String ? idValue : idValue.toString();
|
||||||
@ -519,6 +777,7 @@ class QuestionsDb {
|
|||||||
pipelineKey: row.length > 8 ? row[8] as String? : null,
|
pipelineKey: row.length > 8 ? row[8] as String? : null,
|
||||||
pipelineStep: row.length > 9 ? row[9] as String? : null,
|
pipelineStep: row.length > 9 ? row[9] as String? : null,
|
||||||
metadata: _readJsonMap(row.length > 10 ? row[10] : null),
|
metadata: _readJsonMap(row.length > 10 ? row[10] : null),
|
||||||
|
userResponseText: row.length > 11 ? row[11] as String? : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -663,12 +922,14 @@ class QuestionsDb {
|
|||||||
String? pipelineKey,
|
String? pipelineKey,
|
||||||
String? pipelineStep,
|
String? pipelineStep,
|
||||||
Map<String, dynamic>? metadata,
|
Map<String, dynamic>? metadata,
|
||||||
|
String? userResponseText,
|
||||||
}) {
|
}) {
|
||||||
return <String, dynamic>{
|
return <String, dynamic>{
|
||||||
'id': id,
|
'id': id,
|
||||||
'assignedUserId': assignedUserId,
|
'assignedUserId': assignedUserId,
|
||||||
'text': questionText,
|
'text': questionText,
|
||||||
'userResponse': userResponse,
|
'userResponse': userResponse,
|
||||||
|
if (userResponseText != null) 'userResponseText': userResponseText,
|
||||||
'correctAnswer': correctAnswer,
|
'correctAnswer': correctAnswer,
|
||||||
'createdAt': createdAt.toIso8601String(),
|
'createdAt': createdAt.toIso8601String(),
|
||||||
'modifiedAt': modifiedAt.toIso8601String(),
|
'modifiedAt': modifiedAt.toIso8601String(),
|
||||||
|
|||||||
6
server/lib/spelling/spelling_answer_policy.dart
Normal file
6
server/lib/spelling/spelling_answer_policy.dart
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/// Grading rules for typed spelling answers.
|
||||||
|
abstract final class SpellingAnswerPolicy {
|
||||||
|
static bool matches(String typed, String canonical) {
|
||||||
|
return typed.trim().toLowerCase() == canonical.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
42
server/lib/spelling/spelling_item_record.dart
Normal file
42
server/lib/spelling/spelling_item_record.dart
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
/// One row from [spelling_items] joined with optional user progress.
|
||||||
|
class SpellingItemRecord {
|
||||||
|
const SpellingItemRecord({
|
||||||
|
required this.id,
|
||||||
|
required this.spellingWordId,
|
||||||
|
required this.canonicalWord,
|
||||||
|
required this.displayedWord,
|
||||||
|
required this.isCorrectlySpelled,
|
||||||
|
required this.correctAnswer,
|
||||||
|
this.familiarityScore = 0,
|
||||||
|
this.attemptCount = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String spellingWordId;
|
||||||
|
final String canonicalWord;
|
||||||
|
final String displayedWord;
|
||||||
|
final bool isCorrectlySpelled;
|
||||||
|
final num correctAnswer;
|
||||||
|
final int familiarityScore;
|
||||||
|
final int attemptCount;
|
||||||
|
|
||||||
|
Map<String, dynamic> toQuestionMetadata() {
|
||||||
|
return <String, dynamic>{
|
||||||
|
'spelling_item_id': id,
|
||||||
|
'spelling_word_id': spellingWordId,
|
||||||
|
'canonical_word': canonicalWord,
|
||||||
|
'displayed_word': displayedWord,
|
||||||
|
'is_correctly_spelled': isCorrectlySpelled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toPipelineContext() {
|
||||||
|
return <String, dynamic>{
|
||||||
|
'spellingItemId': id,
|
||||||
|
'spellingWordId': spellingWordId,
|
||||||
|
'canonicalWord': canonicalWord,
|
||||||
|
'displayedWord': displayedWord,
|
||||||
|
'isCorrectlySpelled': isCorrectlySpelled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
56
server/lib/spelling/spelling_item_selector.dart
Normal file
56
server/lib/spelling/spelling_item_selector.dart
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'spelling_item_record.dart';
|
||||||
|
import 'spelling_items_db.dart';
|
||||||
|
import 'spelling_progress_policy.dart';
|
||||||
|
|
||||||
|
/// Picks a shared spelling item for a user using familiarity-weighted randomness.
|
||||||
|
class SpellingItemSelector {
|
||||||
|
SpellingItemSelector({
|
||||||
|
SpellingItemsDb? itemsDb,
|
||||||
|
Random? random,
|
||||||
|
}) : _itemsDb = itemsDb,
|
||||||
|
_random = random ?? Random();
|
||||||
|
|
||||||
|
final SpellingItemsDb? _itemsDb;
|
||||||
|
final Random _random;
|
||||||
|
|
||||||
|
Future<SpellingItemRecord?> pickForUser(String firebaseUid) async {
|
||||||
|
final SpellingItemsDb? itemsDb = _itemsDb;
|
||||||
|
if (itemsDb == null) {
|
||||||
|
throw StateError('SpellingItemSelector requires itemsDb for pickForUser');
|
||||||
|
}
|
||||||
|
final List<SpellingItemRecord> items =
|
||||||
|
await itemsDb.listActiveWithProgress(firebaseUid);
|
||||||
|
if (items.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return _weightedPick(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Visible for unit tests.
|
||||||
|
SpellingItemRecord? pickFromCandidates(List<SpellingItemRecord> items) {
|
||||||
|
if (items.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return _weightedPick(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
SpellingItemRecord _weightedPick(List<SpellingItemRecord> items) {
|
||||||
|
final List<double> weights = items
|
||||||
|
.map(
|
||||||
|
(SpellingItemRecord item) =>
|
||||||
|
SpellingProgressPolicy.weightForScore(item.familiarityScore),
|
||||||
|
)
|
||||||
|
.toList(growable: false);
|
||||||
|
final double total = weights.fold<double>(0, (double a, double b) => a + b);
|
||||||
|
var roll = _random.nextDouble() * total;
|
||||||
|
for (var i = 0; i < items.length; i++) {
|
||||||
|
roll -= weights[i];
|
||||||
|
if (roll < 0) {
|
||||||
|
return items[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items.last;
|
||||||
|
}
|
||||||
|
}
|
||||||
250
server/lib/spelling/spelling_items_db.dart
Normal file
250
server/lib/spelling/spelling_items_db.dart
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
import 'package:postgres/postgres.dart';
|
||||||
|
|
||||||
|
import '../pipeline/spelling_word_list.dart';
|
||||||
|
import 'spelling_item_record.dart';
|
||||||
|
|
||||||
|
/// Shared spelling catalog ([spelling_words], [spelling_items]).
|
||||||
|
class SpellingItemsDb {
|
||||||
|
SpellingItemsDb(this._connection);
|
||||||
|
|
||||||
|
final Connection _connection;
|
||||||
|
|
||||||
|
/// Inserts curriculum from [SpellingWordList] when the catalog is empty.
|
||||||
|
Future<void> ensureCatalogSeeded() async {
|
||||||
|
final Result count = await _connection.execute(
|
||||||
|
'SELECT COUNT(*)::int FROM spelling_words',
|
||||||
|
);
|
||||||
|
if ((count.first[0]! as num).toInt() > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final ({String correct, String misspelled}) pair
|
||||||
|
in SpellingWordList.pairs) {
|
||||||
|
final Result wordRow = await _connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
INSERT INTO spelling_words (canonical_word)
|
||||||
|
VALUES (@canonical)
|
||||||
|
ON CONFLICT (canonical_word) DO UPDATE SET canonical_word = EXCLUDED.canonical_word
|
||||||
|
RETURNING id
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'canonical': pair.correct},
|
||||||
|
);
|
||||||
|
final String wordId = wordRow.first[0]!.toString();
|
||||||
|
|
||||||
|
await _insertItem(
|
||||||
|
spellingWordId: wordId,
|
||||||
|
displayedWord: pair.correct,
|
||||||
|
isCorrectlySpelled: true,
|
||||||
|
correctAnswer: 10,
|
||||||
|
);
|
||||||
|
await _insertItem(
|
||||||
|
spellingWordId: wordId,
|
||||||
|
displayedWord: pair.misspelled,
|
||||||
|
isCorrectlySpelled: false,
|
||||||
|
correctAnswer: -10,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _insertItem({
|
||||||
|
required String spellingWordId,
|
||||||
|
required String displayedWord,
|
||||||
|
required bool isCorrectlySpelled,
|
||||||
|
required num correctAnswer,
|
||||||
|
}) async {
|
||||||
|
await _connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
INSERT INTO spelling_items (
|
||||||
|
spelling_word_id, displayed_word, is_correctly_spelled, correct_answer
|
||||||
|
) VALUES (
|
||||||
|
@word_id::uuid, @displayed, @is_correct, @answer
|
||||||
|
)
|
||||||
|
ON CONFLICT (spelling_word_id, displayed_word) DO NOTHING
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{
|
||||||
|
'word_id': spellingWordId,
|
||||||
|
'displayed': displayedWord,
|
||||||
|
'is_correct': isCorrectlySpelled,
|
||||||
|
'answer': correctAnswer,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Active catalog rows with per-user familiarity (0 when unseen).
|
||||||
|
Future<List<SpellingItemRecord>> listActiveWithProgress(
|
||||||
|
String firebaseUid,
|
||||||
|
) async {
|
||||||
|
final Result result = await _connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
SELECT
|
||||||
|
si.id,
|
||||||
|
si.spelling_word_id,
|
||||||
|
sw.canonical_word,
|
||||||
|
si.displayed_word,
|
||||||
|
si.is_correctly_spelled,
|
||||||
|
si.correct_answer,
|
||||||
|
COALESCE(usp.familiarity_score, 0),
|
||||||
|
COALESCE(usp.attempt_count, 0)
|
||||||
|
FROM spelling_items si
|
||||||
|
INNER JOIN spelling_words sw ON sw.id = si.spelling_word_id
|
||||||
|
LEFT JOIN user_spelling_progress usp
|
||||||
|
ON usp.spelling_item_id = si.id
|
||||||
|
AND usp.firebase_uid = @uid
|
||||||
|
WHERE si.active = true AND sw.active = true
|
||||||
|
ORDER BY sw.canonical_word, si.displayed_word
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'uid': firebaseUid},
|
||||||
|
);
|
||||||
|
|
||||||
|
return result
|
||||||
|
.map(
|
||||||
|
(ResultRow row) => SpellingItemRecord(
|
||||||
|
id: row[0]!.toString(),
|
||||||
|
spellingWordId: row[1]!.toString(),
|
||||||
|
canonicalWord: row[2]! as String,
|
||||||
|
displayedWord: row[3]! as String,
|
||||||
|
isCorrectlySpelled: row[4]! as bool,
|
||||||
|
correctAnswer: _readNumeric(row[5]),
|
||||||
|
familiarityScore: (row[6]! as num).toInt(),
|
||||||
|
attemptCount: (row[7]! as num).toInt(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canonical words in the shared catalog (no user history).
|
||||||
|
Future<List<String>> listCanonicalWords() async {
|
||||||
|
final Result result = await _connection.execute(
|
||||||
|
'''
|
||||||
|
SELECT canonical_word
|
||||||
|
FROM spelling_words
|
||||||
|
WHERE active = true
|
||||||
|
ORDER BY canonical_word ASC
|
||||||
|
''',
|
||||||
|
);
|
||||||
|
return result
|
||||||
|
.map((ResultRow row) => row[0]! as String)
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a word and quiz items when absent; returns false if already present.
|
||||||
|
Future<bool> addCanonicalWord(String rawWord) async {
|
||||||
|
final String canonical = _normalizeCanonical(rawWord);
|
||||||
|
if (canonical.isEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Result existing = await _connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
SELECT id FROM spelling_words
|
||||||
|
WHERE canonical_word = @canonical
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'canonical': canonical},
|
||||||
|
);
|
||||||
|
if (existing.isNotEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Result wordRow = await _connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
INSERT INTO spelling_words (canonical_word)
|
||||||
|
VALUES (@canonical)
|
||||||
|
RETURNING id
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'canonical': canonical},
|
||||||
|
);
|
||||||
|
final String wordId = wordRow.first[0]!.toString();
|
||||||
|
final String misspelled = SpellingWordList.generatedMisspelling(canonical);
|
||||||
|
await _insertItem(
|
||||||
|
spellingWordId: wordId,
|
||||||
|
displayedWord: canonical,
|
||||||
|
isCorrectlySpelled: true,
|
||||||
|
correctAnswer: 10,
|
||||||
|
);
|
||||||
|
if (misspelled != canonical) {
|
||||||
|
await _insertItem(
|
||||||
|
spellingWordId: wordId,
|
||||||
|
displayedWord: misspelled,
|
||||||
|
isCorrectlySpelled: false,
|
||||||
|
correctAnswer: -10,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line per word; skips blanks and duplicates.
|
||||||
|
Future<({int added, int skipped})> importCanonicalWords(
|
||||||
|
Iterable<String> lines,
|
||||||
|
) async {
|
||||||
|
var added = 0;
|
||||||
|
var skipped = 0;
|
||||||
|
for (final String line in lines) {
|
||||||
|
final String canonical = _normalizeCanonical(line);
|
||||||
|
if (canonical.isEmpty) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (await addCanonicalWord(canonical)) {
|
||||||
|
added++;
|
||||||
|
} else {
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (added: added, skipped: skipped);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _normalizeCanonical(String raw) =>
|
||||||
|
raw.trim().toLowerCase();
|
||||||
|
|
||||||
|
Future<SpellingItemRecord?> findById(String spellingItemId) async {
|
||||||
|
final Result result = await _connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
SELECT
|
||||||
|
si.id,
|
||||||
|
si.spelling_word_id,
|
||||||
|
sw.canonical_word,
|
||||||
|
si.displayed_word,
|
||||||
|
si.is_correctly_spelled,
|
||||||
|
si.correct_answer
|
||||||
|
FROM spelling_items si
|
||||||
|
INNER JOIN spelling_words sw ON sw.id = si.spelling_word_id
|
||||||
|
WHERE si.id = @id::uuid
|
||||||
|
LIMIT 1
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'id': spellingItemId},
|
||||||
|
);
|
||||||
|
if (result.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final ResultRow row = result.first;
|
||||||
|
return SpellingItemRecord(
|
||||||
|
id: row[0]!.toString(),
|
||||||
|
spellingWordId: row[1]!.toString(),
|
||||||
|
canonicalWord: row[2]! as String,
|
||||||
|
displayedWord: row[3]! as String,
|
||||||
|
isCorrectlySpelled: row[4]! as bool,
|
||||||
|
correctAnswer: _readNumeric(row[5]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static num _readNumeric(Object? value) {
|
||||||
|
if (value is num) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value is String) {
|
||||||
|
return num.parse(value);
|
||||||
|
}
|
||||||
|
throw ArgumentError('Expected numeric value, got $value');
|
||||||
|
}
|
||||||
|
}
|
||||||
38
server/lib/spelling/spelling_progress_policy.dart
Normal file
38
server/lib/spelling/spelling_progress_policy.dart
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
/// Tunables for spelling familiarity scoring and weighted selection.
|
||||||
|
abstract final class SpellingProgressPolicy {
|
||||||
|
/// Added to [familiarity_score] when the user answers the quiz correctly.
|
||||||
|
static const int correctDelta = 1;
|
||||||
|
|
||||||
|
/// Subtracted from [familiarity_score] when the user answers incorrectly.
|
||||||
|
static const int incorrectDelta = 1;
|
||||||
|
|
||||||
|
/// Base weight before familiarity adjustment.
|
||||||
|
static const double baseWeight = 1.0;
|
||||||
|
|
||||||
|
/// Weight reduction per point of positive familiarity (mastered words).
|
||||||
|
static const double penaltyPerPoint = 0.15;
|
||||||
|
|
||||||
|
/// Minimum selection weight — never zero chance.
|
||||||
|
static const double minWeight = 0.05;
|
||||||
|
|
||||||
|
/// Selection weight for a catalog item given the user's familiarity score.
|
||||||
|
static double weightForScore(int familiarityScore) {
|
||||||
|
return max(
|
||||||
|
minWeight,
|
||||||
|
baseWeight - familiarityScore * penaltyPerPoint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies a graded quiz outcome to the stored familiarity score.
|
||||||
|
static int scoreAfterAttempt({
|
||||||
|
required int currentScore,
|
||||||
|
required bool correct,
|
||||||
|
}) {
|
||||||
|
if (correct) {
|
||||||
|
return currentScore + correctDelta;
|
||||||
|
}
|
||||||
|
return currentScore - incorrectDelta;
|
||||||
|
}
|
||||||
|
}
|
||||||
48
server/lib/spelling/spelling_question_links_db.dart
Normal file
48
server/lib/spelling/spelling_question_links_db.dart
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import 'package:postgres/postgres.dart';
|
||||||
|
|
||||||
|
/// Per-question link between a user, quiz row, and shared spelling item.
|
||||||
|
class SpellingQuestionLinksDb {
|
||||||
|
SpellingQuestionLinksDb(this._connection);
|
||||||
|
|
||||||
|
final Connection _connection;
|
||||||
|
|
||||||
|
/// Creates a link when the spelling tile loads; idempotent per [questionId].
|
||||||
|
Future<void> linkQuestion({
|
||||||
|
required String questionId,
|
||||||
|
required String firebaseUid,
|
||||||
|
required String spellingItemId,
|
||||||
|
}) async {
|
||||||
|
await _connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
INSERT INTO spelling_question_links (
|
||||||
|
question_id, firebase_uid, spelling_item_id
|
||||||
|
) VALUES (
|
||||||
|
@question_id::uuid, @uid, @item_id::uuid
|
||||||
|
)
|
||||||
|
ON CONFLICT (question_id) DO NOTHING
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{
|
||||||
|
'question_id': questionId,
|
||||||
|
'uid': firebaseUid,
|
||||||
|
'item_id': spellingItemId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> hasLink(String questionId) async {
|
||||||
|
final Result result = await _connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
SELECT 1
|
||||||
|
FROM spelling_question_links
|
||||||
|
WHERE question_id = @question_id::uuid
|
||||||
|
LIMIT 1
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'question_id': questionId},
|
||||||
|
);
|
||||||
|
return result.isNotEmpty;
|
||||||
|
}
|
||||||
|
}
|
||||||
114
server/lib/spelling/user_spelling_progress_db.dart
Normal file
114
server/lib/spelling/user_spelling_progress_db.dart
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import 'package:postgres/postgres.dart';
|
||||||
|
|
||||||
|
import 'spelling_progress_policy.dart';
|
||||||
|
|
||||||
|
/// Per-user familiarity with shared [spelling_items].
|
||||||
|
class UserSpellingProgressDb {
|
||||||
|
UserSpellingProgressDb(this._connection);
|
||||||
|
|
||||||
|
final Connection _connection;
|
||||||
|
|
||||||
|
/// Ensures a per-user link to [spellingItemId] with scores starting at zero.
|
||||||
|
Future<void> ensureLink({
|
||||||
|
required String firebaseUid,
|
||||||
|
required String spellingItemId,
|
||||||
|
}) async {
|
||||||
|
await _connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
INSERT INTO user_spelling_progress (
|
||||||
|
firebase_uid,
|
||||||
|
spelling_item_id,
|
||||||
|
familiarity_score,
|
||||||
|
attempt_count,
|
||||||
|
correct_count
|
||||||
|
) VALUES (
|
||||||
|
@uid,
|
||||||
|
@item_id::uuid,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
ON CONFLICT (firebase_uid, spelling_item_id) DO NOTHING
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{
|
||||||
|
'uid': firebaseUid,
|
||||||
|
'item_id': spellingItemId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> recordQuizOutcome({
|
||||||
|
required String firebaseUid,
|
||||||
|
required String spellingItemId,
|
||||||
|
required bool correct,
|
||||||
|
}) async {
|
||||||
|
final DateTime now = DateTime.now().toUtc();
|
||||||
|
final String outcome = correct ? 'correct' : 'incorrect';
|
||||||
|
final int delta = correct
|
||||||
|
? SpellingProgressPolicy.correctDelta
|
||||||
|
: -SpellingProgressPolicy.incorrectDelta;
|
||||||
|
|
||||||
|
await _connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
INSERT INTO user_spelling_progress (
|
||||||
|
firebase_uid,
|
||||||
|
spelling_item_id,
|
||||||
|
familiarity_score,
|
||||||
|
attempt_count,
|
||||||
|
correct_count,
|
||||||
|
last_attempted_at,
|
||||||
|
last_outcome
|
||||||
|
) VALUES (
|
||||||
|
@uid,
|
||||||
|
@item_id::uuid,
|
||||||
|
@delta,
|
||||||
|
1,
|
||||||
|
@correct_inc,
|
||||||
|
@now,
|
||||||
|
@outcome
|
||||||
|
)
|
||||||
|
ON CONFLICT (firebase_uid, spelling_item_id) DO UPDATE SET
|
||||||
|
familiarity_score = user_spelling_progress.familiarity_score + @delta,
|
||||||
|
attempt_count = user_spelling_progress.attempt_count + 1,
|
||||||
|
correct_count = user_spelling_progress.correct_count + @correct_inc,
|
||||||
|
last_attempted_at = @now,
|
||||||
|
last_outcome = @outcome
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{
|
||||||
|
'uid': firebaseUid,
|
||||||
|
'item_id': spellingItemId,
|
||||||
|
'delta': delta,
|
||||||
|
'correct_inc': correct ? 1 : 0,
|
||||||
|
'now': now,
|
||||||
|
'outcome': outcome,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int?> familiarityScore({
|
||||||
|
required String firebaseUid,
|
||||||
|
required String spellingItemId,
|
||||||
|
}) async {
|
||||||
|
final Result result = await _connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
SELECT familiarity_score
|
||||||
|
FROM user_spelling_progress
|
||||||
|
WHERE firebase_uid = @uid AND spelling_item_id = @item_id::uuid
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{
|
||||||
|
'uid': firebaseUid,
|
||||||
|
'item_id': spellingItemId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (result.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (result.first[0]! as num).toInt();
|
||||||
|
}
|
||||||
|
}
|
||||||
2
server/migrations/018_question_source_preferences.sql
Normal file
2
server/migrations/018_question_source_preferences.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS question_source_preferences JSONB;
|
||||||
35
server/migrations/019_spelling_catalog.sql
Normal file
35
server/migrations/019_spelling_catalog.sql
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
-- Shared spelling curriculum and per-user familiarity tracking.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS spelling_words (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
canonical_word TEXT NOT NULL UNIQUE,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT true
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS spelling_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
spelling_word_id UUID NOT NULL REFERENCES spelling_words (id) ON DELETE CASCADE,
|
||||||
|
displayed_word TEXT NOT NULL,
|
||||||
|
is_correctly_spelled BOOLEAN NOT NULL,
|
||||||
|
correct_answer NUMERIC NOT NULL,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
UNIQUE (spelling_word_id, displayed_word)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS spelling_items_active_idx
|
||||||
|
ON spelling_items (spelling_word_id)
|
||||||
|
WHERE active = true;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_spelling_progress (
|
||||||
|
firebase_uid TEXT NOT NULL REFERENCES users (firebase_uid) ON DELETE CASCADE,
|
||||||
|
spelling_item_id UUID NOT NULL REFERENCES spelling_items (id) ON DELETE CASCADE,
|
||||||
|
familiarity_score INTEGER NOT NULL DEFAULT 0,
|
||||||
|
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
correct_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_attempted_at TIMESTAMPTZ,
|
||||||
|
last_outcome TEXT,
|
||||||
|
PRIMARY KEY (firebase_uid, spelling_item_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS user_spelling_progress_uid_idx
|
||||||
|
ON user_spelling_progress (firebase_uid);
|
||||||
14
server/migrations/020_spelling_question_links.sql
Normal file
14
server/migrations/020_spelling_question_links.sql
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
-- Links a spelling quiz question row to the selected catalog item at tile load.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS spelling_question_links (
|
||||||
|
question_id UUID PRIMARY KEY REFERENCES questions (id) ON DELETE CASCADE,
|
||||||
|
firebase_uid TEXT NOT NULL REFERENCES users (firebase_uid) ON DELETE CASCADE,
|
||||||
|
spelling_item_id UUID NOT NULL REFERENCES spelling_items (id) ON DELETE CASCADE,
|
||||||
|
linked_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS spelling_question_links_uid_idx
|
||||||
|
ON spelling_question_links (firebase_uid);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS spelling_question_links_item_idx
|
||||||
|
ON spelling_question_links (spelling_item_id);
|
||||||
4
server/migrations/021_questions_user_response_text.sql
Normal file
4
server/migrations/021_questions_user_response_text.sql
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
-- Text answers for spelling quizzes (typed spellings).
|
||||||
|
|
||||||
|
ALTER TABLE questions
|
||||||
|
ADD COLUMN IF NOT EXISTS user_response_text TEXT;
|
||||||
@ -1,8 +1,12 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:cyberhybridhub_server/db.dart';
|
import 'package:cyberhybridhub_server/db.dart';
|
||||||
|
import 'package:cyberhybridhub_server/question_source_preferences_db.dart';
|
||||||
|
import 'package:cyberhybridhub_server/pipeline/question_pipeline.dart';
|
||||||
|
import 'package:cyberhybridhub_server/question_dispatcher.dart';
|
||||||
import 'package:cyberhybridhub_server/question_service.dart';
|
import 'package:cyberhybridhub_server/question_service.dart';
|
||||||
import 'package:cyberhybridhub_server/questions_db.dart';
|
import 'package:cyberhybridhub_server/questions_db.dart';
|
||||||
|
import 'package:cyberhybridhub_server/spelling/spelling_items_db.dart';
|
||||||
import 'package:cyberhybridhub_server/signalr/questions_hub_connections.dart';
|
import 'package:cyberhybridhub_server/signalr/questions_hub_connections.dart';
|
||||||
import 'package:cyberhybridhub_server/trading/market_data_db.dart';
|
import 'package:cyberhybridhub_server/trading/market_data_db.dart';
|
||||||
import 'package:cyberhybridhub_server/trading/trade_orders_db.dart';
|
import 'package:cyberhybridhub_server/trading/trade_orders_db.dart';
|
||||||
@ -11,7 +15,7 @@ import 'package:cyberhybridhub_server/trading/user_trading_state_db.dart';
|
|||||||
import 'package:dotenv/dotenv.dart';
|
import 'package:dotenv/dotenv.dart';
|
||||||
import 'package:postgres/postgres.dart';
|
import 'package:postgres/postgres.dart';
|
||||||
|
|
||||||
/// Integration test Postgres: [cyberhybridhub_test] with migrations 001–017.
|
/// Integration test Postgres: [cyberhybridhub_test] with migrations 001–018.
|
||||||
class TestDb {
|
class TestDb {
|
||||||
TestDb._(this.db, this._connection, this.databaseUrl);
|
TestDb._(this.db, this._connection, this.databaseUrl);
|
||||||
|
|
||||||
@ -52,6 +56,7 @@ class TestDb {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await profileDb.migrate();
|
await profileDb.migrate();
|
||||||
|
await SpellingItemsDb(profileDb.connection).ensureCatalogSeeded();
|
||||||
return TestDb._(profileDb, profileDb.connection, testUrl);
|
return TestDb._(profileDb, profileDb.connection, testUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -113,12 +118,38 @@ class TestDb {
|
|||||||
|
|
||||||
QuestionsDb get questionsDb => QuestionsDb(_connection);
|
QuestionsDb get questionsDb => QuestionsDb(_connection);
|
||||||
|
|
||||||
|
QuestionSourcePreferencesDb get questionSourcePreferencesDb =>
|
||||||
|
QuestionSourcePreferencesDb(_connection);
|
||||||
|
|
||||||
|
SpellingItemsDb get spellingItemsDb => SpellingItemsDb(_connection);
|
||||||
|
|
||||||
/// QuestionService backed by a no-op hub (no live SignalR clients in tests).
|
/// QuestionService backed by a no-op hub (no live SignalR clients in tests).
|
||||||
QuestionService questionService() => QuestionService(
|
QuestionService questionService() => QuestionService(
|
||||||
questionsDb: questionsDb,
|
questionsDb: questionsDb,
|
||||||
hubConnections: QuestionsHubConnections(),
|
hubConnections: QuestionsHubConnections(),
|
||||||
|
questionSourcePreferencesDb: questionSourcePreferencesDb,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
QuestionPipeline questionPipeline({bool testMode = true}) => QuestionPipeline(
|
||||||
|
questionsDb: questionsDb,
|
||||||
|
questionService: questionService(),
|
||||||
|
questionSourcePreferencesDb: questionSourcePreferencesDb,
|
||||||
|
spellingItemsDb: spellingItemsDb,
|
||||||
|
testMode: testMode,
|
||||||
|
);
|
||||||
|
|
||||||
|
QuestionDispatcher questionDispatcher({bool testMode = true}) {
|
||||||
|
final QuestionPipeline pipeline = questionPipeline(testMode: testMode);
|
||||||
|
final QuestionDispatcher dispatcher = QuestionDispatcher(
|
||||||
|
questionsDb: questionsDb,
|
||||||
|
questionService: questionService(),
|
||||||
|
questionPipeline: pipeline,
|
||||||
|
preferencesDb: questionSourcePreferencesDb,
|
||||||
|
);
|
||||||
|
pipeline.attachQuestionDispatcher(dispatcher);
|
||||||
|
return dispatcher;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> truncateTradingTables() async {
|
Future<void> truncateTradingTables() async {
|
||||||
await _connection.execute(
|
await _connection.execute(
|
||||||
'''
|
'''
|
||||||
|
|||||||
@ -1030,9 +1030,12 @@ void main() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final QuestionService service = testDb!.questionService();
|
await testDb!.questionService().bootstrapOnLogin(uid);
|
||||||
final Map<String, dynamic>? payload = await service.bootstrapOnLogin(uid);
|
final Map<String, dynamic>? payload =
|
||||||
|
await testDb!.questionDispatcher().dispatchNextQuestion(uid);
|
||||||
expect(payload, isNotNull);
|
expect(payload, isNotNull);
|
||||||
|
expect(payload!['sourceId'], 'market_history');
|
||||||
|
expect(payload['presentation'], 'market_glyph_slider');
|
||||||
|
|
||||||
final List<Map<String, dynamic>> queued =
|
final List<Map<String, dynamic>> queued =
|
||||||
await testDb!.questionsDb.listUnansweredQuestions(uid);
|
await testDb!.questionsDb.listUnansweredQuestions(uid);
|
||||||
|
|||||||
125
server/test/integration/question_dispatcher_mix_test.dart
Normal file
125
server/test/integration/question_dispatcher_mix_test.dart
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
@Tags(<String>['integration', 'postgres'])
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:cyberhybridhub_server/pipeline/question_pipeline.dart';
|
||||||
|
import 'package:cyberhybridhub_server/question_dispatcher.dart';
|
||||||
|
import 'package:postgres/postgres.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
import '../helpers/test_db.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestDb? testDb;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
testDb = await TestDb.open();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
if (testDb != null) {
|
||||||
|
await testDb!.truncateTradingTables();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDownAll(() async {
|
||||||
|
await testDb?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disabled source is never dispatched', () async {
|
||||||
|
if (testDb == null) {
|
||||||
|
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const String uid = 'dispatcher-spelling-only';
|
||||||
|
await testDb!.seedUser(uid);
|
||||||
|
await testDb!.questionSourcePreferencesDb.save(
|
||||||
|
uid,
|
||||||
|
<Map<String, dynamic>>[
|
||||||
|
<String, dynamic>{'id': 'market_history', 'enabled': false, 'ratio': 0},
|
||||||
|
<String, dynamic>{'id': 'spelling', 'enabled': true, 'ratio': 100},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final QuestionDispatcher dispatcher = testDb!.questionDispatcher();
|
||||||
|
final Map<String, dynamic>? payload =
|
||||||
|
await dispatcher.dispatchNextQuestion(uid);
|
||||||
|
|
||||||
|
expect(payload, isNotNull);
|
||||||
|
expect(payload!['sourceId'], 'spelling');
|
||||||
|
expect(payload['presentation'], 'spelling_word');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('70/30 mix yields spelling and respects fall-through over cycles', () async {
|
||||||
|
if (testDb == null) {
|
||||||
|
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const String uid = 'dispatcher-mix-uid';
|
||||||
|
await testDb!.seedUser(uid);
|
||||||
|
await testDb!.questionSourcePreferencesDb.save(
|
||||||
|
uid,
|
||||||
|
<Map<String, dynamic>>[
|
||||||
|
<String, dynamic>{'id': 'market_history', 'enabled': true, 'ratio': 70},
|
||||||
|
<String, dynamic>{'id': 'spelling', 'enabled': true, 'ratio': 30},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final QuestionDispatcher dispatcher = testDb!.questionDispatcher(
|
||||||
|
testMode: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
final Set<String> seenSources = <String>{};
|
||||||
|
for (var cycle = 0; cycle < 16 && seenSources.length < 2; cycle++) {
|
||||||
|
final Map<String, dynamic>? payload =
|
||||||
|
await dispatcher.dispatchNextQuestion(uid);
|
||||||
|
if (payload == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seenSources.add(payload['sourceId'] as String);
|
||||||
|
|
||||||
|
final Result rows = await testDb!.connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
SELECT id, pipeline_key
|
||||||
|
FROM questions
|
||||||
|
WHERE assigned_user_id = @uid AND user_response IS NULL
|
||||||
|
ORDER BY created_at
|
||||||
|
LIMIT 1
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'uid': uid},
|
||||||
|
);
|
||||||
|
if (rows.isEmpty) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
final String questionId = rows.first[0]!.toString();
|
||||||
|
final String? pipelineKey = rows.first[1] as String?;
|
||||||
|
await testDb!.questionsDb.submitAnswer(
|
||||||
|
questionId: questionId,
|
||||||
|
assignedUserId: uid,
|
||||||
|
userResponse: 0,
|
||||||
|
);
|
||||||
|
if (pipelineKey == PipelineKeys.spelling) {
|
||||||
|
await testDb!.connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
UPDATE user_pipeline_state
|
||||||
|
SET step = @idle
|
||||||
|
WHERE assigned_user_id = @uid AND pipeline_key = @key
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{
|
||||||
|
'uid': uid,
|
||||||
|
'key': PipelineKeys.spelling,
|
||||||
|
'idle': PipelineSteps.idle,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(seenSources, contains('spelling'));
|
||||||
|
expect(seenSources.length, greaterThanOrEqualTo(1));
|
||||||
|
});
|
||||||
|
}
|
||||||
186
server/test/integration/question_source_preferences_test.dart
Normal file
186
server/test/integration/question_source_preferences_test.dart
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
@Tags(<String>['integration', 'postgres'])
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:cyberhybridhub_server/firebase_auth.dart';
|
||||||
|
import 'package:cyberhybridhub_server/handlers/question_source_handler.dart';
|
||||||
|
import 'package:cyberhybridhub_server/question_source_preferences_db.dart';
|
||||||
|
import 'package:cyberhybridhub_server/questions_db.dart';
|
||||||
|
import 'package:postgres/postgres.dart';
|
||||||
|
import 'package:shelf/shelf.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
import '../helpers/test_db.dart';
|
||||||
|
|
||||||
|
class _FakeAuthVerifier extends FirebaseAuthVerifier {
|
||||||
|
_FakeAuthVerifier() : super('test-key');
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> verifyBearerToken(String? authorization) async {
|
||||||
|
if (authorization == null || !authorization.startsWith('Bearer ')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final String token = authorization.substring('Bearer '.length).trim();
|
||||||
|
return token.isEmpty ? null : token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> _get(
|
||||||
|
Handler handler, {
|
||||||
|
required String path,
|
||||||
|
String? bearer,
|
||||||
|
}) {
|
||||||
|
return Future<Response>.value(
|
||||||
|
handler(
|
||||||
|
Request(
|
||||||
|
'GET',
|
||||||
|
Uri.parse('http://localhost$path'),
|
||||||
|
headers: <String, String>{
|
||||||
|
if (bearer != null) 'Authorization': 'Bearer $bearer',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> _put(
|
||||||
|
Handler handler, {
|
||||||
|
required String path,
|
||||||
|
required Map<String, dynamic> body,
|
||||||
|
String? bearer,
|
||||||
|
}) {
|
||||||
|
return Future<Response>.value(
|
||||||
|
handler(
|
||||||
|
Request(
|
||||||
|
'PUT',
|
||||||
|
Uri.parse('http://localhost$path'),
|
||||||
|
body: jsonEncode(body),
|
||||||
|
headers: <String, String>{
|
||||||
|
if (bearer != null) 'Authorization': 'Bearer $bearer',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestDb? testDb;
|
||||||
|
late Handler handler;
|
||||||
|
late QuestionSourcePreferencesDb preferencesDb;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
testDb = await TestDb.open();
|
||||||
|
if (testDb == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
preferencesDb = testDb!.questionSourcePreferencesDb;
|
||||||
|
handler = questionSourceHandler(
|
||||||
|
db: preferencesDb,
|
||||||
|
auth: _FakeAuthVerifier(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
if (testDb != null) {
|
||||||
|
await testDb!.truncateTradingTables();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDownAll(() async {
|
||||||
|
await testDb?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET returns defaults for unknown user', () async {
|
||||||
|
if (testDb == null) {
|
||||||
|
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Response response = await _get(
|
||||||
|
handler,
|
||||||
|
path: questionSourcesBasePath,
|
||||||
|
bearer: 'prefs-user',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.statusCode, 200);
|
||||||
|
final Map<String, dynamic> body =
|
||||||
|
jsonDecode(await response.readAsString()) as Map<String, dynamic>;
|
||||||
|
final List<dynamic> sources = body['sources'] as List<dynamic>;
|
||||||
|
expect(sources.length, 2);
|
||||||
|
expect(sources.first['id'], 'market_history');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PUT persists preferences and GET returns saved mix', () async {
|
||||||
|
if (testDb == null) {
|
||||||
|
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const String uid = 'prefs-save-user';
|
||||||
|
await testDb!.seedUser(uid);
|
||||||
|
|
||||||
|
final Response putResponse = await _put(
|
||||||
|
handler,
|
||||||
|
path: questionSourcesBasePath,
|
||||||
|
bearer: uid,
|
||||||
|
body: <String, dynamic>{
|
||||||
|
'sources': <Map<String, dynamic>>[
|
||||||
|
<String, dynamic>{'id': 'market_history', 'enabled': true, 'ratio': 80},
|
||||||
|
<String, dynamic>{'id': 'spelling', 'enabled': false, 'ratio': 20},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(putResponse.statusCode, 200);
|
||||||
|
|
||||||
|
final Response getResponse = await _get(
|
||||||
|
handler,
|
||||||
|
path: questionSourcesBasePath,
|
||||||
|
bearer: uid,
|
||||||
|
);
|
||||||
|
expect(getResponse.statusCode, 200);
|
||||||
|
final Map<String, dynamic> body =
|
||||||
|
jsonDecode(await getResponse.readAsString()) as Map<String, dynamic>;
|
||||||
|
final List<dynamic> sources = body['sources'] as List<dynamic>;
|
||||||
|
final Map<String, dynamic> market = sources.firstWhere(
|
||||||
|
(dynamic source) => source['id'] == 'market_history',
|
||||||
|
) as Map<String, dynamic>;
|
||||||
|
expect(market['ratio'], 80);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensureProspectiveQuestionQueued skips batch when market_history disabled',
|
||||||
|
() async {
|
||||||
|
if (testDb == null) {
|
||||||
|
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const String uid = 'prefs-disabled-market-user';
|
||||||
|
await testDb!.seedUser(uid);
|
||||||
|
await preferencesDb.save(
|
||||||
|
uid,
|
||||||
|
<Map<String, dynamic>>[
|
||||||
|
<String, dynamic>{'id': 'market_history', 'enabled': false, 'ratio': 50},
|
||||||
|
<String, dynamic>{'id': 'spelling', 'enabled': false, 'ratio': 50},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final QuestionsDb questionsDb = testDb!.questionsDb;
|
||||||
|
final service = testDb!.questionService();
|
||||||
|
await questionsDb.ensureUserExists(uid);
|
||||||
|
await service.ensureProspectiveQuestionQueued(uid);
|
||||||
|
|
||||||
|
final Result rows = await testDb!.connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
SELECT COUNT(*)::int
|
||||||
|
FROM questions
|
||||||
|
WHERE assigned_user_id = @uid
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'uid': uid},
|
||||||
|
);
|
||||||
|
expect(rows.first[0], 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
209
server/test/integration/spelling_catalog_test.dart
Normal file
209
server/test/integration/spelling_catalog_test.dart
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
@Tags(<String>['integration', 'postgres'])
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:cyberhybridhub_server/pipeline/question_pipeline.dart';
|
||||||
|
import 'package:cyberhybridhub_server/spelling/spelling_item_selector.dart';
|
||||||
|
import 'package:cyberhybridhub_server/spelling/spelling_items_db.dart';
|
||||||
|
import 'package:cyberhybridhub_server/spelling/user_spelling_progress_db.dart';
|
||||||
|
import 'package:postgres/postgres.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
import '../helpers/test_db.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestDb? testDb;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
testDb = await TestDb.open();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
if (testDb != null) {
|
||||||
|
await testDb!.truncateTradingTables();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDownAll(() async {
|
||||||
|
await testDb?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('catalog seeds two items per curated word', () async {
|
||||||
|
if (testDb == null) {
|
||||||
|
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Result words = await testDb!.connection.execute(
|
||||||
|
'SELECT COUNT(*)::int FROM spelling_words',
|
||||||
|
);
|
||||||
|
final Result items = await testDb!.connection.execute(
|
||||||
|
'SELECT COUNT(*)::int FROM spelling_items',
|
||||||
|
);
|
||||||
|
expect((words.first[0]! as num).toInt(), 10);
|
||||||
|
expect((items.first[0]! as num).toInt(), 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wrong answer increases familiarity weight for next pick', () async {
|
||||||
|
if (testDb == null) {
|
||||||
|
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const String uid = 'spelling-weight-uid';
|
||||||
|
await testDb!.seedUser(uid);
|
||||||
|
|
||||||
|
final SpellingItemsDb itemsDb = testDb!.spellingItemsDb;
|
||||||
|
final UserSpellingProgressDb progressDb =
|
||||||
|
UserSpellingProgressDb(testDb!.connection);
|
||||||
|
final SpellingItemSelector selector = SpellingItemSelector(
|
||||||
|
itemsDb: itemsDb,
|
||||||
|
random: Random(1),
|
||||||
|
);
|
||||||
|
|
||||||
|
final picked = await selector.pickForUser(uid);
|
||||||
|
expect(picked, isNotNull);
|
||||||
|
|
||||||
|
await progressDb.recordQuizOutcome(
|
||||||
|
firebaseUid: uid,
|
||||||
|
spellingItemId: picked!.id,
|
||||||
|
correct: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final int? score = await progressDb.familiarityScore(
|
||||||
|
firebaseUid: uid,
|
||||||
|
spellingItemId: picked.id,
|
||||||
|
);
|
||||||
|
expect(score, -1);
|
||||||
|
|
||||||
|
final QuestionPipeline pipeline = testDb!.questionPipeline();
|
||||||
|
await pipeline.maintainSpellingForUser(uid);
|
||||||
|
|
||||||
|
final Result reserved = await testDb!.connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
SELECT id
|
||||||
|
FROM questions
|
||||||
|
WHERE assigned_user_id = @uid
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'uid': uid},
|
||||||
|
);
|
||||||
|
await pipeline.prepareSpellingQuestion(
|
||||||
|
firebaseUid: uid,
|
||||||
|
questionId: reserved.first[0]!.toString(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final Result rows = await testDb!.connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
SELECT metadata
|
||||||
|
FROM questions
|
||||||
|
WHERE assigned_user_id = @uid
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'uid': uid},
|
||||||
|
);
|
||||||
|
final Object? metadataRaw = rows.first[0];
|
||||||
|
final Map<String, dynamic> metadata = metadataRaw is Map<String, dynamic>
|
||||||
|
? metadataRaw
|
||||||
|
: Map<String, dynamic>.from(
|
||||||
|
jsonDecode(metadataRaw.toString()) as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
expect(metadata['spelling_item_id'], isNotEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('quiz answer updates user_spelling_progress', () async {
|
||||||
|
if (testDb == null) {
|
||||||
|
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const String uid = 'spelling-progress-uid';
|
||||||
|
await testDb!.seedUser(uid);
|
||||||
|
|
||||||
|
final QuestionPipeline pipeline = testDb!.questionPipeline();
|
||||||
|
await pipeline.maintainSpellingForUser(uid);
|
||||||
|
|
||||||
|
final Result reserved = await testDb!.connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
SELECT id
|
||||||
|
FROM questions
|
||||||
|
WHERE assigned_user_id = @uid
|
||||||
|
ORDER BY created_at
|
||||||
|
LIMIT 1
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'uid': uid},
|
||||||
|
);
|
||||||
|
await pipeline.prepareSpellingQuestion(
|
||||||
|
firebaseUid: uid,
|
||||||
|
questionId: reserved.first[0]!.toString(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final Result first = await testDb!.connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
SELECT id, correct_answer, metadata
|
||||||
|
FROM questions
|
||||||
|
WHERE assigned_user_id = @uid
|
||||||
|
ORDER BY created_at
|
||||||
|
LIMIT 1
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'uid': uid},
|
||||||
|
);
|
||||||
|
final String questionId = first.first[0]!.toString();
|
||||||
|
final Map<String, dynamic> metadata = _readMetadata(first.first[2]);
|
||||||
|
final String itemId = metadata['spelling_item_id']! as String;
|
||||||
|
final String canonicalWord = metadata['canonical_word']! as String;
|
||||||
|
|
||||||
|
final Map<String, dynamic>? answered = await testDb!.questionsDb.submitAnswer(
|
||||||
|
questionId: questionId,
|
||||||
|
assignedUserId: uid,
|
||||||
|
userResponseText: canonicalWord,
|
||||||
|
);
|
||||||
|
expect(answered, isNotNull);
|
||||||
|
|
||||||
|
await pipeline.onAnswerSubmitted(
|
||||||
|
firebaseUid: uid,
|
||||||
|
answeredQuestion: answered!,
|
||||||
|
userResponse: answered['userResponse'] as num? ?? 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
final Result progress = await testDb!.connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
SELECT familiarity_score, attempt_count, correct_count, last_outcome
|
||||||
|
FROM user_spelling_progress
|
||||||
|
WHERE firebase_uid = @uid AND spelling_item_id = @item_id::uuid
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'uid': uid, 'item_id': itemId},
|
||||||
|
);
|
||||||
|
expect(progress, hasLength(1));
|
||||||
|
expect(progress.first[0], 1);
|
||||||
|
expect(progress.first[1], 1);
|
||||||
|
expect(progress.first[2], 1);
|
||||||
|
expect(progress.first[3], 'correct');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _readMetadata(Object? raw) {
|
||||||
|
if (raw is Map<String, dynamic>) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
if (raw is Map) {
|
||||||
|
return Map<String, dynamic>.from(raw);
|
||||||
|
}
|
||||||
|
return Map<String, dynamic>.from(
|
||||||
|
jsonDecode(raw.toString()) as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
}
|
||||||
62
server/test/integration/spelling_only_panel_test.dart
Normal file
62
server/test/integration/spelling_only_panel_test.dart
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
@Tags(<String>['integration', 'postgres'])
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:cyberhybridhub_server/question_dispatcher.dart';
|
||||||
|
import 'package:cyberhybridhub_server/questions_db.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
import '../helpers/test_db.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestDb? testDb;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
testDb = await TestDb.open();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
if (testDb != null) {
|
||||||
|
await testDb!.truncateTradingTables();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDownAll(() async {
|
||||||
|
await testDb?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listPanelQuestions excludes market when market_history disabled', () async {
|
||||||
|
if (testDb == null) {
|
||||||
|
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const String uid = 'spelling-only-panel-uid';
|
||||||
|
await testDb!.seedUser(uid);
|
||||||
|
await testDb!.questionSourcePreferencesDb.save(
|
||||||
|
uid,
|
||||||
|
<Map<String, dynamic>>[
|
||||||
|
<String, dynamic>{'id': 'market_history', 'enabled': false, 'ratio': 0},
|
||||||
|
<String, dynamic>{'id': 'spelling', 'enabled': true, 'ratio': 100},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await testDb!.questionsDb.createQuestion(
|
||||||
|
assignedUserId: uid,
|
||||||
|
questionText: 'Stale market question',
|
||||||
|
correctAnswer: 10,
|
||||||
|
sourceTag: 'market_history:prospective',
|
||||||
|
pipelineKey: 'trading',
|
||||||
|
);
|
||||||
|
|
||||||
|
final QuestionDispatcher dispatcher = testDb!.questionDispatcher();
|
||||||
|
final List<Map<String, dynamic>> panel =
|
||||||
|
await dispatcher.listPanelQuestions(uid);
|
||||||
|
|
||||||
|
expect(panel, isNotEmpty);
|
||||||
|
for (final Map<String, dynamic> payload in panel) {
|
||||||
|
expect(payload['sourceId'], isNot('market_history'));
|
||||||
|
}
|
||||||
|
expect(panel.first['sourceId'], 'spelling');
|
||||||
|
expect(panel.first['presentation'], 'spelling_word');
|
||||||
|
});
|
||||||
|
}
|
||||||
253
server/test/integration/spelling_pipeline_test.dart
Normal file
253
server/test/integration/spelling_pipeline_test.dart
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
@Tags(<String>['integration', 'postgres'])
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:cyberhybridhub_server/pipeline/question_pipeline.dart';
|
||||||
|
import 'package:postgres/postgres.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
import '../helpers/test_db.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestDb? testDb;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
testDb = await TestDb.open();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
if (testDb != null) {
|
||||||
|
await testDb!.truncateTradingTables();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDownAll(() async {
|
||||||
|
await testDb?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<QuestionPipeline> _pipeline() async {
|
||||||
|
return QuestionPipeline(
|
||||||
|
questionsDb: testDb!.questionsDb,
|
||||||
|
questionService: testDb!.questionService(),
|
||||||
|
questionSourcePreferencesDb: testDb!.questionSourcePreferencesDb,
|
||||||
|
testMode: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('maintainSpellingForUser creates spelling pipeline question', () async {
|
||||||
|
if (testDb == null) {
|
||||||
|
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const String uid = 'spelling-maintain-uid';
|
||||||
|
await testDb!.seedUser(uid);
|
||||||
|
|
||||||
|
final QuestionPipeline pipeline = await _pipeline();
|
||||||
|
await pipeline.maintainSpellingForUser(uid);
|
||||||
|
|
||||||
|
final Result rows = await testDb!.connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
SELECT pipeline_key, pipeline_step, source_tag
|
||||||
|
FROM questions
|
||||||
|
WHERE assigned_user_id = @uid
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'uid': uid},
|
||||||
|
);
|
||||||
|
expect(rows, hasLength(1));
|
||||||
|
expect(rows.first[0], PipelineKeys.spelling);
|
||||||
|
expect(rows.first[1], PipelineSteps.spellingWordQuiz);
|
||||||
|
expect(rows.first[2], 'spelling:word_quiz');
|
||||||
|
|
||||||
|
final Result meta = await testDb!.connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
SELECT metadata->>'spelling_item_id'
|
||||||
|
FROM questions
|
||||||
|
WHERE assigned_user_id = @uid
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'uid': uid},
|
||||||
|
);
|
||||||
|
expect(meta.first[0], isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prepareSpellingQuestion selects word and creates links at tile load',
|
||||||
|
() async {
|
||||||
|
if (testDb == null) {
|
||||||
|
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const String uid = 'spelling-prepare-uid';
|
||||||
|
await testDb!.seedUser(uid);
|
||||||
|
|
||||||
|
final QuestionPipeline pipeline = await _pipeline();
|
||||||
|
await pipeline.maintainSpellingForUser(uid);
|
||||||
|
|
||||||
|
final Result reserved = await testDb!.connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
SELECT id, metadata
|
||||||
|
FROM questions
|
||||||
|
WHERE assigned_user_id = @uid
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'uid': uid},
|
||||||
|
);
|
||||||
|
final String questionId = reserved.first[0]!.toString();
|
||||||
|
|
||||||
|
final Map<String, dynamic>? prepared = await pipeline.prepareSpellingQuestion(
|
||||||
|
firebaseUid: uid,
|
||||||
|
questionId: questionId,
|
||||||
|
);
|
||||||
|
expect(prepared, isNotNull);
|
||||||
|
expect(prepared!['spokenWord'], isNotEmpty);
|
||||||
|
expect(prepared['audioTitle'], questionId);
|
||||||
|
|
||||||
|
final Result link = await testDb!.connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
SELECT spelling_item_id
|
||||||
|
FROM spelling_question_links
|
||||||
|
WHERE question_id = @qid::uuid
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'qid': questionId},
|
||||||
|
);
|
||||||
|
expect(link, hasLength(1));
|
||||||
|
|
||||||
|
final Result progress = await testDb!.connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
SELECT familiarity_score, attempt_count
|
||||||
|
FROM user_spelling_progress
|
||||||
|
WHERE firebase_uid = @uid
|
||||||
|
AND spelling_item_id = @item_id::uuid
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{
|
||||||
|
'uid': uid,
|
||||||
|
'item_id': link.first[0]!.toString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(progress, hasLength(1));
|
||||||
|
expect(progress.first[0], 0);
|
||||||
|
expect(progress.first[1], 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maintainSpellingForUser skips when spelling source disabled', () async {
|
||||||
|
if (testDb == null) {
|
||||||
|
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const String uid = 'spelling-disabled-uid';
|
||||||
|
await testDb!.seedUser(uid);
|
||||||
|
await testDb!.questionSourcePreferencesDb.save(
|
||||||
|
uid,
|
||||||
|
<Map<String, dynamic>>[
|
||||||
|
<String, dynamic>{'id': 'market_history', 'enabled': true, 'ratio': 50},
|
||||||
|
<String, dynamic>{'id': 'spelling', 'enabled': false, 'ratio': 50},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final QuestionPipeline pipeline = await _pipeline();
|
||||||
|
await pipeline.maintainSpellingForUser(uid);
|
||||||
|
|
||||||
|
final Result rows = await testDb!.connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
SELECT COUNT(*)::int
|
||||||
|
FROM questions
|
||||||
|
WHERE assigned_user_id = @uid
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'uid': uid},
|
||||||
|
);
|
||||||
|
expect(rows.first[0], 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('onAnswerSubmitted branches to follow-up then another word', () async {
|
||||||
|
if (testDb == null) {
|
||||||
|
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const String uid = 'spelling-answer-uid';
|
||||||
|
await testDb!.seedUser(uid);
|
||||||
|
|
||||||
|
final QuestionPipeline pipeline = await _pipeline();
|
||||||
|
await pipeline.maintainSpellingForUser(uid);
|
||||||
|
|
||||||
|
final Result reserved = await testDb!.connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
SELECT id
|
||||||
|
FROM questions
|
||||||
|
WHERE assigned_user_id = @uid
|
||||||
|
ORDER BY created_at
|
||||||
|
LIMIT 1
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'uid': uid},
|
||||||
|
);
|
||||||
|
final String reservedId = reserved.first[0]!.toString();
|
||||||
|
await pipeline.prepareSpellingQuestion(
|
||||||
|
firebaseUid: uid,
|
||||||
|
questionId: reservedId,
|
||||||
|
);
|
||||||
|
|
||||||
|
final Result first = await testDb!.connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
SELECT id, metadata
|
||||||
|
FROM questions
|
||||||
|
WHERE assigned_user_id = @uid
|
||||||
|
ORDER BY created_at
|
||||||
|
LIMIT 1
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'uid': uid},
|
||||||
|
);
|
||||||
|
final String questionId = first.first[0]!.toString();
|
||||||
|
final Object? metadataRaw = first.first[1];
|
||||||
|
final Map<String, dynamic> metadata = metadataRaw is Map<String, dynamic>
|
||||||
|
? metadataRaw
|
||||||
|
: Map<String, dynamic>.from(
|
||||||
|
jsonDecode(metadataRaw.toString()) as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
final String canonicalWord = metadata['canonical_word']! as String;
|
||||||
|
|
||||||
|
final Map<String, dynamic>? answered = await testDb!.questionsDb.submitAnswer(
|
||||||
|
questionId: questionId,
|
||||||
|
assignedUserId: uid,
|
||||||
|
userResponseText: canonicalWord,
|
||||||
|
);
|
||||||
|
expect(answered, isNotNull);
|
||||||
|
|
||||||
|
await pipeline.onAnswerSubmitted(
|
||||||
|
firebaseUid: uid,
|
||||||
|
answeredQuestion: answered!,
|
||||||
|
userResponse: answered['userResponse'] as num? ?? 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
final Result rows = await testDb!.connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
SELECT pipeline_step, source_tag
|
||||||
|
FROM questions
|
||||||
|
WHERE assigned_user_id = @uid
|
||||||
|
ORDER BY created_at
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{'uid': uid},
|
||||||
|
);
|
||||||
|
expect(rows.length, greaterThanOrEqualTo(2));
|
||||||
|
expect(rows.last[0], PipelineSteps.spellingFollowUp);
|
||||||
|
expect(rows.last[1], 'spelling:follow_up');
|
||||||
|
});
|
||||||
|
}
|
||||||
64
server/test/integration/spelling_words_api_test.dart
Normal file
64
server/test/integration/spelling_words_api_test.dart
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
@Tags(<String>['integration', 'postgres'])
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:cyberhybridhub_server/spelling/spelling_items_db.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
import '../helpers/test_db.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestDb? testDb;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
testDb = await TestDb.open();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
if (testDb != null) {
|
||||||
|
await testDb!.truncateTradingTables();
|
||||||
|
await testDb!.connection.execute('DELETE FROM spelling_items');
|
||||||
|
await testDb!.connection.execute('DELETE FROM spelling_words');
|
||||||
|
await testDb!.spellingItemsDb.ensureCatalogSeeded();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDownAll(() async {
|
||||||
|
await testDb?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addCanonicalWord and listCanonicalWords', () async {
|
||||||
|
if (testDb == null) {
|
||||||
|
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final SpellingItemsDb db = testDb!.spellingItemsDb;
|
||||||
|
final bool added = await db.addCanonicalWord('customword');
|
||||||
|
expect(added, isTrue);
|
||||||
|
|
||||||
|
final bool duplicate = await db.addCanonicalWord('customword');
|
||||||
|
expect(duplicate, isFalse);
|
||||||
|
|
||||||
|
final List<String> words = await db.listCanonicalWords();
|
||||||
|
expect(words, contains('customword'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('importCanonicalWords adds and skips duplicates', () async {
|
||||||
|
if (testDb == null) {
|
||||||
|
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final SpellingItemsDb db = testDb!.spellingItemsDb;
|
||||||
|
final ({int added, int skipped}) result = await db.importCanonicalWords(
|
||||||
|
<String>['alpha', 'beta', 'alpha', '', ' '],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.added, 2);
|
||||||
|
expect(result.skipped, 1);
|
||||||
|
|
||||||
|
final List<String> words = await db.listCanonicalWords();
|
||||||
|
expect(words, contains('alpha'));
|
||||||
|
expect(words, contains('beta'));
|
||||||
|
});
|
||||||
|
}
|
||||||
17
server/test/pipeline/spelling_word_list_test.dart
Normal file
17
server/test/pipeline/spelling_word_list_test.dart
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import 'package:cyberhybridhub_server/pipeline/spelling_word_list.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('randomChallenge returns a word from the curated list', () {
|
||||||
|
final SpellingChallenge challenge =
|
||||||
|
SpellingWordList.randomChallenge();
|
||||||
|
|
||||||
|
final bool known = SpellingWordList.pairs.any(
|
||||||
|
(({String correct, String misspelled}) pair) =>
|
||||||
|
challenge.displayedWord == pair.correct ||
|
||||||
|
challenge.displayedWord == pair.misspelled,
|
||||||
|
);
|
||||||
|
expect(known, isTrue);
|
||||||
|
expect(challenge.canonicalWord, isNotEmpty);
|
||||||
|
});
|
||||||
|
}
|
||||||
121
server/test/question_dispatcher_test.dart
Normal file
121
server/test/question_dispatcher_test.dart
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:cyberhybridhub_server/question_source_preferences.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('QuestionSourcePreferences.effectiveMix', () {
|
||||||
|
test('matches client-style normalization for enabled sources', () {
|
||||||
|
final List<ResolvedQuestionSourcePreference> prefs =
|
||||||
|
QuestionSourcePreferences.mergeStored(<Map<String, dynamic>>[
|
||||||
|
<String, dynamic>{'id': 'market_history', 'enabled': true, 'ratio': 70},
|
||||||
|
<String, dynamic>{'id': 'spelling', 'enabled': true, 'ratio': 30},
|
||||||
|
]);
|
||||||
|
|
||||||
|
final Map<String, int> mix = QuestionSourcePreferences.effectiveMix(prefs);
|
||||||
|
|
||||||
|
expect(mix['market_history'], 70);
|
||||||
|
expect(mix['spelling'], 30);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('excludes disabled sources', () {
|
||||||
|
final List<ResolvedQuestionSourcePreference> prefs =
|
||||||
|
QuestionSourcePreferences.mergeStored(<Map<String, dynamic>>[
|
||||||
|
<String, dynamic>{'id': 'market_history', 'enabled': true, 'ratio': 50},
|
||||||
|
<String, dynamic>{'id': 'spelling', 'enabled': false, 'ratio': 50},
|
||||||
|
]);
|
||||||
|
|
||||||
|
final Map<String, int> mix = QuestionSourcePreferences.effectiveMix(prefs);
|
||||||
|
|
||||||
|
expect(mix.keys, <String>{'market_history'});
|
||||||
|
expect(mix['market_history'], 100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('_sourceTryOrder (via dispatcher selection)', () {
|
||||||
|
test('weighted random favors configured ratio over many rolls', () {
|
||||||
|
final List<ResolvedQuestionSourcePreference> prefs =
|
||||||
|
QuestionSourcePreferences.mergeStored(<Map<String, dynamic>>[
|
||||||
|
<String, dynamic>{'id': 'market_history', 'enabled': true, 'ratio': 90},
|
||||||
|
<String, dynamic>{'id': 'spelling', 'enabled': true, 'ratio': 10},
|
||||||
|
]);
|
||||||
|
|
||||||
|
final Random random = Random(42);
|
||||||
|
var marketFirst = 0;
|
||||||
|
for (var i = 0; i < 200; i++) {
|
||||||
|
final List<String> order = _sourceTryOrderForTest(prefs, random);
|
||||||
|
if (order.first == 'market_history') {
|
||||||
|
marketFirst++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(marketFirst, greaterThan(140));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only enabled source is always first', () {
|
||||||
|
final List<ResolvedQuestionSourcePreference> prefs =
|
||||||
|
QuestionSourcePreferences.mergeStored(<Map<String, dynamic>>[
|
||||||
|
<String, dynamic>{'id': 'market_history', 'enabled': false, 'ratio': 0},
|
||||||
|
<String, dynamic>{'id': 'spelling', 'enabled': true, 'ratio': 100},
|
||||||
|
]);
|
||||||
|
|
||||||
|
final List<String> order =
|
||||||
|
_sourceTryOrderForTest(prefs, Random(1));
|
||||||
|
|
||||||
|
expect(order, <String>['spelling']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirrors [QuestionDispatcher._sourceTryOrder] for deterministic unit tests.
|
||||||
|
List<String> _sourceTryOrderForTest(
|
||||||
|
List<ResolvedQuestionSourcePreference> prefs,
|
||||||
|
Random random,
|
||||||
|
) {
|
||||||
|
final List<ResolvedQuestionSourcePreference> enabled = prefs
|
||||||
|
.where(
|
||||||
|
(ResolvedQuestionSourcePreference s) => s.enabled && !s.comingSoon,
|
||||||
|
)
|
||||||
|
.toList(growable: false);
|
||||||
|
if (enabled.isEmpty) {
|
||||||
|
return <String>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
final int totalWeight = enabled.fold<int>(
|
||||||
|
0,
|
||||||
|
(int sum, ResolvedQuestionSourcePreference s) => sum + s.ratio,
|
||||||
|
);
|
||||||
|
if (totalWeight <= 0) {
|
||||||
|
final List<String> ids =
|
||||||
|
enabled.map((ResolvedQuestionSourcePreference s) => s.id).toList();
|
||||||
|
ids.shuffle(random);
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
var roll = random.nextInt(totalWeight);
|
||||||
|
String? primary;
|
||||||
|
for (final ResolvedQuestionSourcePreference source in enabled) {
|
||||||
|
roll -= source.ratio;
|
||||||
|
if (roll < 0) {
|
||||||
|
primary = source.id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
primary ??= enabled.first.id;
|
||||||
|
|
||||||
|
final List<ResolvedQuestionSourcePreference> rest = enabled
|
||||||
|
.where((ResolvedQuestionSourcePreference s) => s.id != primary)
|
||||||
|
.toList()
|
||||||
|
..sort(
|
||||||
|
(
|
||||||
|
ResolvedQuestionSourcePreference a,
|
||||||
|
ResolvedQuestionSourcePreference b,
|
||||||
|
) =>
|
||||||
|
b.ratio.compareTo(a.ratio),
|
||||||
|
);
|
||||||
|
|
||||||
|
return <String>[
|
||||||
|
primary,
|
||||||
|
...rest.map((ResolvedQuestionSourcePreference s) => s.id),
|
||||||
|
];
|
||||||
|
}
|
||||||
89
server/test/question_source_mix_test.dart
Normal file
89
server/test/question_source_mix_test.dart
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import 'package:cyberhybridhub_server/question_source_mix.dart';
|
||||||
|
import 'package:cyberhybridhub_server/questions_db.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('QuestionSourceMix.pickNextSourceId', () {
|
||||||
|
test('picks highest share when nothing delivered yet', () {
|
||||||
|
expect(
|
||||||
|
QuestionSourceMix.pickNextSourceId(
|
||||||
|
effectiveMixPercent: <String, int>{
|
||||||
|
'market_history': 70,
|
||||||
|
'spelling': 30,
|
||||||
|
},
|
||||||
|
deliveredCounts: <String, int>{},
|
||||||
|
),
|
||||||
|
'market_history',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('picks spelling when market is ahead of target mix', () {
|
||||||
|
expect(
|
||||||
|
QuestionSourceMix.pickNextSourceId(
|
||||||
|
effectiveMixPercent: <String, int>{
|
||||||
|
'market_history': 50,
|
||||||
|
'spelling': 50,
|
||||||
|
},
|
||||||
|
deliveredCounts: <String, int>{
|
||||||
|
'market_history': 4,
|
||||||
|
'spelling': 0,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
'spelling',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('picks market when spelling is ahead of target mix', () {
|
||||||
|
expect(
|
||||||
|
QuestionSourceMix.pickNextSourceId(
|
||||||
|
effectiveMixPercent: <String, int>{
|
||||||
|
'market_history': 70,
|
||||||
|
'spelling': 30,
|
||||||
|
},
|
||||||
|
deliveredCounts: <String, int>{
|
||||||
|
'market_history': 1,
|
||||||
|
'spelling': 3,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
'market_history',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('QuestionSourceMix.orderUnansweredByPreferredSource', () {
|
||||||
|
test('puts preferred source first preserving FIFO within source', () {
|
||||||
|
final List<Map<String, dynamic>> ordered =
|
||||||
|
QuestionSourceMix.orderUnansweredByPreferredSource(
|
||||||
|
<Map<String, dynamic>>[
|
||||||
|
<String, dynamic>{
|
||||||
|
'id': 'm1',
|
||||||
|
'sourceTag': 'market_history:prospective',
|
||||||
|
'createdAt': '2026-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
<String, dynamic>{
|
||||||
|
'id': 's1',
|
||||||
|
'sourceTag': 'spelling:word_quiz',
|
||||||
|
'pipelineKey': 'spelling',
|
||||||
|
'createdAt': '2026-01-02T00:00:00Z',
|
||||||
|
},
|
||||||
|
<String, dynamic>{
|
||||||
|
'id': 'm2',
|
||||||
|
'sourceTag': 'market_history:prospective',
|
||||||
|
'createdAt': '2026-01-03T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
preferredSourceId: 'spelling',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ordered.map((Map<String, dynamic> r) => r['id']).toList(), <String>[
|
||||||
|
's1',
|
||||||
|
'm1',
|
||||||
|
'm2',
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
QuestionsDb.resolveSourceId(ordered.first),
|
||||||
|
'spelling',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
78
server/test/question_source_preferences_test.dart
Normal file
78
server/test/question_source_preferences_test.dart
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import 'package:cyberhybridhub_server/question_source_preferences.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('mergeStored applies defaults for market and spelling sources', () {
|
||||||
|
final List<ResolvedQuestionSourcePreference> merged =
|
||||||
|
QuestionSourcePreferences.mergeStored(null);
|
||||||
|
|
||||||
|
expect(merged.map((ResolvedQuestionSourcePreference s) => s.id), <String>[
|
||||||
|
'market_history',
|
||||||
|
'spelling',
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
merged.firstWhere((ResolvedQuestionSourcePreference s) => s.id == 'market_history').enabled,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
merged.firstWhere((ResolvedQuestionSourcePreference s) => s.id == 'spelling').enabled,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parsePutBody rejects all disabled sources', () {
|
||||||
|
expect(
|
||||||
|
() => QuestionSourcePreferences.parsePutBody(<String, dynamic>{
|
||||||
|
'sources': <Map<String, dynamic>>[
|
||||||
|
<String, dynamic>{'id': 'market_history', 'enabled': false, 'ratio': 50},
|
||||||
|
<String, dynamic>{'id': 'spelling', 'enabled': false, 'ratio': 50},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
throwsA(isA<QuestionSourceValidationException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parsePutBody accepts enabled spelling source', () {
|
||||||
|
final List<Map<String, dynamic>> stored =
|
||||||
|
QuestionSourcePreferences.parsePutBody(<String, dynamic>{
|
||||||
|
'sources': <Map<String, dynamic>>[
|
||||||
|
<String, dynamic>{'id': 'market_history', 'enabled': true, 'ratio': 50},
|
||||||
|
<String, dynamic>{'id': 'spelling', 'enabled': true, 'ratio': 50},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stored.length, 2);
|
||||||
|
expect(
|
||||||
|
stored.firstWhere((Map<String, dynamic> row) => row['id'] == 'spelling')['enabled'],
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('effectiveMix normalizes enabled source ratios', () {
|
||||||
|
final List<ResolvedQuestionSourcePreference> resolved =
|
||||||
|
QuestionSourcePreferences.mergeStored(<Map<String, dynamic>>[
|
||||||
|
<String, dynamic>{'id': 'market_history', 'enabled': true, 'ratio': 75},
|
||||||
|
<String, dynamic>{'id': 'spelling', 'enabled': true, 'ratio': 25},
|
||||||
|
]);
|
||||||
|
|
||||||
|
final Map<String, int> mix =
|
||||||
|
QuestionSourcePreferences.effectiveMix(resolved);
|
||||||
|
|
||||||
|
expect(mix['market_history'], 75);
|
||||||
|
expect(mix['spelling'], 25);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isEnabled returns false for disabled spelling', () {
|
||||||
|
final List<ResolvedQuestionSourcePreference> resolved =
|
||||||
|
QuestionSourcePreferences.mergeStored(<Map<String, dynamic>>[
|
||||||
|
<String, dynamic>{'id': 'market_history', 'enabled': true, 'ratio': 100},
|
||||||
|
<String, dynamic>{'id': 'spelling', 'enabled': false, 'ratio': 0},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
QuestionSourcePreferences.isEnabled(resolved, 'market_history'),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(QuestionSourcePreferences.isEnabled(resolved, 'spelling'), isFalse);
|
||||||
|
});
|
||||||
|
}
|
||||||
55
server/test/questions_client_payload_test.dart
Normal file
55
server/test/questions_client_payload_test.dart
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import 'package:cyberhybridhub_server/questions_db.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('QuestionsDb client presentation fields', () {
|
||||||
|
test('market_history prospective maps sourceId and presentation', () {
|
||||||
|
final Map<String, dynamic> question = <String, dynamic>{
|
||||||
|
'sourceTag': 'market_history:prospective',
|
||||||
|
'pipelineKey': 'trading',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(QuestionsDb.resolveSourceId(question), 'market_history');
|
||||||
|
expect(QuestionsDb.resolvePresentation(question), 'market_glyph_slider');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('spelling word quiz maps sourceId and presentation', () {
|
||||||
|
final Map<String, dynamic> question = <String, dynamic>{
|
||||||
|
'sourceTag': 'spelling:word_quiz',
|
||||||
|
'pipelineKey': 'spelling',
|
||||||
|
'pipelineStep': 'spelling_word_quiz',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(QuestionsDb.resolveSourceId(question), 'spelling');
|
||||||
|
expect(QuestionsDb.resolvePresentation(question), 'spelling_word');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('spelling follow-up uses slider presentation', () {
|
||||||
|
final Map<String, dynamic> question = <String, dynamic>{
|
||||||
|
'sourceTag': 'spelling:follow_up',
|
||||||
|
'pipelineKey': 'spelling',
|
||||||
|
'pipelineStep': 'spelling_follow_up',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(QuestionsDb.resolveSourceId(question), 'spelling');
|
||||||
|
expect(QuestionsDb.resolvePresentation(question), 'market_glyph_slider');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('spelling pipeline key without step still maps to spelling_word', () {
|
||||||
|
final Map<String, dynamic> question = <String, dynamic>{
|
||||||
|
'pipelineKey': 'spelling',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(QuestionsDb.resolveSourceId(question), 'spelling');
|
||||||
|
expect(QuestionsDb.resolvePresentation(question), 'spelling_word');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing metadata defaults to market presentation', () {
|
||||||
|
expect(QuestionsDb.resolveSourceId(<String, dynamic>{}), 'market_history');
|
||||||
|
expect(
|
||||||
|
QuestionsDb.resolvePresentation(<String, dynamic>{}),
|
||||||
|
'market_glyph_slider',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
9
server/test/spelling/spelling_answer_policy_test.dart
Normal file
9
server/test/spelling/spelling_answer_policy_test.dart
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import 'package:cyberhybridhub_server/spelling/spelling_answer_policy.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('matches is case insensitive and trims whitespace', () {
|
||||||
|
expect(SpellingAnswerPolicy.matches(' Accommodate ', 'accommodate'), isTrue);
|
||||||
|
expect(SpellingAnswerPolicy.matches('separate', 'rhythm'), isFalse);
|
||||||
|
});
|
||||||
|
}
|
||||||
82
server/test/spelling/spelling_item_selector_test.dart
Normal file
82
server/test/spelling/spelling_item_selector_test.dart
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:cyberhybridhub_server/spelling/spelling_item_record.dart';
|
||||||
|
import 'package:cyberhybridhub_server/spelling/spelling_item_selector.dart';
|
||||||
|
import 'package:cyberhybridhub_server/spelling/spelling_progress_policy.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('mastered item is still selectable over many draws', () {
|
||||||
|
final SpellingItemSelector selector = SpellingItemSelector(random: Random(99));
|
||||||
|
|
||||||
|
const SpellingItemRecord mastered = SpellingItemRecord(
|
||||||
|
id: 'mastered',
|
||||||
|
spellingWordId: 'w1',
|
||||||
|
canonicalWord: 'separate',
|
||||||
|
displayedWord: 'separate',
|
||||||
|
isCorrectlySpelled: true,
|
||||||
|
correctAnswer: 10,
|
||||||
|
familiarityScore: 20,
|
||||||
|
);
|
||||||
|
const SpellingItemRecord fresh = SpellingItemRecord(
|
||||||
|
id: 'fresh',
|
||||||
|
spellingWordId: 'w2',
|
||||||
|
canonicalWord: 'rhythm',
|
||||||
|
displayedWord: 'rythm',
|
||||||
|
isCorrectlySpelled: false,
|
||||||
|
correctAnswer: -10,
|
||||||
|
familiarityScore: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
var masteredHits = 0;
|
||||||
|
for (var i = 0; i < 500; i++) {
|
||||||
|
final SpellingItemRecord? pick = selector.pickFromCandidates(
|
||||||
|
<SpellingItemRecord>[mastered, fresh],
|
||||||
|
);
|
||||||
|
if (pick!.id == mastered.id) {
|
||||||
|
masteredHits++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(masteredHits, greaterThan(0));
|
||||||
|
expect(
|
||||||
|
SpellingProgressPolicy.weightForScore(mastered.familiarityScore),
|
||||||
|
SpellingProgressPolicy.minWeight,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('struggling item is picked more often than mastered peer', () {
|
||||||
|
final SpellingItemSelector selector = SpellingItemSelector(random: Random(7));
|
||||||
|
|
||||||
|
const SpellingItemRecord struggling = SpellingItemRecord(
|
||||||
|
id: 'struggling',
|
||||||
|
spellingWordId: 'w1',
|
||||||
|
canonicalWord: 'separate',
|
||||||
|
displayedWord: 'seperate',
|
||||||
|
isCorrectlySpelled: false,
|
||||||
|
correctAnswer: -10,
|
||||||
|
familiarityScore: -4,
|
||||||
|
);
|
||||||
|
const SpellingItemRecord mastered = SpellingItemRecord(
|
||||||
|
id: 'mastered',
|
||||||
|
spellingWordId: 'w2',
|
||||||
|
canonicalWord: 'rhythm',
|
||||||
|
displayedWord: 'rhythm',
|
||||||
|
isCorrectlySpelled: true,
|
||||||
|
correctAnswer: 10,
|
||||||
|
familiarityScore: 8,
|
||||||
|
);
|
||||||
|
|
||||||
|
var strugglingHits = 0;
|
||||||
|
for (var i = 0; i < 300; i++) {
|
||||||
|
final SpellingItemRecord? pick = selector.pickFromCandidates(
|
||||||
|
<SpellingItemRecord>[struggling, mastered],
|
||||||
|
);
|
||||||
|
if (pick!.id == struggling.id) {
|
||||||
|
strugglingHits++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(strugglingHits, greaterThan(150));
|
||||||
|
});
|
||||||
|
}
|
||||||
39
server/test/spelling/spelling_progress_policy_test.dart
Normal file
39
server/test/spelling/spelling_progress_policy_test.dart
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import 'package:cyberhybridhub_server/spelling/spelling_progress_policy.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('weightForScore never drops below minWeight', () {
|
||||||
|
expect(
|
||||||
|
SpellingProgressPolicy.weightForScore(100),
|
||||||
|
SpellingProgressPolicy.minWeight,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('weightForScore is higher for negative familiarity', () {
|
||||||
|
expect(
|
||||||
|
SpellingProgressPolicy.weightForScore(-3),
|
||||||
|
greaterThan(SpellingProgressPolicy.weightForScore(0)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('weightForScore is lower for positive familiarity', () {
|
||||||
|
expect(
|
||||||
|
SpellingProgressPolicy.weightForScore(5),
|
||||||
|
lessThan(SpellingProgressPolicy.weightForScore(0)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scoreAfterAttempt moves up on correct and down on wrong', () {
|
||||||
|
expect(
|
||||||
|
SpellingProgressPolicy.scoreAfterAttempt(currentScore: 2, correct: true),
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
SpellingProgressPolicy.scoreAfterAttempt(
|
||||||
|
currentScore: 2,
|
||||||
|
correct: false,
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
19
server/test/spelling/spelling_words_catalog_test.dart
Normal file
19
server/test/spelling/spelling_words_catalog_test.dart
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import 'package:cyberhybridhub_server/pipeline/spelling_word_list.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('SpellingWordList helpers', () {
|
||||||
|
test('misspellingFor returns curated typo', () {
|
||||||
|
expect(
|
||||||
|
SpellingWordList.misspellingFor('separate'),
|
||||||
|
'seperate',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generatedMisspelling falls back for unknown words', () {
|
||||||
|
final String typo = SpellingWordList.generatedMisspelling('example');
|
||||||
|
expect(typo, isNot('example'));
|
||||||
|
expect(typo.length, lessThan('example'.length));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -42,7 +42,7 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byKey(const Key('admin-app-bar-action')), findsOneWidget);
|
expect(find.byKey(const Key('admin-app-bar-action')), findsOneWidget);
|
||||||
expect(find.byIcon(Icons.admin_panel_settings_outlined), findsOneWidget);
|
expect(find.byIcon(Icons.admin_panel_settings), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('hides admin icon when user is not on allowlist', (
|
testWidgets('hides admin icon when user is not on allowlist', (
|
||||||
|
|||||||
47
test/models/incoming_question_test.dart
Normal file
47
test/models/incoming_question_test.dart
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import 'package:cyberhybridhub/models/incoming_question.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('IncomingQuestion spelling routing', () {
|
||||||
|
test('pending spelling uses word tile even with slider presentation', () {
|
||||||
|
final IncomingQuestion question = IncomingQuestion(
|
||||||
|
id: 'spell-pending',
|
||||||
|
text: 'Spelling question loading…',
|
||||||
|
sentAt: DateTime.utc(2026, 1, 1),
|
||||||
|
sourceId: 'spelling',
|
||||||
|
presentation: 'market_glyph_slider',
|
||||||
|
needsPrepare: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(question.usesSpellingWordTile, isTrue);
|
||||||
|
expect(question.isSpellingFollowUp, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('spelling follow-up uses slider tile', () {
|
||||||
|
final IncomingQuestion question = IncomingQuestion(
|
||||||
|
id: 'spell-follow',
|
||||||
|
text: "Correct — 'colour' is the proper spelling. Enter +1 for another word.",
|
||||||
|
sentAt: DateTime.utc(2026, 1, 1),
|
||||||
|
sourceId: 'spelling',
|
||||||
|
presentation: 'market_glyph_slider',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(question.isSpellingFollowUp, isTrue);
|
||||||
|
expect(question.usesSpellingWordTile, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prepared spelling with spokenWord uses word tile', () {
|
||||||
|
final IncomingQuestion question = IncomingQuestion(
|
||||||
|
id: 'spell-ready',
|
||||||
|
text: 'Spell the word you hear.',
|
||||||
|
sentAt: DateTime.utc(2026, 1, 1),
|
||||||
|
sourceId: 'spelling',
|
||||||
|
presentation: 'market_glyph_slider',
|
||||||
|
spokenWord: 'colour',
|
||||||
|
audioTitle: 'spell-ready',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(question.usesSpellingWordTile, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
139
test/question_source_settings_test.dart
Normal file
139
test/question_source_settings_test.dart
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import 'package:cyberhybridhub/models/app_user.dart';
|
||||||
|
import 'package:cyberhybridhub/repositories/question_source_config_repository.dart';
|
||||||
|
import 'package:cyberhybridhub/screens/home_screen.dart';
|
||||||
|
import 'package:cyberhybridhub/widgets/question_source_settings_sheet.dart';
|
||||||
|
import 'package:cyberhybridhub/services/questions_hub_service.dart';
|
||||||
|
import 'package:cyberhybridhub/theme/app_theme.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUp(() {
|
||||||
|
QuestionSourceConfigRepository.instance.resetToDefaults();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('gear icon opens question source settings dialog', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
|
final QuestionsHubService hub = QuestionsHubService.instance;
|
||||||
|
hub.hasPendingQuestion.value = false;
|
||||||
|
hub.pendingQuestionCount.value = 0;
|
||||||
|
|
||||||
|
addTearDown(() {
|
||||||
|
hub.hasPendingQuestion.value = false;
|
||||||
|
hub.pendingQuestionCount.value = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: const HomeScreen(
|
||||||
|
user: AppUser(uid: 'u1', displayName: 'Test User'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('question-source-settings-button')), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('question-source-settings-button')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('question-source-settings-dialog')), findsOneWidget);
|
||||||
|
expect(find.text('Question sources'), findsOneWidget);
|
||||||
|
expect(find.text('Market question'), findsOneWidget);
|
||||||
|
expect(find.text('Spelling'), findsOneWidget);
|
||||||
|
expect(find.text('Coming soon'), findsNothing);
|
||||||
|
expect(find.text('Geography'), findsNothing);
|
||||||
|
expect(find.text('Weather'), findsNothing);
|
||||||
|
expect(find.text('Trading'), findsNothing);
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('question-source-enabled-spelling')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
final Switch spellingSwitch = tester.widget<Switch>(
|
||||||
|
find.byKey(const Key('question-source-enabled-spelling')),
|
||||||
|
);
|
||||||
|
expect(spellingSwitch.onChanged, isNotNull);
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('question-source-ratio-spelling')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('question-source-edit-spelling')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('spelling edit opens spelling word list screen', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
onGenerateRoute: (RouteSettings settings) {
|
||||||
|
if (settings.name == '/spelling-words') {
|
||||||
|
return MaterialPageRoute<void>(
|
||||||
|
settings: settings,
|
||||||
|
builder: (_) => const Scaffold(
|
||||||
|
body: Center(child: Text('Spelling words page')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
home: Builder(
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: () => QuestionSourceSettingsSheet.show(context),
|
||||||
|
child: const Text('Open'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Open'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('question-source-edit-spelling')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Spelling words page'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('saving question source settings updates repository', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: const HomeScreen(
|
||||||
|
user: AppUser(uid: 'u1', displayName: 'Test User'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('question-source-settings-button')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.drag(
|
||||||
|
find.byKey(const Key('question-source-ratio-market_history')),
|
||||||
|
const Offset(80, 0),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('question-source-settings-save')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
QuestionSourceConfigRepository.instance.current
|
||||||
|
.firstWhere((source) => source.id == 'market_history')
|
||||||
|
.ratio,
|
||||||
|
isNot(50),
|
||||||
|
);
|
||||||
|
expect(find.byKey(const Key('question-source-settings-dialog')), findsNothing);
|
||||||
|
});
|
||||||
|
}
|
||||||
137
test/repositories/question_source_config_repository_test.dart
Normal file
137
test/repositories/question_source_config_repository_test.dart
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import 'package:cyberhybridhub/models/question_source_preference.dart';
|
||||||
|
import 'package:cyberhybridhub/repositories/question_source_config_repository.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUp(() {
|
||||||
|
QuestionSourceConfigRepository.instance.resetToDefaults();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mergeWithDefaults adds new sources from defaults', () {
|
||||||
|
const List<QuestionSourcePreference> saved = <QuestionSourcePreference>[
|
||||||
|
QuestionSourcePreference(
|
||||||
|
id: 'market_history',
|
||||||
|
label: 'Market question',
|
||||||
|
description: 'Prospective price-move guessing game',
|
||||||
|
enabled: true,
|
||||||
|
ratio: 100,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
final List<QuestionSourcePreference> merged =
|
||||||
|
QuestionSourceConfigRepository.mergeWithDefaults(saved);
|
||||||
|
|
||||||
|
expect(merged.map((QuestionSourcePreference s) => s.id), <String>[
|
||||||
|
'market_history',
|
||||||
|
'spelling',
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
merged.firstWhere((QuestionSourcePreference s) => s.id == 'market_history').ratio,
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
merged.firstWhere((QuestionSourcePreference s) => s.id == 'spelling').comingSoon,
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mergeWithDefaults drops removed legacy sources', () {
|
||||||
|
const List<QuestionSourcePreference> saved = <QuestionSourcePreference>[
|
||||||
|
QuestionSourcePreference(
|
||||||
|
id: 'geography',
|
||||||
|
label: 'Geography',
|
||||||
|
description: 'Legacy',
|
||||||
|
ratio: 25,
|
||||||
|
),
|
||||||
|
QuestionSourcePreference(
|
||||||
|
id: 'market_history',
|
||||||
|
label: 'Market question',
|
||||||
|
description: 'Prospective price-move guessing game',
|
||||||
|
ratio: 75,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
final List<QuestionSourcePreference> merged =
|
||||||
|
QuestionSourceConfigRepository.mergeWithDefaults(saved);
|
||||||
|
|
||||||
|
expect(merged.map((QuestionSourcePreference s) => s.id), <String>[
|
||||||
|
'market_history',
|
||||||
|
'spelling',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mergeWithDefaults refreshes labels and disables comingSoon sources', () {
|
||||||
|
const List<QuestionSourcePreference> saved = <QuestionSourcePreference>[
|
||||||
|
QuestionSourcePreference(
|
||||||
|
id: 'market_history',
|
||||||
|
label: 'Market history',
|
||||||
|
description: 'Old description',
|
||||||
|
ratio: 30,
|
||||||
|
),
|
||||||
|
QuestionSourcePreference(
|
||||||
|
id: 'spelling',
|
||||||
|
label: 'Spelling',
|
||||||
|
description: 'Old description',
|
||||||
|
enabled: true,
|
||||||
|
comingSoon: false,
|
||||||
|
ratio: 70,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
final List<QuestionSourcePreference> merged =
|
||||||
|
QuestionSourceConfigRepository.mergeWithDefaults(saved);
|
||||||
|
|
||||||
|
final QuestionSourcePreference market = merged.firstWhere(
|
||||||
|
(QuestionSourcePreference s) => s.id == 'market_history',
|
||||||
|
);
|
||||||
|
expect(market.label, 'Market question');
|
||||||
|
expect(market.description, 'Prospective price-move guessing game');
|
||||||
|
|
||||||
|
final QuestionSourcePreference spelling = merged.firstWhere(
|
||||||
|
(QuestionSourcePreference s) => s.id == 'spelling',
|
||||||
|
);
|
||||||
|
expect(spelling.comingSoon, isFalse);
|
||||||
|
expect(spelling.enabled, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('effectiveMix excludes disabled and comingSoon sources', () {
|
||||||
|
final List<QuestionSourcePreference> sources =
|
||||||
|
QuestionSourceConfigRepository.defaultPreferences;
|
||||||
|
|
||||||
|
final Map<String, int> mix =
|
||||||
|
QuestionSourceConfigRepository.effectiveMix(sources);
|
||||||
|
|
||||||
|
expect(mix.containsKey('spelling'), isTrue);
|
||||||
|
expect(mix.length, 2);
|
||||||
|
expect(mix['market_history'], 50);
|
||||||
|
expect(mix['spelling'], 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saveAll without active session updates in-memory preferences only', () async {
|
||||||
|
await QuestionSourceConfigRepository.instance.saveAll(
|
||||||
|
<QuestionSourcePreference>[
|
||||||
|
const QuestionSourcePreference(
|
||||||
|
id: 'market_history',
|
||||||
|
label: 'Market question',
|
||||||
|
description: 'Prospective price-move guessing game',
|
||||||
|
enabled: true,
|
||||||
|
ratio: 75,
|
||||||
|
),
|
||||||
|
const QuestionSourcePreference(
|
||||||
|
id: 'spelling',
|
||||||
|
label: 'Spelling',
|
||||||
|
description: 'Spell-check quiz from curated word lists',
|
||||||
|
enabled: true,
|
||||||
|
ratio: 25,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
QuestionSourceConfigRepository.instance.current
|
||||||
|
.firstWhere((QuestionSourcePreference s) => s.id == 'market_history')
|
||||||
|
.ratio,
|
||||||
|
75,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
29
test/utils/spelling_spoken_word_test.dart
Normal file
29
test/utils/spelling_spoken_word_test.dart
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import 'package:cyberhybridhub/utils/spelling_spoken_word.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('spellingWordToSpeak extracts quoted word', () {
|
||||||
|
expect(
|
||||||
|
spellingWordToSpeak(
|
||||||
|
"Is 'colour' spelled correctly? Swipe toward +10 for yes.",
|
||||||
|
),
|
||||||
|
'colour',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('spellingWordToSpeak handles double quotes', () {
|
||||||
|
expect(
|
||||||
|
spellingWordToSpeak('Is "separate" spelled correctly?'),
|
||||||
|
'separate',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('spellingWordToSpeak returns null for generic test text', () {
|
||||||
|
expect(
|
||||||
|
spellingWordToSpeak(
|
||||||
|
'Starter question: enter a whole number between -10 and 10.',
|
||||||
|
),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
133
test/widgets/question_presentation_router_test.dart
Normal file
133
test/widgets/question_presentation_router_test.dart
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import 'package:cyberhybridhub/models/incoming_question.dart';
|
||||||
|
import 'package:cyberhybridhub/widgets/question_presentation_router.dart';
|
||||||
|
import 'package:cyberhybridhub/widgets/spelling_question_tile.dart';
|
||||||
|
import 'package:cyberhybridhub/widgets/swipe_question_tile.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
Future<IncomingQuestion?> _mockPrepare(String questionId) async {
|
||||||
|
return IncomingQuestion(
|
||||||
|
id: questionId,
|
||||||
|
text: 'Spell the word you hear.',
|
||||||
|
sentAt: DateTime.utc(2026, 1, 1),
|
||||||
|
sourceId: 'spelling',
|
||||||
|
presentation: 'spelling_word',
|
||||||
|
spokenWord: 'colour',
|
||||||
|
audioTitle: questionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('renders SwipeQuestionTile for market_history', (tester) async {
|
||||||
|
final IncomingQuestion question = IncomingQuestion(
|
||||||
|
id: 'market-1',
|
||||||
|
text: 'Will price move up?',
|
||||||
|
sentAt: DateTime.utc(2026, 1, 1),
|
||||||
|
sourceId: 'market_history',
|
||||||
|
presentation: 'market_glyph_slider',
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: QuestionPresentationRouter(
|
||||||
|
question: question,
|
||||||
|
onAnswer: (_) async {},
|
||||||
|
onDefer: () async {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byType(SwipeQuestionTile), findsOneWidget);
|
||||||
|
expect(find.byType(SpellingQuestionTile), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders SwipeQuestionTile for spelling follow-up', (tester) async {
|
||||||
|
final IncomingQuestion question = IncomingQuestion(
|
||||||
|
id: 'spell-follow-1',
|
||||||
|
text: "Correct — 'colour' is the proper spelling. Enter +1 for another word.",
|
||||||
|
sentAt: DateTime.utc(2026, 1, 1),
|
||||||
|
sourceId: 'spelling',
|
||||||
|
presentation: 'market_glyph_slider',
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: QuestionPresentationRouter(
|
||||||
|
question: question,
|
||||||
|
onAnswer: (_) async {},
|
||||||
|
onDefer: () async {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byType(SwipeQuestionTile), findsOneWidget);
|
||||||
|
expect(find.byType(SpellingQuestionTile), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders SpellingQuestionTile for spelling', (tester) async {
|
||||||
|
final IncomingQuestion question = IncomingQuestion(
|
||||||
|
id: 'spell-1',
|
||||||
|
text: 'Spell the word you hear.',
|
||||||
|
sentAt: DateTime.utc(2026, 1, 1),
|
||||||
|
sourceId: 'spelling',
|
||||||
|
presentation: 'spelling_word',
|
||||||
|
spokenWord: 'colour',
|
||||||
|
audioTitle: 'spell-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: QuestionPresentationRouter(
|
||||||
|
question: question,
|
||||||
|
onAnswer: (_) async {},
|
||||||
|
onAnswerText: (_) async {},
|
||||||
|
onDefer: () async {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byType(SpellingQuestionTile), findsOneWidget);
|
||||||
|
expect(find.byType(SwipeQuestionTile), findsNothing);
|
||||||
|
expect(find.byKey(const Key('spelling-speak-word')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('spelling-answer-field')), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('spelling tile shows speaker and text field after prepare',
|
||||||
|
(tester) async {
|
||||||
|
final IncomingQuestion question = IncomingQuestion(
|
||||||
|
id: 'spell-1',
|
||||||
|
text: 'Spelling question loading…',
|
||||||
|
sentAt: DateTime.utc(2026, 1, 1),
|
||||||
|
sourceId: 'spelling',
|
||||||
|
presentation: 'spelling_word',
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: SpellingQuestionTile(
|
||||||
|
questionId: question.id,
|
||||||
|
questionText: question.text,
|
||||||
|
needsPrepare: true,
|
||||||
|
prepareQuestion: _mockPrepare,
|
||||||
|
onSubmitAnswer: (_) async {},
|
||||||
|
onDefer: () async {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('spelling-speak-word')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('spelling-answer-field')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('spelling-submit-answer')), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
24
test/widgets/spelling_question_placeholder_test.dart
Normal file
24
test/widgets/spelling_question_placeholder_test.dart
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import 'package:cyberhybridhub/widgets/spelling_question_placeholder.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('SpellingQuestionPlaceholder shows retry', (tester) async {
|
||||||
|
var retried = false;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: SpellingQuestionPlaceholder(
|
||||||
|
onRetry: () => retried = true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('No spelling question ready'), findsOneWidget);
|
||||||
|
await tester.tap(find.byKey(const Key('spelling-placeholder-retry')));
|
||||||
|
await tester.pump();
|
||||||
|
expect(retried, isTrue);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@
|
|||||||
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
|
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
|
||||||
#include <firebase_auth/firebase_auth_plugin_c_api.h>
|
#include <firebase_auth/firebase_auth_plugin_c_api.h>
|
||||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||||
|
#include <flutter_tts/flutter_tts_plugin.h>
|
||||||
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
|
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
|
||||||
#include <url_launcher_windows/url_launcher_windows.h>
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
|
|
||||||
@ -19,6 +20,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
|||||||
registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi"));
|
registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi"));
|
||||||
FirebaseCorePluginCApiRegisterWithRegistrar(
|
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||||
|
FlutterTtsPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("FlutterTtsPlugin"));
|
||||||
Sqlite3FlutterLibsPluginRegisterWithRegistrar(
|
Sqlite3FlutterLibsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin"));
|
registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin"));
|
||||||
UrlLauncherWindowsRegisterWithRegistrar(
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
|
|||||||
@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
connectivity_plus
|
connectivity_plus
|
||||||
firebase_auth
|
firebase_auth
|
||||||
firebase_core
|
firebase_core
|
||||||
|
flutter_tts
|
||||||
sqlite3_flutter_libs
|
sqlite3_flutter_libs
|
||||||
url_launcher_windows
|
url_launcher_windows
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user