spelling
Some checks failed
Admin portal tests / test (push) Has been cancelled

This commit is contained in:
nathanAndrinoid 2026-06-05 03:22:35 +00:00
parent 1f83f6c9f7
commit 4459852f83
82 changed files with 6745 additions and 264 deletions

File diff suppressed because one or more lines are too long

View File

@ -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),
); );
}, },
); );

View 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';
}

View 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',
};
}
}

View 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',
};
}
}

View File

@ -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;
}, },
); );

View File

@ -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,
); );
} }
} }

View 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);
}

View 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(),
};
}
}

View File

@ -0,0 +1,4 @@
/// Named routes for non-admin screens.
abstract final class AppRoutes {
static const String spellingWordList = '/spelling-words';
}

View File

@ -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>[
if (showMarketScore)
_CumulativeScoreChip( _CumulativeScoreChip(
summary: score, summary: score,
onPressed: () => _showGuessScoreStatsDialog(context, score), onPressed: () =>
_showGuessScoreStatsDialog(context, score),
), ),
if (hasPending && count >= 1) ...<Widget>[ 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);

View 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);
}
}

View File

@ -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) {

View File

@ -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;
} }
if (question.isMarketHistory) {
guessScoreSummary.value = result.score; 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) {
if (_shouldKeepPanelOpenWithoutQuestion()) {
pendingQuestion.value = null;
questionQueue.value = <IncomingQuestion>[];
hasPendingQuestion.value = true;
questionPanelOpen.value = true;
} else {
_clearPendingUi(); _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) {
if (_shouldKeepPanelOpenWithoutQuestion()) {
pendingQuestion.value = null;
questionQueue.value = <IncomingQuestion>[];
hasPendingQuestion.value = true;
questionPanelOpen.value = true;
} else {
_clearPendingUi(); _clearPendingUi();
}
return; return;
} }

View 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).
}
}
}

View File

@ -0,0 +1,5 @@
/// Platform TTS backend for spelling words.
abstract class SpellingTtsBackend {
Future<void> speak(String text, {String? title});
Future<void> stop();
}

View 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 (_) {}
}
}

View 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();
}
}

View 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;
}

View 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,
),
),
),
),
],
);
}
}

View 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'),
),
],
],
),
),
),
);
}
}

View File

@ -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();
} }

View 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,
);
}
}

View 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())),
),
],
],
),
),
),
);
}
}

View 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'),
),
],
],
),
),
),
);
}
}

View 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'),
),
],
),
);
}
}

View File

@ -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,6 +727,7 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
), ),
), ),
), ),
if (widget.showGuidGlyph)
Positioned( Positioned(
top: 8, top: 8,
bottom: 8, bottom: 8,

View File

@ -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"))

View File

@ -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:

View File

@ -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:

View File

@ -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

View File

@ -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)"

View File

@ -43,13 +43,13 @@ 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 |
@ -64,12 +64,13 @@ trading tables between cases. Optional override: `TEST_DATABASE_URL`.
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,12 +113,13 @@ 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` |
@ -125,7 +130,7 @@ 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 |
@ -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,42 +168,56 @@ 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 |
@ -206,7 +225,7 @@ Requires `TRADING_ENABLED=true` when `MARKET_HISTORY_SYNC_ENABLED=true`.
| `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 |
@ -220,9 +239,9 @@ 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
@ -231,15 +250,21 @@ 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

View File

@ -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);
}); });

View 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',
},
);
}

View File

@ -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 {
final Map<String, dynamic>? question =
await questionService.bootstrapOnLogin(firebaseUid); await questionService.bootstrapOnLogin(firebaseUid);
final Map<String, dynamic>? question =
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) { if (nextQuestion != null) {
unansweredCount = unansweredCount =
(nextQuestion['unansweredCount'] as num?)?.toInt() ?? (nextQuestion['unansweredCount'] as num?)?.toInt() ??
await questionsDb.countUnansweredQuestions(firebaseUid); unansweredCount;
} }
} else { final bool isMarketAnswer =
final Map<String, dynamic>? nextRow = QuestionsDb.resolveSourceId(updated) == 'market_history';
await questionsDb.findUnansweredQuestion(firebaseUid); final Map<String, dynamic>? score = isMarketAnswer
if (nextRow != null) { ? await questionsDb.getGuessScoreSummary(firebaseUid)
nextQuestion = questionsDb.toClientPayload( : null;
nextRow,
unansweredCount: unansweredCount,
);
}
}
final Map<String, dynamic> score =
await questionsDb.getGuessScoreSummary(firebaseUid);
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 {

View 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',
},
);
}

View File

@ -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);
} }
} }

View 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,
);
}
}

View 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,
);
}
}

View File

@ -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,

View 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];
}
}

View 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(),
};
}
}

View 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;
}
}

View File

@ -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);
if (resolveSourceId(updated) == 'market_history') {
await _recordProspectiveAnswer(updated); await _recordProspectiveAnswer(updated);
await ProspectiveGuessAssignmentsDb(_connection).markAnsweredByQuestionId( await ProspectiveGuessAssignmentsDb(_connection).markAnsweredByQuestionId(
questionId: questionId, questionId: questionId,
answeredAt: now, 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(),

View 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();
}
}

View 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,
};
}
}

View 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;
}
}

View 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');
}
}

View 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;
}
}

View 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;
}
}

View 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();
}
}

View File

@ -0,0 +1,2 @@
ALTER TABLE users
ADD COLUMN IF NOT EXISTS question_source_preferences JSONB;

View 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);

View 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);

View File

@ -0,0 +1,4 @@
-- Text answers for spelling quizzes (typed spellings).
ALTER TABLE questions
ADD COLUMN IF NOT EXISTS user_response_text TEXT;

View File

@ -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(
''' '''

View File

@ -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);

View 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));
});
}

View 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);
});
}

View 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>,
);
}

View 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');
});
}

View 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');
});
}

View 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'));
});
}

View 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);
});
}

View 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),
];
}

View 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',
);
});
});
}

View 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);
});
}

View 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',
);
});
});
}

View 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);
});
}

View 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));
});
}

View 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,
);
});
}

View 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));
});
});
}

View File

@ -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', (

View 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);
});
});
}

View 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);
});
}

View 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,
);
});
}

View 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,
);
});
}

View 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);
});
}

View 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);
});
}

View File

@ -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(

View File

@ -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
) )