216 lines
6.3 KiB
Dart
216 lines
6.3 KiB
Dart
/// 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(),
|
||
};
|
||
}
|
||
}
|