A to-do app that doesn't believe you — an enforcement layer rather than a
neutral ledger.
Architecture ported from Autoreceptives/Frontend/Receptive: stacked MVVM with
the mandatory 4-file screen pattern, one ParentViewModel owning the loading /
network / error overlays and the handleError decision tree, one AppDataManager
gateway, dio comms carrying the three identity headers, secure storage with
random-suffixed keys, and a single-chokepoint Navigator. Package root and Dart
package name are both Grounded; org is nya.
The enforcement engine, one unit per formula in utils/:
- DebtEngine w(class) x severity(d) x decay(t), sublinear severity so old
misses cannot swamp the score; abandonment 2x with 30-day
decay immunity; late complete retains 30%
- StandingEngine Good -> Warned -> Grounded -> Lockdown, derived not set;
Grounded replaces home with the overdue queue
- CapacityEngine blocks over-scheduling against p50 of historically completed
minutes, with a learned per-category estimation multiplier
- IntegrityEngine session integrity, weekly volume, plyometric contact ceiling
and enforced recovery gaps
- ExcuseAnalyser on-device excuse clustering plus the confrontation copy
- GuardrailEngine distress detection and rationed amnesty
- ToneEngine all enforcement copy, so the tone cap lives in one place
CommitmentEvent is append-only and is the source of truth rather than the
status field, which is what makes honest history and excuse analysis possible.
Goals contain commitments via parentId, and a task can be run from a
full-screen runner that derives elapsed time from wall-clock so screen-off
cannot lose time. Backgrounding pauses the clock and is counted. The runner is
mirrored into an ongoing notification, with alarm-class full-screen intents
reserved for non-negotiables.
Design language, fonts, icon and native splash are in place; Mason bricks are
retargeted to this project and verified end-to-end.
flutter analyze lib/ reports no errors.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mfu2gQLSFN21YRBcU2NrTt
375 lines
11 KiB
Dart
375 lines
11 KiB
Dart
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<ReportCardScreen>
|
||
implements ConnectReportCardScreen {
|
||
ViewReportCardScreen? _model;
|
||
|
||
ReportCard _report = ReportCard();
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ViewModelBuilder<ViewReportCardScreen>.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<FlSpot> spots = <FlSpot>[];
|
||
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>[
|
||
LineChartBarData(
|
||
spots: spots,
|
||
isCurved: true,
|
||
barWidth: 2.5,
|
||
color: colorDebtLine,
|
||
dotData: const FlDotData(show: false),
|
||
belowBarData: BarAreaData(show: true, color: colorDebtFill),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _completionRow(MapEntry<String, double> 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<String, double> 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;
|
||
});
|
||
}
|
||
}
|