import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../models/question_source_preference.dart'; import '../repositories/question_source_config_repository.dart'; import '../routes/app_routes.dart'; import '../services/questions_hub_service.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 initialPreferences; static Future show(BuildContext context) { return showDialog( 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 createState() => _QuestionSourceSettingsSheetState(); } class _QuestionSourceSettingsSheetState extends State { late List _draft; String? _validationMessage; bool _saving = false; bool _resettingSpelling = false; @override void initState() { super.initState(); _draft = QuestionSourceConfigRepository.mergeWithDefaults( widget.initialPreferences, ).map((QuestionSourcePreference source) => source.copyWith()).toList(); } List _distribute(List targetProportions, int targetSum) { final List values = targetProportions.map((double p) => p * targetSum).toList(); final List floorValues = values.map((double v) => v.floor()).toList(); final int currentSum = floorValues.fold(0, (int sum, int val) => sum + val); final int remainder = targetSum - currentSum; if (remainder > 0) { final List indices = List.generate(values.length, (int i) => i); indices.sort((int a, int b) { final double fracA = values[a] - floorValues[a]; final double fracB = values[b] - floorValues[b]; return fracB.compareTo(fracA); }); for (int i = 0; i < remainder; i++) { floorValues[indices[i % indices.length]] += 1; } } return floorValues; } void _updateRatio(int targetIndex, int newRatio) { setState(() { newRatio = newRatio.clamp(0, 100); if (newRatio == 100) { for (int i = 0; i < _draft.length; i++) { if (i == targetIndex) { _draft[i] = _draft[i].copyWith(enabled: true, ratio: 100); } else { _draft[i] = _draft[i].copyWith(enabled: false, ratio: 0); } } _validationMessage = null; return; } final List> otherActiveSources = _draft .asMap() .entries .where((MapEntry entry) => entry.key != targetIndex && !entry.value.comingSoon && entry.value.enabled) .toList(); if (otherActiveSources.isEmpty) { final List disabledNonComingSoonIndices = _draft .asMap() .entries .where((MapEntry entry) => entry.key != targetIndex && !entry.value.comingSoon) .map((MapEntry entry) => entry.key) .toList(); if (disabledNonComingSoonIndices.isNotEmpty) { final int freedPercentage = 100 - newRatio; final List proportions = List.filled( disabledNonComingSoonIndices.length, 1.0 / disabledNonComingSoonIndices.length, ); final List distributed = _distribute(proportions, freedPercentage); _draft[targetIndex] = _draft[targetIndex].copyWith(enabled: true, ratio: newRatio); for (int i = 0; i < disabledNonComingSoonIndices.length; i++) { final int idx = disabledNonComingSoonIndices[i]; _draft[idx] = _draft[idx].copyWith(enabled: true, ratio: distributed[i]); } } else { _draft[targetIndex] = _draft[targetIndex].copyWith(enabled: true, ratio: 100); } _validationMessage = null; return; } final int targetOtherSum = 100 - newRatio; final int currentOtherSum = otherActiveSources.fold( 0, (int sum, MapEntry entry) => sum + entry.value.ratio, ); final List otherIndices = otherActiveSources.map((MapEntry entry) => entry.key).toList(); final List proportions = []; if (currentOtherSum > 0) { for (final int idx in otherIndices) { proportions.add(_draft[idx].ratio / currentOtherSum); } } else { for (int i = 0; i < otherIndices.length; i++) { proportions.add(1.0 / otherIndices.length); } } final List distributed = _distribute(proportions, targetOtherSum); _draft[targetIndex] = _draft[targetIndex].copyWith(enabled: true, ratio: newRatio); for (int i = 0; i < otherIndices.length; i++) { final int idx = otherIndices[i]; _draft[idx] = _draft[idx].copyWith(ratio: distributed[i]); } _validationMessage = null; }); } void _toggleSourceEnabled(int targetIndex, bool enabled) { setState(() { if (enabled) { final List otherActiveIndices = _draft .asMap() .entries .where((MapEntry entry) => entry.key != targetIndex && !entry.value.comingSoon && entry.value.enabled) .map((MapEntry entry) => entry.key) .toList(); if (otherActiveIndices.isEmpty) { _draft[targetIndex] = _draft[targetIndex].copyWith(enabled: true, ratio: 100); } else { final int newCount = otherActiveIndices.length + 1; final int targetRatio = (100 / newCount).round(); final int remainingRatio = 100 - targetRatio; final int currentOtherSum = otherActiveIndices.fold( 0, (int sum, int idx) => sum + _draft[idx].ratio, ); final List proportions = []; if (currentOtherSum > 0) { for (final int idx in otherActiveIndices) { proportions.add(_draft[idx].ratio / currentOtherSum); } } else { for (int i = 0; i < otherActiveIndices.length; i++) { proportions.add(1.0 / otherActiveIndices.length); } } final List distributed = _distribute(proportions, remainingRatio); _draft[targetIndex] = _draft[targetIndex].copyWith(enabled: true, ratio: targetRatio); for (int i = 0; i < otherActiveIndices.length; i++) { final int idx = otherActiveIndices[i]; _draft[idx] = _draft[idx].copyWith(ratio: distributed[i]); } } } else { _draft[targetIndex] = _draft[targetIndex].copyWith(enabled: false, ratio: 0); final List otherActiveIndices = _draft .asMap() .entries .where((MapEntry entry) => entry.key != targetIndex && !entry.value.comingSoon && entry.value.enabled) .map((MapEntry entry) => entry.key) .toList(); if (otherActiveIndices.isNotEmpty) { final int currentOtherSum = otherActiveIndices.fold( 0, (int sum, int idx) => sum + _draft[idx].ratio, ); final List proportions = []; if (currentOtherSum > 0) { for (final int idx in otherActiveIndices) { proportions.add(_draft[idx].ratio / currentOtherSum); } } else { for (int i = 0; i < otherActiveIndices.length; i++) { proportions.add(1.0 / otherActiveIndices.length); } } final List distributed = _distribute(proportions, 100); for (int i = 0; i < otherActiveIndices.length; i++) { final int idx = otherActiveIndices[i]; _draft[idx] = _draft[idx].copyWith(ratio: distributed[i]); } } } _validationMessage = null; }); } void _openSpellingWordList(BuildContext context) { Navigator.of(context).pushNamed(AppRoutes.spellingWordList); } Future _confirmResetSpellingProgress() async { final bool? confirmed = await showDialog( context: context, builder: (BuildContext dialogContext) { return AlertDialog( key: const Key('spelling-progress-reset-confirm-dialog'), title: const Text('Reset spelling progress?'), content: const Text( 'This deletes your spelling question links and familiarity scores ' 'and returns spelling to its initial state. You cannot undo this.', ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('Cancel'), ), TextButton( key: const Key('spelling-progress-reset-confirm-button'), onPressed: () => Navigator.of(dialogContext).pop(true), child: const Text('Reset'), ), ], ); }, ); if (confirmed != true || !mounted) { return; } setState(() { _resettingSpelling = true; _validationMessage = null; }); final bool ok = await QuestionsHubService.instance.resetSpellingProgress(); if (!mounted) { return; } setState(() { _resettingSpelling = false; if (!ok) { _validationMessage = 'Could not reset spelling progress.'; } }); } Future _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 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 mix = QuestionSourceConfigRepository.effectiveMix(_draft); final int enabledCount = _draft.where((QuestionSourcePreference s) => s.enabled).length; return Stack( children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 12, 8, 16), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ 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: _resettingSpelling || _saving ? null : () => 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: [ 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], onRatioChanged: (int ratio) => _updateRatio(index, ratio), onEnabledChanged: (bool enabled) => _toggleSourceEnabled(index, enabled), onEditSpelling: _draft[index].id == 'spelling' ? () => _openSpellingWordList(context) : null, onResetSpelling: _draft[index].id == 'spelling' ? _confirmResetSpellingProgress : null, resetSpellingBusy: _resettingSpelling, ), ], ), ), ), const SizedBox(height: 8), Text( enabledCount == 0 ? 'No sources enabled.' : 'Effective mix: ${mix.entries.map((MapEntry 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) ...[ const SizedBox(height: 8), Text( _validationMessage!, style: const TextStyle(color: Colors.redAccent), ), ], const SizedBox(height: 12), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( onPressed: _resettingSpelling || _saving ? null : () => Navigator.of(context).pop(), child: const Text('Cancel'), ), const SizedBox(width: 8), FilledButton( key: const Key('question-source-settings-save'), onPressed: _saving || _resettingSpelling ? null : _save, child: _saving ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Text('Save'), ), ], ), ], ), ), if (_resettingSpelling) Positioned.fill( child: ColoredBox( color: Colors.black.withValues(alpha: 0.45), child: const Center( child: SizedBox( width: 32, height: 32, child: CircularProgressIndicator( strokeWidth: 2, color: AppColors.accent, ), ), ), ), ), ], ); } } class _QuestionSourceRow extends StatefulWidget { const _QuestionSourceRow({ super.key, required this.source, required this.effectivePercent, required this.onRatioChanged, required this.onEnabledChanged, this.onEditSpelling, this.onResetSpelling, this.resetSpellingBusy = false, }); final QuestionSourcePreference source; final int? effectivePercent; final ValueChanged onRatioChanged; final ValueChanged onEnabledChanged; final VoidCallback? onEditSpelling; final VoidCallback? onResetSpelling; final bool resetSpellingBusy; @override State<_QuestionSourceRow> createState() => _QuestionSourceRowState(); } class _QuestionSourceRowState extends State<_QuestionSourceRow> { late final TextEditingController _controller; late final FocusNode _focusNode; @override void initState() { super.initState(); _controller = TextEditingController(text: '${widget.source.ratio}'); _focusNode = FocusNode(); _focusNode.addListener(_onFocusChange); } @override void didUpdateWidget(covariant _QuestionSourceRow oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.source.ratio != widget.source.ratio) { final int? parsed = int.tryParse(_controller.text); if (parsed != widget.source.ratio) { if (_controller.text.isEmpty && widget.source.ratio == 0) { return; } _controller.text = '${widget.source.ratio}'; } } } @override void dispose() { _controller.dispose(); _focusNode.removeListener(_onFocusChange); _focusNode.dispose(); super.dispose(); } void _onFocusChange() { if (!_focusNode.hasFocus) { if (_controller.text.isEmpty) { _controller.text = '0'; widget.onRatioChanged(0); } } } void _onTextChange(String text) { if (text.isEmpty) { widget.onRatioChanged(0); return; } final int? value = int.tryParse(text); if (value != null) { final int clamped = value.clamp(0, 100); if (clamped != widget.source.ratio) { widget.onRatioChanged(clamped); } } } @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: [ Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Flexible( child: Text( widget.source.label, style: Theme.of(context).textTheme.titleLarge, ), ), if (widget.source.comingSoon) ...[ 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( widget.source.description, style: Theme.of(context).textTheme.bodyLarge, ), ], ), ), if (widget.onEditSpelling != null) IconButton( key: const Key('question-source-edit-spelling'), tooltip: 'Edit spelling word list', onPressed: widget.resetSpellingBusy ? null : widget.onEditSpelling, icon: const Icon(Icons.edit, size: 20), ), if (widget.onResetSpelling != null) IconButton( key: const Key('question-source-reset-spelling'), tooltip: 'Reset spelling progress', onPressed: widget.resetSpellingBusy ? null : widget.onResetSpelling, icon: const Icon(Icons.restart_alt, size: 20), ), Switch( key: Key('question-source-enabled-${widget.source.id}'), value: widget.source.enabled, onChanged: widget.source.comingSoon ? null : (bool enabled) => widget.onEnabledChanged(enabled), ), ], ), if (widget.source.enabled && !widget.source.comingSoon) ...[ Row( children: [ const Text('Weight'), const Spacer(), SizedBox( width: 70, height: 36, child: TextField( key: Key('question-source-input-${widget.source.id}'), controller: _controller, focusNode: _focusNode, keyboardType: TextInputType.number, textAlign: TextAlign.center, style: const TextStyle( color: AppColors.accent, fontWeight: FontWeight.w600, fontSize: 14, ), decoration: InputDecoration( contentPadding: const EdgeInsets.symmetric( horizontal: 6, vertical: 8, ), suffixText: '%', suffixStyle: const TextStyle( color: AppColors.accent, fontWeight: FontWeight.w600, fontSize: 14, ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(6), borderSide: const BorderSide( color: AppColors.accent, ), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(6), borderSide: BorderSide( color: AppColors.accent.withValues(alpha: 0.3), ), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(6), borderSide: const BorderSide( color: AppColors.accent, ), ), ), inputFormatters: [ FilteringTextInputFormatter.digitsOnly, ], onChanged: (String text) { final int? val = int.tryParse(text); if (val != null) { if (val > 100) { _controller.text = '100'; _controller.selection = TextSelection.fromPosition( const TextPosition(offset: 3), ); widget.onRatioChanged(100); } else { _onTextChange(text); } } else { _onTextChange(text); } }, ), ), ], ), Slider( key: Key('question-source-ratio-${widget.source.id}'), value: widget.source.ratio.toDouble(), min: 0, max: 100, divisions: 100, label: '${widget.source.ratio}', onChanged: (double value) => widget.onRatioChanged(value.round()), ), ], ], ), ), ), ); } }