import 'package:flutter/material.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(); } void _updateSource(int index, QuestionSourcePreference next) { setState(() { _draft[index] = next; _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], onChanged: (QuestionSourcePreference next) => _updateSource(index, next), 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 StatelessWidget { const _QuestionSourceRow({ super.key, required this.source, required this.effectivePercent, required this.onChanged, this.onEditSpelling, this.onResetSpelling, this.resetSpellingBusy = false, }); final QuestionSourcePreference source; final int? effectivePercent; final ValueChanged onChanged; final VoidCallback? onEditSpelling; final VoidCallback? onResetSpelling; final bool resetSpellingBusy; @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( source.label, style: Theme.of(context).textTheme.titleLarge, ), ), if (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( 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: resetSpellingBusy ? null : onEditSpelling, icon: const Icon(Icons.edit, size: 20), ), if (onResetSpelling != null) IconButton( key: const Key('question-source-reset-spelling'), tooltip: 'Reset spelling progress', onPressed: resetSpellingBusy ? null : onResetSpelling, icon: const Icon(Icons.restart_alt, 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) ...[ Row( children: [ 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())), ), ], ], ), ), ), ); } }