/// 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 toJson() { return { 'id': id, 'label': label, 'description': description, 'enabled': enabled, 'ratio': ratio, 'comingSoon': comingSoon, }; } Map toStoredJson() { return { '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 definitions = [ 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 mergeStored( List>? stored, ) { final Map> byId = >{ if (stored != null) for (final Map row in stored) row['id']! as String: row, }; return definitions .map((QuestionSourcePreferenceDefinition definition) { final Map? 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> parsePutBody(Map body) { final Object? rawSources = body['sources']; if (rawSources is! List) { throw QuestionSourceValidationException('Expected sources array.'); } final Map known = { for (final QuestionSourcePreferenceDefinition definition in definitions) definition.id: definition, }; final List> parsed = >[]; for (final Object? entry in rawSources) { if (entry is! Map) { 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({ 'id': id, 'enabled': enabled, 'ratio': ratio, }); } if (parsed.length != definitions.length) { throw QuestionSourceValidationException( 'Provide every known question source.', ); } final bool hasEnabled = parsed.any((Map 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 effectiveMix( List sources, ) { final Iterable enabled = sources.where( (ResolvedQuestionSourcePreference s) => s.enabled && !s.comingSoon, ); final int totalWeight = enabled.fold( 0, (int sum, ResolvedQuestionSourcePreference s) => sum + s.ratio, ); if (totalWeight <= 0) { return {}; } return { for (final ResolvedQuestionSourcePreference source in enabled) source.id: ((source.ratio / totalWeight) * 100).round(), }; } static bool isEnabled( List resolved, String sourceId, ) { for (final ResolvedQuestionSourcePreference source in resolved) { if (source.id == sourceId) { return source.enabled && !source.comingSoon; } } return false; } static Map toApiPayload({ required List sources, DateTime? updatedAt, }) { return { 'sources': sources .map((ResolvedQuestionSourcePreference s) => s.toJson()) .toList(), if (updatedAt != null) 'updatedAt': updatedAt.toUtc().toIso8601String(), }; } }