import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:stacked/stacked.dart'; import '../../about/external/data/ReportCard.dart'; import '../../about/internal/application/TextType.dart'; import '../../designs/Component.dart'; import '../../designs/Responsive.dart'; import '../../designs/Shell.dart'; import '../../designs/text/Text.dart'; import '../../utils/Colors.dart'; import '../../utils/CommonUtils.dart'; import 'ConnectReportCardScreen.dart'; import 'ReportCardScreen.dart'; import 'ViewReportCardScreen.dart'; class ReportCardScreenState extends State implements ConnectReportCardScreen { ViewReportCardScreen? _model; ReportCard _report = ReportCard(); @override Widget build(BuildContext context) { return ViewModelBuilder.reactive( viewModelBuilder: () => ViewReportCardScreen(context, this), onViewModelReady: (viewModel) { _model = viewModel; _initiate(); }, builder: (context, viewModel, child) => LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { return Responsive( mobile: _mobileView(constraints), tablet: _mobileView(constraints), desktop: _mobileView(constraints), ); }, ), ); } void _initiate() { _model?.loadReport(); } void _onBack() { Navigator.pop(context); } Widget _mobileView(BoxConstraints constraints) { return Sheet( eyebrow: "This week", title: "Report card", onBack: _onBack, banner: _gradeBanner(), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ if (_report.praise.isNotEmpty) ...[ _praiseCard(), const SizedBox(height: 24), ], _assignedAction(), const SizedBox(height: 28), sectionBreak("Debt", caption: "across the week"), _debtTrend(), const SizedBox(height: 28), sectionBreak("Completion", caption: "by class"), ..._report.completionByClass.entries.map(_completionRow), const SizedBox(height: 20), _worstHour(), const SizedBox(height: 28), if (_report.deferralLeaderboard.isNotEmpty) ...[ sectionBreak("Most dodged", caption: "the leaderboard"), ..._report.deferralLeaderboard.take(5).map(_deferralRow), const SizedBox(height: 28), ], if (_report.estimationAccuracy.isNotEmpty) ...[ sectionBreak("Your estimates", caption: "against reality"), ..._report.estimationAccuracy.entries.map(_estimationRow), const SizedBox(height: 28), ], if (_report.trainingAdherence > 0) ...[ sectionBreak("Training"), _trainingCard(), ], ], ), ); } /// The grade sits in the chrome. Cosmetic, but it is the thing people /// actually react to. Widget _gradeBanner() { return Container( padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), decoration: BoxDecoration( color: colorWhite.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(16), ), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ text("PERIOD", 9, TextType.Bold, color: colorWhite.withValues(alpha: 0.45), letterSpacing: 1.2), const SizedBox(height: 6), text( "${formatDate(_report.periodStart)} — ${formatDate(_report.periodEnd)}", 13, TextType.Medium, color: colorWhite, ), ], ), ), if (_report.grade.isNotEmpty) Container( width: 54, height: 54, alignment: Alignment.center, decoration: BoxDecoration( color: colorWhite, borderRadius: BorderRadius.circular(16), ), child: text(_report.grade, 26, TextType.Light, color: colorPrimaryDark), ), ], ), ); } /// Rationed but real. Only rendered when something specific was earned. Widget _praiseCard() { return card( background: colorStandingGoodBg, borderColor: colorPositive.withValues(alpha: 0.20), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(CupertinoIcons.checkmark_seal_fill, size: 18, color: colorPositive), const SizedBox(width: 12), Expanded( child: text(_report.praise, 14, TextType.Regular, color: colorPrimaryDark, height: 1.55), ), ], ), ); } /// One assigned action for next week. Not five. Widget _assignedAction() { return card( background: colorPrimaryDark, borderColor: colorPrimaryDark, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ text("NEXT WEEK, ONE THING", 9, TextType.Bold, color: colorWhite.withValues(alpha: 0.45), letterSpacing: 1.2), const SizedBox(height: 12), text( _report.assignedAction.isEmpty ? "Not enough history yet to assign anything." : _report.assignedAction, 19, TextType.Light, color: colorWhite, height: 1.4, ), ], ), ); } Widget _debtTrend() { if (_report.debtTrend.isEmpty) { return card( child: text("No debt recorded this week.", 13, TextType.Regular, color: colorGrey2), ); } final List spots = []; for (int index = 0; index < _report.debtTrend.length; index++) { spots.add(FlSpot(index.toDouble(), _report.debtTrend[index])); } return card( padding: const EdgeInsets.fromLTRB(8, 20, 16, 8), child: SizedBox( height: 150, child: LineChart( LineChartData( gridData: FlGridData( show: true, drawVerticalLine: false, getDrawingHorizontalLine: (value) => const FlLine(color: colorChartGrid, strokeWidth: 1), ), titlesData: FlTitlesData( topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), leftTitles: const AxisTitles( sideTitles: SideTitles(showTitles: true, reservedSize: 32)), bottomTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), ), borderData: FlBorderData(show: false), lineBarsData: [ LineChartBarData( spots: spots, isCurved: true, barWidth: 2.5, color: colorDebtLine, dotData: const FlDotData(show: false), belowBarData: BarAreaData(show: true, color: colorDebtFill), ), ], ), ), ), ); } Widget _completionRow(MapEntry entry) { return Container( margin: const EdgeInsets.only(bottom: 14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ text(entry.key, 13, TextType.Medium, color: colorPrimaryDark), text("${(entry.value * 100).round()}%", 13, TextType.Bold, color: colorGrey2), ], ), const SizedBox(height: 8), meter( entry.value, fill: entry.value >= 0.8 ? colorPositive : entry.value >= 0.5 ? colorStandingWarned : colorStandingGrounded, ), ], ), ); } /// The recurring window where things go to die. Widget _worstHour() { if (_report.worstHour < 0) { return const SizedBox.shrink(); } return card( background: colorStandingWarnedBg, borderColor: colorStandingWarned.withValues(alpha: 0.20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ text("YOUR WORST HOUR", 9, TextType.Bold, color: colorStandingWarned, letterSpacing: 1.2), const SizedBox(height: 10), text(hourLabel(_report.worstHour), 30, TextType.Light, color: colorPrimaryDark), const SizedBox(height: 8), text( "This is where things go to die. Stop scheduling anything that matters into it.", 13, TextType.Regular, color: colorGrey2, height: 1.5, ), ], ), ); } Widget _deferralRow(DeferralCount item) { return Container( margin: const EdgeInsets.only(bottom: 8), child: card( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13), child: Row( children: [ Expanded( child: text(item.title, 13, TextType.Regular, color: colorPrimaryDark, maxLines: 1, overflow: TextOverflow.ellipsis), ), pill("${item.count}×", colorStandingGrounded, colorStandingGroundedBg, textSize: 9), ], ), ), ); } /// Your estimates are wrong, and this is by how much. Widget _estimationRow(MapEntry entry) { return Container( margin: const EdgeInsets.only(bottom: 8), child: card( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13), child: Row( children: [ Expanded( child: text(entry.key, 13, TextType.Regular, color: colorPrimaryDark), ), text("${entry.value.toStringAsFixed(1)}×", 15, TextType.Bold, color: entry.value > 1.4 ? colorStandingGrounded : colorGrey2), ], ), ), ); } Widget _trainingCard() { return card( child: Row( children: [ Expanded( child: labelled( "Adherence", "${(_report.trainingAdherence * 100).round()}%", valueSize: 26, valueType: TextType.Light, ), ), Expanded( child: labelled( "Integrity", "${(_report.programIntegrity * 100).round()}%", valueSize: 26, valueType: TextType.Light, valueColor: _report.programIntegrity < 0.7 ? colorStandingGrounded : colorPrimaryDark, ), ), ], ), ); } @override void onReportLoaded(ReportCard report) { setState(() { _report = report; }); } }