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: () {
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 'bootstrap.dart';
import 'models/app_user.dart';
import 'routes/app_routes.dart';
import 'screens/home_screen.dart';
import 'screens/landing_screen.dart';
import 'screens/spelling_list_screen.dart';
import 'services/auth_service.dart';
import 'theme/app_theme.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;
},
);

View File

@ -5,8 +5,16 @@ class IncomingQuestion {
required this.text,
required this.sentAt,
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 text;
final DateTime sentAt;
@ -14,12 +22,82 @@ class IncomingQuestion {
/// Unanswered questions in the database for this user (from API).
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) {
return IncomingQuestion(
id: json['id'] as String,
text: json['text'] as String,
sentAt: DateTime.parse(json['sentAt'] as String).toUtc(),
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/questions_hub_service.dart';
import '../theme/app_theme.dart';
import '../widgets/hub_toolbar_icon_button.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 {
const HomeScreen({
@ -45,32 +50,67 @@ class HomeScreen extends StatelessWidget {
Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
centerTitle: true,
elevation: 0,
scrolledUnderElevation: 0,
automaticallyImplyLeading: false,
titleSpacing: 8,
centerTitle: false,
title: ListenableBuilder(
listenable: Listenable.merge(<Listenable>[
QuestionsHubService.instance.hasPendingQuestion,
QuestionsHubService.instance.pendingQuestion,
QuestionsHubService.instance.pendingQuestionCount,
QuestionsHubService.instance.guessScoreSummary,
QuestionsHubService.instance.questionPanelOpen,
QuestionsHubService.instance.questionQueue,
QuestionSourceConfigRepository.instance.preferences,
]),
builder: (BuildContext context, Widget? child) {
final int count =
QuestionsHubService.instance.pendingQuestionCount.value;
final bool hasPending =
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 =
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(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (showMarketScore)
_CumulativeScoreChip(
summary: score,
onPressed: () => _showGuessScoreStatsDialog(context, score),
onPressed: () =>
_showGuessScoreStatsDialog(context, score),
),
if (hasPending && count >= 1) ...<Widget>[
if (showMarketScore && showEnvelope)
const SizedBox(width: 8),
_QuestionEnvelopeButton(
count: count,
if (showEnvelope) ...<Widget>[
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: () =>
QuestionsHubService.instance.openQuestionPanel(),
),
@ -82,15 +122,22 @@ class HomeScreen extends StatelessWidget {
actions: <Widget>[
const AdminAppBarAction(),
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',
onPressed: () => UserProfileRepository.instance.sync(),
icon: Icon(Icons.sync, color: syncIconColor),
),
IconButton(
onPressed: () => AuthService.instance.signOut(),
tooltip: 'Sign out',
onPressed: () => AuthService.instance.signOut(),
icon: const Icon(Icons.logout),
),
const SizedBox(width: 4),
],
),
body: DecoratedBox(
@ -110,14 +157,31 @@ class HomeScreen extends StatelessWidget {
QuestionsHubService.instance.pendingQuestion,
QuestionsHubService.instance.questionActionBusy,
QuestionsHubService.instance.pendingQuestionCount,
QuestionSourceConfigRepository.instance.preferences,
]),
builder: (BuildContext context, Widget? child) {
final QuestionsHubService hub = QuestionsHubService.instance;
final bool panelOpen = hub.questionPanelOpen.value;
final bool hasPending = hub.hasPendingQuestion.value;
final IncomingQuestion? question = hub.currentQuestion;
final bool showQuestionPanel =
panelOpen && hasPending && question != null;
final sources = QuestionSourceConfigRepository.instance.current;
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(
crossAxisAlignment: CrossAxisAlignment.stretch,
@ -143,13 +207,14 @@ class HomeScreen extends StatelessWidget {
),
const SizedBox(height: 4),
Expanded(
child: SwipeQuestionTile(
key: ValueKey<String>(question.id),
questionId: question.id,
child: QuestionPresentationRouter(
question: question,
busy: hub.questionActionBusy.value,
onSwipeRight: (num answer) =>
onAnswer: (num 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(
child: SingleChildScrollView(
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) {
final String totalText = _formatScoreNumber(summary.total);
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({
required String questionId,
num answer = 0,
num? answer,
String? answerText,
}) async {
final String? token = await AuthService.instance.getIdToken();
if (token == 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(
Uri.parse('$apiBaseUrl/v1/me/questions/$questionId/answer'),
headers: _authHeaders(token),
body: jsonEncode(<String, num>{'answer': answer}),
body: jsonEncode(requestBody),
);
if (response.statusCode != 200) {
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 {
final String? token = await AuthService.instance.getIdToken();
if (token == null) {

View File

@ -8,7 +8,9 @@ import '../models/guess_score_reset_result.dart';
import '../models/guess_score_summary.dart';
import '../models/incoming_question.dart';
import '../models/question_defer_result.dart';
import '../models/question_source_preference.dart';
import '../models/question_submit_result.dart';
import '../repositories/question_source_config_repository.dart';
import 'auth_service.dart';
import 'questions_api_service.dart';
@ -127,6 +129,9 @@ class QuestionsHubService {
}
void _applyIncoming(IncomingQuestion question) {
if (!_acceptsQuestionSource(question)) {
return;
}
final int count =
question.unansweredCount < 1 ? 1 : question.unansweredCount;
pendingQuestion.value = question;
@ -148,27 +153,83 @@ class QuestionsHubService {
Future<void> openQuestionPanel() async {
questionActionBusy.value = true;
try {
// Align client filters with server-side source preferences before fetch.
await QuestionSourceConfigRepository.instance.loadFromRemote();
final List<IncomingQuestion> fetched = await _api.fetchUnanswered();
if (fetched.isNotEmpty) {
questionQueue.value = fetched;
pendingQuestion.value = fetched.first;
pendingQuestionCount.value = fetched.length;
// GET /questions already returns only server-enabled sources.
final List<IncomingQuestion> active = fetched;
questionPanelOpen.value = true;
if (active.isNotEmpty) {
questionQueue.value = active;
pendingQuestion.value = active.first;
pendingQuestionCount.value = active.length;
hasPendingQuestion.value = true;
} else if (pendingQuestion.value != null) {
} else if (pendingQuestion.value != null &&
_acceptsQuestionSource(pendingQuestion.value!)) {
questionQueue.value = <IncomingQuestion>[pendingQuestion.value!];
pendingQuestionCount.value = 1;
hasPendingQuestion.value = true;
} else {
pendingQuestion.value = null;
questionQueue.value = <IncomingQuestion>[];
hasPendingQuestion.value = _shouldKeepPanelOpenWithoutQuestion();
}
questionPanelOpen.value = hasPendingQuestion.value;
} finally {
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() {
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.
Future<void> deferCurrentQuestion() async {
final IncomingQuestion? question = currentQuestion;
@ -193,11 +254,17 @@ class QuestionsHubService {
final List<IncomingQuestion> refreshed = await _api.fetchUnanswered();
if (refreshed.isEmpty) {
if (result.nextQuestion != null) {
if (result.nextQuestion != null &&
_acceptsQuestionSource(result.nextQuestion!)) {
_setActiveQuestion(
result.nextQuestion!,
unansweredCount: result.unansweredCount,
);
} else if (_shouldKeepPanelOpenWithoutQuestion()) {
pendingQuestion.value = null;
questionQueue.value = <IncomingQuestion>[];
hasPendingQuestion.value = true;
questionPanelOpen.value = true;
} else {
_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.
Future<void> submitCurrentAnswer({num answer = 0}) async {
final IncomingQuestion? question = currentQuestion;
@ -232,20 +320,36 @@ class QuestionsHubService {
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;
try {
final QuestionSubmitResult? result = await _api.submitAnswer(
questionId: answeredQuestionId,
answer: answer,
answerText: answerText,
);
if (result == null) {
return;
}
if (question.isMarketHistory) {
guessScoreSummary.value = result.score;
}
if (result.nextQuestion != null) {
if (result.nextQuestion != null &&
_acceptsQuestionSource(result.nextQuestion!)) {
_setActiveQuestion(
result.nextQuestion!,
unansweredCount: result.unansweredCount,
@ -254,7 +358,14 @@ class QuestionsHubService {
}
if (result.unansweredCount == 0) {
if (_shouldKeepPanelOpenWithoutQuestion()) {
pendingQuestion.value = null;
questionQueue.value = <IncomingQuestion>[];
hasPendingQuestion.value = true;
questionPanelOpen.value = true;
} else {
_clearPendingUi();
}
return;
}
@ -263,7 +374,14 @@ class QuestionsHubService {
.where((IncomingQuestion q) => q.id != answeredQuestionId)
.toList();
if (remaining.isEmpty) {
if (_shouldKeepPanelOpenWithoutQuestion()) {
pendingQuestion.value = null;
questionQueue.value = <IncomingQuestion>[];
hasPendingQuestion.value = true;
questionPanelOpen.value = true;
} else {
_clearPendingUi();
}
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/user_profile.dart';
import '../repositories/user_profile_repository.dart';
import '../repositories/question_source_config_repository.dart';
import '../services/questions_hub_service.dart';
import '../theme/app_theme.dart';
@ -37,7 +38,11 @@ class _ProfileSessionState extends State<ProfileSession> {
void initState() {
super.initState();
_sessionReady = UserProfileRepository.instance.startSession(widget.user)
..then((_) => QuestionsHubService.instance.onLogin());
..then((_) async {
await QuestionSourceConfigRepository.instance
.startSession(widget.user.uid);
await QuestionsHubService.instance.onLogin();
});
_syncStatusSubscription =
UserProfileRepository.instance.syncStatusStream.listen((
ProfileSyncStatus status,
@ -53,6 +58,7 @@ class _ProfileSessionState extends State<ProfileSession> {
_syncStatusSubscription?.cancel();
QuestionsHubService.instance.clearGuessScoreCache();
unawaited(QuestionsHubService.instance.disconnect());
unawaited(QuestionSourceConfigRepository.instance.endSession());
UserProfileRepository.instance.endSession();
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,
this.busy = false,
this.swipeAxisToleranceDegrees = 25,
this.showGuidGlyph = true,
});
final String questionId;
@ -45,6 +46,9 @@ class SwipeQuestionTile extends StatefulWidget {
/// Max deviation from pure horizontal (0°/180°) or vertical (±90°) to lock axis.
final double swipeAxisToleranceDegrees;
/// When false, hides the GUID glyph (used by spelling presentation).
final bool showGuidGlyph;
@override
State<SwipeQuestionTile> createState() => _SwipeQuestionTileState();
}
@ -723,6 +727,7 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
),
),
),
if (widget.showGuidGlyph)
Positioned(
top: 8,
bottom: 8,

View File

@ -6,8 +6,10 @@ import FlutterMacOS
import Foundation
import connectivity_plus
import file_picker
import firebase_auth
import firebase_core
import flutter_tts
import google_sign_in_ios
import shared_preferences_foundation
import sqlite3_flutter_libs
@ -15,8 +17,10 @@ import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FlutterTtsPlugin.register(with: registry.registrar(forPlugin: "FlutterTtsPlugin"))
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin"))

View File

@ -185,6 +185,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@ -273,6 +281,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: "direct main"
description:
@ -342,11 +358,27 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: "direct dev"
description: flutter
source: sdk
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:
dependency: transitive
description: flutter
@ -1109,6 +1141,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.3"
win32:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
source: hosted
version: "5.15.0"
xdg_directories:
dependency: transitive
description:

View File

@ -47,6 +47,8 @@ dependencies:
connectivity_plus: ^6.1.4
signalr_netcore: ^1.4.4
logging: ^1.3.0
file_picker: ^8.3.7
flutter_tts: ^4.2.2
dev_dependencies:
flutter_test:

View File

@ -63,9 +63,14 @@ if [ "${SKIP_PUB_GET:-0}" = "1" ]; then
_flutter_pub_flag=(--no-pub)
echo "SKIP_PUB_GET=1 — not running flutter pub get"
fi
flutter build web --release "${_flutter_pub_flag[@]}" \
--dart-define=API_BASE_URL="$API_BASE_URL" \
--dart-define=AUTH_VERBOSE_LOG=true
# --no-tree-shake-icons: keep the full MaterialIcons font so its bytes are
# build-invariant. Tree-shaking re-subsets MaterialIcons-Regular.otf every build
# (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
"$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"
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"
echo "Stamped: $INDEX and flutter_bootstrap.js (if present)"

View File

@ -43,13 +43,13 @@ dart pub get
dart test
```
Integration tests apply migrations `001`–`009` on `cyberhybridhub_test` and truncate
trading tables between cases. Optional override: `TEST_DATABASE_URL`.
Integration tests apply migrations `001`–`009` on `cyberhybridhub_test` and
truncate trading tables between cases. Optional override: `TEST_DATABASE_URL`.
## Endpoints
| Method | Path | Auth |
|--------|------|------|
| ------ | ------------------------------ | ------------------------------------------------------------------------------- |
| `GET` | `/v1/me/profile` | `Authorization: Bearer <Firebase ID token>` |
| `PUT` | `/v1/me/profile` | same |
| `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`
The Flutter app calls `POST /v1/me/questions/bootstrap` once at login. The API ensures a
`users` row exists, picks a random row from `market_history_prospective_questions`, and
creates a user question linked by `metadata.prospective_question_id` (falls back to
oldest unanswered when no prospective rows exist). After sign-in it connects to SignalR
and listens for `ReceiveQuestion`. On each new WebSocket connection the API only delivers
an existing unanswered question — it does not create new rows.
The Flutter app calls `POST /v1/me/questions/bootstrap` once at login. The API
ensures a `users` row exists, picks a random row from
`market_history_prospective_questions`, and creates a user question linked by
`metadata.prospective_question_id` (falls back to oldest unanswered when no
prospective rows exist). After sign-in it connects to SignalR and listens for
`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):
@ -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`
(nullable), `correct_answer`, `created_at`, `modified_at`.
`questions` table: `id` (UUID), `assigned_user_id`, `question_text`,
`user_response` (nullable), `correct_answer`, `created_at`, `modified_at`.
`market_history_prospective_answers` table snapshots answered prospective-question context:
`question_id`, `prospective_question_id`, `symbol`, `older_slot_start`,
`newer_slot_start`, `expected_percent_increase_price`, and `user_slider_value`.
`market_history_prospective_answers` table snapshots answered
prospective-question context: `question_id`, `prospective_question_id`,
`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):
- Persists across devices and logins for the same `firebase_uid`.
- `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`):
- `slot_start` — older session-half edge for the active pair; advances when every
top-50% volume symbol in `(slot_start → next slot)` has been answered.
- `slot_start` — older session-half edge for the active pair; advances when
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
clears that user's `market_history_prospective_answers` and
`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
question is created (`pending` until answered). Survives logins; unique
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_CLOSENESS_ENABLED` | Correct sign | Wrong sign |
|----------------------------------------|--------------|------------|
| -------------------------------------- | ------------------------------------------------------------------------------------ | ---------- |
| `false` (default) | `+1` | `-1` |
| `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.
| Env var | Default | Purpose |
|---------|---------|---------|
| -------------------------------------- | ------- | ------------------------------------------------------------------------------------------- |
| `QUESTION_WORKER_ENABLED` | `true` | Set to `false` to disable the worker |
| `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 |
@ -139,8 +144,8 @@ enqueues pipeline questions when the user's queue is not full.
**Branching flow**
1. **Track choice** — user swipes toward +10 (weather) or -10 (geography).
2. **Geography** — yes/no population threshold, then capital confirmation;
wrong population guess triggers a recovery question.
2. **Geography** — yes/no population threshold, then capital confirmation; wrong
population guess triggers a recovery question.
3. **Weather** — yes/no warm/cool for a random European city, then a follow-up
to continue weather or switch to geography.
@ -163,42 +168,56 @@ curl -s -X POST http://localhost:3000/v1/me/incoming-question \
## Market history
Alpaca **`1Min`** bars aggregated into two **US regular-session** half-days per trading day
(morning **9:30–12:45 ET**, afternoon **12:45–16:00 ET**, ~195 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).
Alpaca **`1Min`** bars aggregated into two **US regular-session** half-days per
trading day (morning **9:30–12:45 ET**, afternoon **12:45–16:00 ET**, ~195
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.
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.
Runs when `hasPendingSlots` is true (worker tick). One bars request per slot × symbol batch.
Before each worker or admin pipeline, orphaned `market_data_sync_runs` rows with
`finished_at IS NULL` 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.
**Backfill** (`kind=backfill`): fetches each **ended** slot still missing in DB;
skips the open slot. 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. Runs when `hasPendingSlots` is true
(worker tick). One bars request per slot × symbol batch. Before each worker or
admin pipeline, orphaned `market_data_sync_runs` rows with `finished_at IS NULL`
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` →
prospective-question refresh (silent; not logged to `market_data_sync_runs`).
**Worker pipeline** (each tick when sync enabled): `universe` → `backfill` →
`cleanup` → prospective-question refresh (silent; not logged to
`market_data_sync_runs`).
**Prospective questions** (`market_history_prospective_questions`, migration `012`):
- **Identity:** `UNIQUE (symbol, older_slot_start, newer_slot_start)` — one row per
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 …
ON CONFLICT DO UPDATE`; prune symbols that dropped out of the top 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.
**Prospective questions** (`market_history_prospective_questions`, migration
`012`):
- **Identity:** `UNIQUE (symbol, older_slot_start, newer_slot_start)` — one row
per 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 …
ON CONFLICT DO UPDATE`; prune symbols that dropped out of the top
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`.
**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 |
|---------|---------|---------|
| ---------------------------------------- | --------- | ----------------------------------------------------------------- |
| `MARKET_HISTORY_SYNC_ENABLED` | `false` | Worker: universe → backfill → cleanup |
| `MARKET_HISTORY_WINDOW_DAYS` | `5` | Oldest slot start to retain / backfill |
| `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_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_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_MAX_SYMBOLS` | `2000` | Max symbols per backfill run |
| `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`.
| Env var | Default | Purpose |
|---------|---------|---------|
| ---------------------- | --------- | ---------------------------------------------------------------- |
| `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
`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`.
| Method | Path | Auth |
|--------|------|------|
| ------ | --------------------------------------------------- | ------------------ |
| `GET` | `/v1/admin/market-history/sync-runs` | Admin Firebase UID |
| `POST` | `/v1/admin/market-data/resync` | 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
{ "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

View File

@ -13,11 +13,16 @@ import '../lib/firebase_auth.dart';
import '../lib/handlers/incoming_question_handler.dart';
import '../lib/handlers/market_history_admin_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_hub_handler.dart';
import '../lib/handlers/spelling_words_handler.dart';
import '../lib/handlers/trading_dev_handler.dart';
import '../lib/pipeline/question_pipeline.dart';
import '../lib/question_dispatcher.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/trading/prospective_answer_scoring.dart';
import '../lib/trading/guardrails.dart';
@ -61,9 +66,14 @@ Future<void> main() async {
final FirebaseAuthVerifier auth = FirebaseAuthVerifier(env.firebaseWebApiKey);
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(
questionsDb: questionsDb,
hubConnections: questionsHubConnections,
questionSourcePreferencesDb: questionSourcePreferencesDb,
);
// Trading wiring (gated by TRADING_ENABLED). The trading pipeline plugs
@ -225,8 +235,17 @@ Future<void> main() async {
questionsDb: questionsDb,
questionService: questionService,
tradingPipeline: tradingPipeline,
questionSourcePreferencesDb: questionSourcePreferencesDb,
spellingItemsDb: spellingItemsDb,
testMode: env.questionPipelineTestMode,
);
final QuestionDispatcher questionDispatcher = QuestionDispatcher(
questionsDb: questionsDb,
questionService: questionService,
questionPipeline: questionPipeline,
preferencesDb: questionSourcePreferencesDb,
);
questionPipeline.attachQuestionDispatcher(questionDispatcher);
QuestionBackgroundWorker? backgroundWorker;
if (env.questionWorkerEnabled) {
backgroundWorker = QuestionBackgroundWorker(
@ -238,6 +257,14 @@ Future<void> main() async {
backgroundWorker.start();
}
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(
auth: auth,
questionService: questionService,
@ -250,6 +277,7 @@ Future<void> main() async {
auth: auth,
questionsDb: questionsDb,
questionService: questionService,
questionDispatcher: questionDispatcher,
questionPipeline: questionPipeline,
);
final Handler? tradingDev = tradingDevActions == null
@ -289,6 +317,12 @@ Future<void> main() async {
if (path.startsWith(questionsBasePath)) {
return questions(request);
}
if (path == questionSourcesBasePath) {
return questionSources(request);
}
if (path.startsWith(spellingWordsBasePath)) {
return spellingWords(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 '../firebase_auth.dart';
import '../pipeline/question_pipeline.dart';
import '../question_dispatcher.dart';
import '../question_service.dart';
import '../questions_db.dart';
@ -16,6 +17,7 @@ Handler questionsHandler({
required FirebaseAuthVerifier auth,
required QuestionsDb questionsDb,
required QuestionService questionService,
required QuestionDispatcher questionDispatcher,
QuestionPipeline? questionPipeline,
}) {
final Router router = Router();
@ -26,8 +28,9 @@ Handler questionsHandler({
return _jsonResponse(401, <String, dynamic>{'error': 'Unauthorized'});
}
try {
final Map<String, dynamic>? question =
await questionService.bootstrapOnLogin(firebaseUid);
final Map<String, dynamic>? question =
await questionDispatcher.dispatchNextQuestion(firebaseUid);
final int unansweredCount =
await questionsDb.countUnansweredQuestions(firebaseUid);
final Map<String, dynamic> score =
@ -49,16 +52,8 @@ Handler questionsHandler({
return _jsonResponse(401, <String, dynamic>{'error': 'Unauthorized'});
}
try {
final List<Map<String, dynamic>> rows =
await questionsDb.listUnansweredQuestions(firebaseUid);
final List<Map<String, dynamic>> questions = rows
.map(
(Map<String, dynamic> row) => questionsDb.toClientPayload(
row,
unansweredCount: rows.length,
),
)
.toList();
final List<Map<String, dynamic>> questions =
await questionDispatcher.listPanelQuestions(firebaseUid);
return _jsonResponse(200, <String, dynamic>{
'questions': questions,
'unansweredCount': questions.length,
@ -95,7 +90,7 @@ Handler questionsHandler({
final Map<String, dynamic> score =
await questionsDb.resetGuessScoreSummary(firebaseUid);
final Map<String, dynamic>? question =
await questionService.ensureProspectiveQuestionQueued(firebaseUid);
await questionDispatcher.dispatchNextQuestion(firebaseUid);
final int unansweredCount =
await questionsDb.countUnansweredQuestions(firebaseUid);
return _jsonResponse(200, <String, dynamic>{
@ -121,12 +116,14 @@ Handler questionsHandler({
final Map<String, dynamic> json = body.isEmpty
? <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(
questionId: id,
assignedUserId: firebaseUid,
userResponse: answer,
userResponseText: answerText,
);
if (updated == null) {
return _jsonResponse(404, <String, dynamic>{'error': 'Not found'});
@ -135,36 +132,30 @@ Handler questionsHandler({
await questionPipeline.onAnswerSubmitted(
firebaseUid: firebaseUid,
answeredQuestion: updated,
userResponse: answer,
userResponse: updated['userResponse'] as num? ?? answer ?? 0,
);
}
var unansweredCount =
await questionsDb.countUnansweredQuestions(firebaseUid);
Map<String, dynamic>? nextQuestion;
if (unansweredCount == 0) {
nextQuestion =
await questionService.ensureProspectiveQuestionQueued(firebaseUid);
final Map<String, dynamic>? nextQuestion =
await questionDispatcher.nextQuestionAfterAnswer(
firebaseUid: firebaseUid,
answeredQuestion: updated,
);
if (nextQuestion != null) {
unansweredCount =
(nextQuestion['unansweredCount'] as num?)?.toInt() ??
await questionsDb.countUnansweredQuestions(firebaseUid);
unansweredCount;
}
} else {
final Map<String, dynamic>? nextRow =
await questionsDb.findUnansweredQuestion(firebaseUid);
if (nextRow != null) {
nextQuestion = questionsDb.toClientPayload(
nextRow,
unansweredCount: unansweredCount,
);
}
}
final Map<String, dynamic> score =
await questionsDb.getGuessScoreSummary(firebaseUid);
final bool isMarketAnswer =
QuestionsDb.resolveSourceId(updated) == 'market_history';
final Map<String, dynamic>? score = isMarketAnswer
? await questionsDb.getGuessScoreSummary(firebaseUid)
: null;
return _jsonResponse(200, <String, dynamic>{
'question': updated,
'unansweredCount': unansweredCount,
'score': score,
if (score != null) 'score': score,
if (nextQuestion != null) 'nextQuestion': nextQuestion,
});
} 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(
'$questionsBasePath/<id>/defer',
(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:math';
import '../question_dispatcher.dart';
import '../question_service.dart';
import '../question_source_preferences.dart';
import '../question_source_preferences_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 'branch_decision.dart';
import 'external_data_fetcher.dart';
@ -22,6 +31,7 @@ abstract final class PipelineKeys {
static const String geography = 'geography';
static const String weather = 'weather';
static const String trading = 'trading';
static const String spelling = 'spelling';
}
/// Steps within each pipeline branch.
@ -32,6 +42,8 @@ abstract final class PipelineSteps {
static const String capitalRecovery = 'capital_recovery';
static const String temperatureQuiz = 'temperature_quiz';
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';
}
@ -60,20 +72,49 @@ class QuestionPipeline {
required QuestionService questionService,
ExternalDataFetcher? fetcher,
TradingPipeline? tradingPipeline,
QuestionSourcePreferencesDb? questionSourcePreferencesDb,
SpellingItemsDb? spellingItemsDb,
SpellingItemSelector? spellingItemSelector,
UserSpellingProgressDb? spellingProgressDb,
SpellingQuestionLinksDb? spellingQuestionLinksDb,
this.maxQueuedQuestions = 3,
this.testMode = false,
}) : _questionsDb = questionsDb,
_questionService = questionService,
_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 QuestionService _questionService;
final ExternalDataFetcher _fetcher;
final TradingPipeline? _tradingPipeline;
final QuestionSourcePreferencesDb? _questionSourcePreferencesDb;
final SpellingItemsDb _spellingItemsDb;
final SpellingItemSelector _spellingSelector;
final UserSpellingProgressDb _spellingProgressDb;
final SpellingQuestionLinksDb _spellingQuestionLinksDb;
final int maxQueuedQuestions;
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.
Future<void> runMaintenanceCycle() async {
final List<String> userIds = await _questionsDb.listAllUserFirebaseUids();
@ -87,7 +128,12 @@ class QuestionPipeline {
}
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) {
return;
}
@ -95,9 +141,6 @@ class QuestionPipeline {
final Map<String, dynamic>? state =
await _questionsDb.getPipelineState(firebaseUid);
if (state == null) {
if (queued == 0) {
await _startRootPipeline(firebaseUid);
}
return;
}
@ -114,6 +157,7 @@ class QuestionPipeline {
required String sourceTag,
required String pipelineKey,
required String pipelineStep,
Map<String, dynamic>? metadata,
}) async {
await _questionService.createAndDeliverQuestion(
assignedUserId: firebaseUid,
@ -124,6 +168,7 @@ class QuestionPipeline {
sourceTag: sourceTag,
pipelineKey: pipelineKey,
pipelineStep: pipelineStep,
metadata: metadata,
);
}
@ -141,10 +186,28 @@ class QuestionPipeline {
}
Future<bool> _canEnqueue(String firebaseUid) async {
final int queued = await _questionsDb.countUnansweredQuestions(firebaseUid);
final int queued = await _enabledUnansweredCount(firebaseUid);
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.
Future<void> onAnswerSubmitted({
required String firebaseUid,
@ -199,9 +262,217 @@ class QuestionPipeline {
);
case PipelineKeys.trading:
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(
String firebaseUid,
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(
String firebaseUid,
Map<String, dynamic> state,
@ -462,8 +828,10 @@ class QuestionPipeline {
await _startGeographyPipeline(firebaseUid);
case PipelineKeys.weather:
await _startWeatherPipeline(firebaseUid);
case PipelineKeys.spelling:
await _startSpellingPipeline(firebaseUid);
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:io';
import 'question_source_preferences_db.dart';
import 'questions_db.dart';
import 'signalr/questions_hub_connections.dart';
import 'trading/prospective_guess_selection.dart';
@ -12,21 +13,21 @@ class QuestionService {
QuestionService({
required QuestionsDb questionsDb,
required QuestionsHubConnections hubConnections,
QuestionSourcePreferencesDb? questionSourcePreferencesDb,
}) : _questionsDb = questionsDb,
_hubConnections = hubConnections;
_hubConnections = hubConnections,
_questionSourcePreferencesDb = questionSourcePreferencesDb;
final QuestionsDb _questionsDb;
final QuestionsHubConnections _hubConnections;
final QuestionSourcePreferencesDb? _questionSourcePreferencesDb;
ProspectiveGuessAssignmentsDb get _assignmentsDb =>
ProspectiveGuessAssignmentsDb(_questionsDb.connection);
/// Called at login: ensures one unanswered prospective question when possible.
Future<Map<String, dynamic>?> bootstrapOnLogin(
String firebaseUid,
) async {
/// Called at login: ensures the user row exists (queue top-up uses [QuestionDispatcher]).
Future<void> bootstrapOnLogin(String firebaseUid) async {
await _questionsDb.ensureUserExists(firebaseUid);
return ensureProspectiveQuestionQueued(firebaseUid);
}
/// Ensures the active slot's top-half batch exists, then returns the FIFO head.
@ -54,11 +55,36 @@ class QuestionService {
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.
Future<void> _ensureCurrentSlotBatch(
String firebaseUid, {
DateTime? now,
int? maxCreates,
}) async {
if (!await _isMarketHistoryEnabled(firebaseUid)) {
return;
}
final ProspectiveGuessSelection selection = ProspectiveGuessSelection(
connection: _questionsDb.connection,
);
@ -72,8 +98,12 @@ class QuestionService {
final DateTime baseOrder = DateTime.now().toUtc();
var createdAny = false;
var createdCount = 0;
for (var index = 0; index < batch.unassignedAssets.length; index++) {
if (maxCreates != null && createdCount >= maxCreates) {
return;
}
final QuestionAuditAsset asset = batch.unassignedAssets[index];
if (await _assignmentsDb.hasAssignmentForSymbolSlotPair(
firebaseUid: firebaseUid,
@ -123,6 +153,7 @@ class QuestionService {
continue;
}
createdAny = true;
createdCount++;
}
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.
Future<Map<String, dynamic>> createAndDeliverQuestion({
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:uuid/uuid.dart';
import 'question_source_mix.dart';
import 'trading/market_history_config.dart';
import 'trading/prospective_answer_scoring.dart';
import 'trading/guess_score_store.dart';
@ -36,7 +37,7 @@ class QuestionsDb {
static const String _questionsSelectColumns = '''
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.metadata''';
q.metadata, q.user_response_text''';
static const String _unansweredQuestionsJoin = '''
FROM questions q
@ -151,26 +152,47 @@ class QuestionsDb {
Future<Map<String, dynamic>?> submitAnswer({
required String questionId,
required String assignedUserId,
required num userResponse,
num? userResponse,
String? userResponseText,
}) 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 Result result = await _connection.execute(
Sql.named(
'''
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
AND assigned_user_id = @uid
AND user_response IS NULL
RETURNING id, assigned_user_id, question_text, user_response, correct_answer,
created_at, modified_at, source_tag, pipeline_key, pipeline_step,
metadata
metadata, user_response_text
''',
),
parameters: <String, dynamic>{
'id': questionId,
'uid': assignedUserId,
'user_response': userResponse,
'user_response': effectiveResponse,
'user_response_text': textToStore,
'modified_at': now,
},
);
@ -178,15 +200,122 @@ class QuestionsDb {
return null;
}
final Map<String, dynamic> updated = _rowFromResult(result.first);
if (resolveSourceId(updated) == 'market_history') {
await _recordProspectiveAnswer(updated);
await ProspectiveGuessAssignmentsDb(_connection).markAnsweredByQuestionId(
questionId: questionId,
answeredAt: now,
);
await _gradeProspectiveAnswerIfNeeded(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({
required String questionId,
required String assignedUserId,
@ -196,7 +325,7 @@ class QuestionsDb {
'''
SELECT id, assigned_user_id, question_text, user_response, correct_answer,
created_at, modified_at, source_tag, pipeline_key, pipeline_step,
metadata
metadata, user_response_text
FROM questions
WHERE id = @id::uuid AND assigned_user_id = @uid
''',
@ -370,6 +499,41 @@ class QuestionsDb {
}
/// 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 {
final Result result = await _connection.execute(
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).
Map<String, dynamic> toClientPayload(
Map<String, dynamic> question, {
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>{
'id': question['id'],
'assignedUserId': question['assignedUserId'],
'text': question['text'],
'sentAt': question['createdAt'],
'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) {
final Object idValue = row[0]!;
final String id = idValue is String ? idValue : idValue.toString();
@ -519,6 +777,7 @@ class QuestionsDb {
pipelineKey: row.length > 8 ? row[8] as String? : null,
pipelineStep: row.length > 9 ? row[9] as String? : 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? pipelineStep,
Map<String, dynamic>? metadata,
String? userResponseText,
}) {
return <String, dynamic>{
'id': id,
'assignedUserId': assignedUserId,
'text': questionText,
'userResponse': userResponse,
if (userResponseText != null) 'userResponseText': userResponseText,
'correctAnswer': correctAnswer,
'createdAt': createdAt.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 '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/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/trading/market_data_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:postgres/postgres.dart';
/// Integration test Postgres: [cyberhybridhub_test] with migrations 001–017.
/// Integration test Postgres: [cyberhybridhub_test] with migrations 001–018.
class TestDb {
TestDb._(this.db, this._connection, this.databaseUrl);
@ -52,6 +56,7 @@ class TestDb {
}
await profileDb.migrate();
await SpellingItemsDb(profileDb.connection).ensureCatalogSeeded();
return TestDb._(profileDb, profileDb.connection, testUrl);
}
@ -113,12 +118,38 @@ class TestDb {
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 questionService() => QuestionService(
questionsDb: questionsDb,
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 {
await _connection.execute(
'''

View File

@ -1030,9 +1030,12 @@ void main() {
);
}
final QuestionService service = testDb!.questionService();
final Map<String, dynamic>? payload = await service.bootstrapOnLogin(uid);
await testDb!.questionService().bootstrapOnLogin(uid);
final Map<String, dynamic>? payload =
await testDb!.questionDispatcher().dispatchNextQuestion(uid);
expect(payload, isNotNull);
expect(payload!['sourceId'], 'market_history');
expect(payload['presentation'], 'market_glyph_slider');
final List<Map<String, dynamic>> queued =
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();
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', (

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 <firebase_auth/firebase_auth_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 <url_launcher_windows/url_launcher_windows.h>
@ -19,6 +20,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi"));
FirebaseCorePluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
FlutterTtsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterTtsPlugin"));
Sqlite3FlutterLibsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(

View File

@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
connectivity_plus
firebase_auth
firebase_core
flutter_tts
sqlite3_flutter_libs
url_launcher_windows
)