cyberhybridhub/lib/screens/home_screen.dart
2026-06-03 04:21:42 -05:00

530 lines
19 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/profile_avatar.dart';
import '../widgets/swipe_question_tile.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 Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
centerTitle: true,
title: ListenableBuilder(
listenable: Listenable.merge(<Listenable>[
QuestionsHubService.instance.hasPendingQuestion,
QuestionsHubService.instance.pendingQuestion,
QuestionsHubService.instance.pendingQuestionCount,
QuestionsHubService.instance.guessScoreSummary,
]),
builder: (BuildContext context, Widget? child) {
final int count =
QuestionsHubService.instance.pendingQuestionCount.value;
final bool hasPending =
QuestionsHubService.instance.hasPendingQuestion.value;
final GuessScoreSummary score =
QuestionsHubService.instance.guessScoreSummary.value;
return Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
_CumulativeScoreChip(
summary: score,
onPressed: () => _showGuessScoreStatsDialog(context, score),
),
if (hasPending && count >= 1) ...<Widget>[
const SizedBox(width: 8),
_QuestionEnvelopeButton(
count: count,
onPressed: () =>
QuestionsHubService.instance.openQuestionPanel(),
),
],
],
);
},
),
actions: <Widget>[
const AdminAppBarAction(),
IconButton(
onPressed: () => UserProfileRepository.instance.sync(),
tooltip: 'Sync profile',
icon: Icon(Icons.sync, color: syncIconColor),
),
IconButton(
onPressed: () => AuthService.instance.signOut(),
tooltip: 'Sign out',
icon: const Icon(Icons.logout),
),
],
),
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,
]),
builder: (BuildContext context, Widget? child) {
final QuestionsHubService hub = QuestionsHubService.instance;
final bool panelOpen = hub.questionPanelOpen.value;
final bool hasPending = hub.hasPendingQuestion.value;
final IncomingQuestion? question = hub.currentQuestion;
final bool showQuestionPanel =
panelOpen && hasPending && question != null;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
if (showQuestionPanel)
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),
),
],
),
const SizedBox(height: 4),
Expanded(
child: SwipeQuestionTile(
key: ValueKey<String>(question.id),
questionId: question.id,
busy: hub.questionActionBusy.value,
onSwipeRight: (num answer) =>
hub.submitCurrentAnswer(answer: answer),
onSwipeLeft: hub.deferCurrentQuestion,
),
),
],
),
),
),
if (!showQuestionPanel)
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',
),
),
],
],
),
),
),
],
);
},
),
),
),
);
}
}
class _QuestionEnvelopeButton extends StatelessWidget {
const _QuestionEnvelopeButton({
required this.count,
required this.onPressed,
});
final int count;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: <Widget>[
IconButton(
onPressed: onPressed,
tooltip: count == 1 ? '1 question' : '$count questions',
icon: const Icon(Icons.mail_outline, size: 22),
style: IconButton.styleFrom(
backgroundColor: AppColors.accent.withValues(alpha: 0.15),
foregroundColor: AppColors.accent,
padding: const EdgeInsets.all(8),
minimumSize: const Size(36, 36),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
if (count >= 1)
Positioned(
top: 4,
right: 0,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
decoration: BoxDecoration(
color: AppColors.accent,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.surface, width: 1.5),
),
constraints: const BoxConstraints(minWidth: 18, minHeight: 18),
child: Text(
'$count',
textAlign: TextAlign.center,
style: const TextStyle(
color: AppColors.background,
fontSize: 11,
fontWeight: FontWeight.w700,
height: 1.1,
),
),
),
),
],
);
}
}
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;
}
final bool ok =
await QuestionsHubService.instance.resetGuessScoreSummary();
if (!context.mounted) {
return;
}
if (ok) {
Navigator.of(context).pop();
}
}
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)),
);
}
}