import 'package:flutter/material.dart'; import '../admin/widgets/admin_app_bar_action.dart'; import '../models/app_user.dart'; import '../models/guess_score_summary.dart'; import '../models/incoming_question.dart'; import '../utils/guess_slot_format.dart'; import '../models/sync_result.dart'; import '../models/user_profile.dart'; 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/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({ super.key, required this.user, this.profile, this.syncStatus = ProfileSyncStatus.idle, }); final AppUser user; final UserProfile? profile; final ProfileSyncStatus syncStatus; @override Widget build(BuildContext context) { final String? photoUrl = profile?.photoUrl ?? user.photoUrl; final String displayName = profile?.displayName ?? user.displayName ?? 'there'; final String? email = profile?.email ?? user.email; final Color syncIconColor = switch (syncStatus) { ProfileSyncStatus.syncing => Colors.white, ProfileSyncStatus.synced => AppColors.success, ProfileSyncStatus.error => Colors.redAccent, ProfileSyncStatus.offline => Colors.orange, ProfileSyncStatus.idle => AppColors.textSecondary, }; return Stack( children: [ Scaffold( appBar: AppBar( backgroundColor: Colors.transparent, elevation: 0, scrolledUnderElevation: 0, automaticallyImplyLeading: false, titleSpacing: 8, centerTitle: false, title: ListenableBuilder( listenable: Listenable.merge([ 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: [ if (showMarketScore) _CumulativeScoreChip( summary: score, onPressed: () => _showGuessScoreStatsDialog(context, score), ), if (showMarketScore && showEnvelope) const SizedBox(width: 8), if (showEnvelope) ...[ 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(), ), ], ], ); }, ), actions: [ const AdminAppBarAction(), IconButton( 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( tooltip: 'Sign out', onPressed: () => AuthService.instance.signOut(), icon: const Icon(Icons.logout), ), const SizedBox(width: 4), ], ), body: DecoratedBox( decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [AppColors.background, AppColors.surface], ), ), child: SafeArea( child: ListenableBuilder( listenable: Listenable.merge([ QuestionsHubService.instance.questionPanelOpen, QuestionsHubService.instance.hasPendingQuestion, QuestionsHubService.instance.questionQueue, 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 IncomingQuestion? question = hub.currentQuestion; final sources = QuestionSourceConfigRepository.instance.current; final bool marketEnabled = QuestionSourceConfigRepository.isMarketHistoryEnabled( sources, ); final bool spellingEnabled = QuestionSourceConfigRepository.isSpellingEnabled(sources); final bool spellingFullyWeighted = QuestionSourceConfigRepository.isSpellingFullyWeighted( sources, ); final bool showSpellingPlaceholder = panelOpen && spellingEnabled && (spellingFullyWeighted || !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, children: [ if (showQuestionPanel) Expanded( child: Padding( padding: const EdgeInsets.fromLTRB(8, 4, 8, 8), child: FocusScope( autofocus: true, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ const Spacer(), IconButton( onPressed: hub.closeQuestionPanel, tooltip: 'Close', icon: const Icon(Icons.close), ), ], ), const SizedBox(height: 4), Expanded( child: QuestionPresentationRouter( question: question, busy: hub.questionActionBusy.value, onAnswer: (num answer) => hub.submitCurrentAnswer(answer: answer), onAnswerText: hub.submitCurrentSpellingAnswer, onPrepared: hub.applyPreparedQuestion, onDefer: hub.deferCurrentQuestion, ), ), ], ), ), ), ), if (showSpellingPlaceholder) Expanded( child: Padding( padding: const EdgeInsets.fromLTRB(8, 4, 8, 8), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ 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: [ Row( children: [ 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), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( padding: const EdgeInsets.all(24), decoration: BoxDecoration( color: AppColors.surfaceElevated, borderRadius: BorderRadius.circular(16), border: Border.all( color: AppColors.accent.withValues(alpha: 0.2), ), ), child: Column( children: [ ProfileAvatar( photoUrl: photoUrl, radius: 36, ), const SizedBox(height: 16), Text( 'Welcome, $displayName', style: Theme.of(context) .textTheme .headlineMedium, textAlign: TextAlign.center, ), if (email != null) ...[ const SizedBox(height: 8), Text( email, style: Theme.of(context).textTheme.bodyLarge, textAlign: TextAlign.center, ), ], const SizedBox(height: 12), _SyncStatusChip(status: syncStatus), const SizedBox(height: 20), const Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.check_circle, color: AppColors.success, size: 20, ), SizedBox(width: 8), Text( 'You\'re signed in', style: TextStyle( color: AppColors.success, fontWeight: FontWeight.w600, ), ), ], ), ], ), ), const SizedBox(height: 24), Text( profile?.dirty == true ? 'Profile saved locally. Will sync when online.' : 'Profile synced with your account.', style: Theme.of(context).textTheme.bodyLarge, textAlign: TextAlign.center, ), if (UserProfileRepository.instance.usesLocalStore) ...[ const SizedBox(height: 16), OutlinedButton.icon( onPressed: profile == null ? null : () async { final UserProfile current = profile!; await UserProfileRepository.instance .updateProfile( current.copyWith( onboardingCompleted: !current .onboardingCompleted, ), ); }, icon: const Icon(Icons.toggle_on_outlined), label: Text( profile?.onboardingCompleted == true ? 'Mark onboarding incomplete' : 'Mark onboarding complete', ), ), ], ], ), ), ), ], ); }, ), ), ), ), ListenableBuilder( listenable: Listenable.merge([ QuestionsHubService.instance.scoreResetBusy, QuestionsHubService.instance.spellingResetBusy, ]), builder: (BuildContext context, Widget? child) { final bool blocking = QuestionsHubService.instance.scoreResetBusy.value || QuestionsHubService.instance.spellingResetBusy.value; if (!blocking) { return const SizedBox.shrink(); } return Stack( key: QuestionsHubService.instance.spellingResetBusy.value ? const Key('spelling-progress-reset-blocking-overlay') : const Key('guess-score-reset-blocking-overlay'), children: [ ModalBarrier( dismissible: false, color: Colors.black.withValues(alpha: 0.54), ), const Center( child: CircularProgressIndicator(color: AppColors.accent), ), ], ); }, ), ], ); } } void _showGuessScoreStatsDialog(BuildContext context, GuessScoreSummary summary) { final String totalText = _formatScoreNumber(summary.total); final String percentText = _formatScoreNumber(summary.percentCorrect); final String? slotText = summary.slotStart == null ? null : formatGuessSlotRange( slotStart: summary.slotStart!, newerSlotStart: summary.newerSlotStart, ); showDialog( context: context, builder: (BuildContext dialogContext) { return AlertDialog( key: const Key('guess-score-stats-dialog'), title: const Text('Score statistics'), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (slotText != null) _ScoreStatRow( key: const Key('guess-score-slot-row'), label: 'Time slot', value: slotText, ), _ScoreStatRow(label: 'Total score', value: totalText), _ScoreStatRow( label: 'Questions answered', value: '${summary.answersTotal}', ), _ScoreStatRow(label: 'Percent correct', value: '$percentText%'), ], ), actions: [ TextButton( key: const Key('guess-score-reset-button'), onPressed: () => _confirmResetGuessScore(dialogContext), child: const Text('Reset'), ), TextButton( onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('Close'), ), ], ); }, ); } String _formatScoreNumber(num value) { return value == value.roundToDouble() ? value.toStringAsFixed(0) : value.toStringAsFixed(2); } Future _confirmResetGuessScore(BuildContext context) async { final bool? confirmed = await showDialog( context: context, builder: (BuildContext context) { return AlertDialog( key: const Key('guess-score-reset-confirm-dialog'), title: const Text('Reset score?'), content: const Text( 'This clears your total score and answer statistics. ' 'You cannot undo this.', ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel'), ), TextButton( key: const Key('guess-score-reset-confirm-button'), onPressed: () => Navigator.of(context).pop(true), child: const Text('Reset'), ), ], ); }, ); if (confirmed != true || !context.mounted) { return; } Navigator.of(context).pop(); await QuestionsHubService.instance.resetGuessScoreSummary(); } class _ScoreStatRow extends StatelessWidget { const _ScoreStatRow({ super.key, required this.label, required this.value, }); final String label; final String value; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(label, style: Theme.of(context).textTheme.bodyLarge), Text( value, style: Theme.of(context).textTheme.bodyLarge?.copyWith( fontWeight: FontWeight.w700, color: AppColors.accent, ), ), ], ), ); } } class _CumulativeScoreChip extends StatelessWidget { const _CumulativeScoreChip({ required this.summary, required this.onPressed, }); final GuessScoreSummary summary; final VoidCallback onPressed; @override Widget build(BuildContext context) { final String scoreText = summary.total == summary.total.roundToDouble() ? summary.total.toStringAsFixed(0) : summary.total.toStringAsFixed(2); return Material( color: Colors.transparent, child: InkWell( key: const Key('topbar-cumulative-score'), onTap: onPressed, borderRadius: BorderRadius.circular(999), child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: AppColors.accent.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(999), border: Border.all(color: AppColors.accent.withValues(alpha: 0.35)), ), child: Text( 'Score: $scoreText', style: const TextStyle( color: AppColors.accent, fontWeight: FontWeight.w700, fontSize: 12, ), ), ), ), ); } } class _SyncStatusChip extends StatelessWidget { const _SyncStatusChip({required this.status}); final ProfileSyncStatus status; @override Widget build(BuildContext context) { final ({String label, Color color, IconData icon}) style = switch (status) { ProfileSyncStatus.syncing => ( label: 'Syncing…', color: AppColors.accent, icon: Icons.sync, ), ProfileSyncStatus.synced => ( label: 'Synced', color: AppColors.success, icon: Icons.cloud_done_outlined, ), ProfileSyncStatus.offline => ( label: 'Offline', color: Colors.orange, icon: Icons.cloud_off_outlined, ), ProfileSyncStatus.error => ( label: 'Sync error', color: Colors.redAccent, icon: Icons.error_outline, ), ProfileSyncStatus.idle => ( label: 'Ready', color: AppColors.accent, icon: Icons.cloud_queue_outlined, ), }; return Chip( avatar: Icon(style.icon, size: 18, color: style.color), label: Text(style.label), side: BorderSide(color: style.color.withValues(alpha: 0.4)), ); } }