cyberhybridhub/lib/screens/home_screen.dart
nathanAndrinoid a521a74214
Some checks failed
Admin portal tests / test (push) Has been cancelled
balance controled working spelling and market questions.
2026-06-17 04:20:45 +00:00

546 lines
22 KiB
Dart

import 'package:flutter/material.dart';
import '../admin/widgets/admin_app_bar_action.dart';
import '../models/app_user.dart';
import '../models/incoming_question.dart';
import '../models/question_source_preference.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.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);
if (!showEnvelope) {
return const SizedBox.shrink();
}
return 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 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: <Widget>[
if (panelOpen)
_QuestionSourceConfigTopBar(sources: sources),
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: Listenable.merge(<Listenable>[
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: <Widget>[
ModalBarrier(
dismissible: false,
color: Colors.black.withValues(alpha: 0.54),
),
const Center(
child: CircularProgressIndicator(color: AppColors.accent),
),
],
);
},
),
],
);
}
}
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)),
);
}
}
class _QuestionSourceConfigTopBar extends StatelessWidget {
const _QuestionSourceConfigTopBar({
required this.sources,
});
final List<QuestionSourcePreference> sources;
@override
Widget build(BuildContext context) {
final Map<String, int> mix =
QuestionSourceConfigRepository.effectiveMix(sources);
final String mixText = mix.isEmpty
? 'No sources enabled'
: mix.entries.map((MapEntry<String, int> entry) {
final QuestionSourcePreference source = sources.firstWhere(
(QuestionSourcePreference s) => s.id == entry.key,
);
return '${source.label}: ${entry.value}%';
}).join(', ');
return Container(
key: const Key('question-source-config-top-bar'),
decoration: BoxDecoration(
color: AppColors.surfaceElevated,
border: Border(
bottom: BorderSide(
color: AppColors.accent.withValues(alpha: 0.15),
width: 1,
),
),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: <Widget>[
const Icon(
Icons.tune_outlined,
color: AppColors.accent,
size: 20,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
const Text(
'Question source configuration',
style: TextStyle(
color: AppColors.textPrimary,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
mixText,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 11,
),
overflow: TextOverflow.ellipsis,
),
],
),
),
TextButton.icon(
key: const Key('question-source-config-top-bar-button'),
onPressed: () => QuestionSourceSettingsSheet.show(context),
icon: const Icon(
Icons.settings_outlined,
size: 14,
color: AppColors.accent,
),
label: const Text(
'Configure',
style: TextStyle(
color: AppColors.accent,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
backgroundColor: AppColors.accent.withValues(alpha: 0.08),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
side: BorderSide(
color: AppColors.accent.withValues(alpha: 0.2),
),
),
),
),
],
),
);
}
}