cyberhybridhub/lib/screens/home_screen.dart
nathanAndrinoid 4459852f83
Some checks failed
Admin portal tests / test (push) Has been cancelled
spelling
2026-06-05 03:22:35 +00:00

616 lines
24 KiB
Dart

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: <Widget>[
Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
automaticallyImplyLeading: false,
titleSpacing: 8,
centerTitle: false,
title: ListenableBuilder(
listenable: Listenable.merge(<Listenable>[
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: <Widget>[
if (showMarketScore)
_CumulativeScoreChip(
summary: score,
onPressed: () =>
_showGuessScoreStatsDialog(context, score),
),
if (showMarketScore && showEnvelope)
const SizedBox(width: 8),
if (showEnvelope) ...<Widget>[
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: <Widget>[
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: <Color>[AppColors.background, AppColors.surface],
),
),
child: SafeArea(
child: ListenableBuilder(
listenable: Listenable.merge(<Listenable>[
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 showSpellingPlaceholder = panelOpen &&
spellingEnabled &&
!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: <Widget>[
if (showQuestionPanel)
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 8),
child: FocusScope(
autofocus: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Row(
children: <Widget>[
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: <Widget>[
Row(
children: <Widget>[
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: <Widget>[
Row(
children: <Widget>[
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: <Widget>[
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: <Widget>[
ProfileAvatar(
photoUrl: photoUrl,
radius: 36,
),
const SizedBox(height: 16),
Text(
'Welcome, $displayName',
style: Theme.of(context)
.textTheme
.headlineMedium,
textAlign: TextAlign.center,
),
if (email != null) ...<Widget>[
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: <Widget>[
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)
...<Widget>[
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: QuestionsHubService.instance.scoreResetBusy,
builder: (BuildContext context, Widget? child) {
if (!QuestionsHubService.instance.scoreResetBusy.value) {
return const SizedBox.shrink();
}
return Stack(
key: const Key('guess-score-reset-blocking-overlay'),
children: <Widget>[
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<void>(
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: <Widget>[
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: <Widget>[
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<void> _confirmResetGuessScore(BuildContext context) async {
final bool? confirmed = await showDialog<bool>(
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: <Widget>[
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: <Widget>[
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)),
);
}
}