Files
GroundedHelper/frontend/lib/Grounded/see/home/HomeState.dart
alvocool 16bff634b5 Initial commit: Grounded Flutter frontend
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
2026-07-27 09:11:17 +03:00

697 lines
21 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:stacked/stacked.dart';
import '../../about/external/data/Commitment.dart';
import '../../about/external/data/ExcuseCluster.dart';
import '../../about/internal/application/CommitmentClass.dart';
import '../../about/internal/application/CommitmentStatus.dart';
import '../../about/internal/application/NavigatorType.dart';
import '../../about/internal/application/NotificationType.dart';
import '../../about/internal/application/Standing.dart';
import '../../about/internal/application/TextType.dart';
import '../../about/internal/application/ToneLevel.dart';
import '../../about/internal/application/UserDetails.dart';
import '../../configs/Navigator.dart';
import '../../designs/Component.dart';
import '../../designs/Responsive.dart';
import '../../designs/Shell.dart';
import '../../designs/buttons/Buttons.dart';
import '../../designs/text/Text.dart';
import '../../utils/Colors.dart';
import '../../utils/CommonUtils.dart';
import '../../utils/DebtEngine.dart';
import '../../utils/StandingEngine.dart';
import '../../utils/Thresholds.dart';
import '../../utils/ToneEngine.dart';
import '../commitment/NewCommitment.dart';
import '../excuse/ExcuseReport.dart';
import '../goal/Goals.dart';
import '../overdue/OverdueQueue.dart';
import '../reportcard/ReportCardScreen.dart';
import '../settings/Settings.dart';
import '../training/Training.dart';
import 'ConnectHome.dart';
import 'Home.dart';
import 'ViewHome.dart';
class HomeState extends State<Home> implements ConnectHome {
ViewHome? _model;
UserDetails _user = UserDetails(pic: '', name: '');
List<Commitment> _plan = <Commitment>[];
List<Commitment> _overdue = <Commitment>[];
Standing _standing = Standing.Good;
double _debt = 0;
ExcuseCluster? _insight;
bool _distressed = false;
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewHome>.reactive(
viewModelBuilder: () => ViewHome(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?.initialise();
}
// ── Handlers ──────────────────────────────────────────────────────────────
void _onOpenOverdue() async {
final result = await GroundedNavigation()
.navigateToPageWithData(const OverdueQueue(), context);
if (result == true) {
_model?.loadPlan();
}
}
void _onAddCommitment() async {
if (!StandingEngine.permitsNewCommitment(_standing)) {
_model?.requestNewCommitment(_standing, CommitmentClass.Standard);
return;
}
final result = await GroundedNavigation()
.navigateToPageWithData(const NewCommitment(), context);
if (result == true) {
_model?.loadPlan();
}
}
void _onOpenReportCard() {
GroundedNavigation().navigateToPage(
NavigatorType.justOpen, const ReportCardScreen(), context);
}
void _onOpenGoals() async {
final result = await GroundedNavigation()
.navigateToPageWithData(const Goals(), context);
if (result == true) {
_model?.loadPlan();
}
}
void _onOpenTraining() {
GroundedNavigation()
.navigateToPage(NavigatorType.justOpen, const Training(), context);
}
void _onOpenSettings() {
GroundedNavigation()
.navigateToPage(NavigatorType.justOpen, const Settings(), context);
}
void _onOpenExcuses() {
GroundedNavigation()
.navigateToPage(NavigatorType.justOpen, const ExcuseReport(), context);
}
// ── Views ─────────────────────────────────────────────────────────────────
Widget _mobileView(BoxConstraints constraints) {
// Grounded and Lockdown replace the home screen with the overdue queue —
// you do not get to look at your nice plans, only at your mess.
final bool queueIsHome =
StandingEngine.showsOverdueQueueAsHome(_standing) && !_distressed;
return Sheet(
eyebrow: _user.name.isEmpty ? "Grounded" : _user.name,
title: queueIsHome ? "What you owe" : "Today",
chrome: _distressed ? colorPrimaryDark : _chromeFor(_standing),
banner: _standingBanner(),
action: chromeAction(
CupertinoIcons.person,
_onOpenSettings,
dotted: _user.sickMode,
dotColor: colorStandingWarned,
),
child: _distressed
? _distressBody()
: queueIsHome
? _groundedBody()
: _planBody(),
);
}
/// The chrome carries the standing colour, so the tier is legible before a
/// single word is read.
Color _chromeFor(Standing standing) {
switch (standing) {
case Standing.Good:
return colorPrimaryDark;
case Standing.Warned:
return colorPrimaryDark;
case Standing.Grounded:
return colorStandingGrounded;
case Standing.Lockdown:
return colorStandingLockdown;
}
}
/// The debt strip that sits in the black chrome under the title.
Widget _standingBanner() {
if (_distressed) {
return const SizedBox.shrink();
}
final Color tone = standingColor(_standing);
return Container(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
decoration: BoxDecoration(
color: colorWhite.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(16),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
color: _standing == Standing.Good ? tone : colorWhite,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
text(
standingLabel(_standing).toUpperCase(),
9,
TextType.Bold,
color: colorWhite.withValues(alpha: 0.75),
letterSpacing: 1.2,
),
],
),
const SizedBox(height: 8),
text(
ToneEngine.standingHeadline(_standing, _user.tone),
16,
TextType.Medium,
color: colorWhite,
),
],
),
),
const SizedBox(width: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
text("DEBT", 9, TextType.Bold,
color: colorWhite.withValues(alpha: 0.45),
letterSpacing: 1.0),
const SizedBox(height: 4),
text(formatDebt(_debt), 30, TextType.Light, color: colorWhite),
],
),
],
),
);
}
/// The normal day: the plan, with the overdue count kept visible above it so
/// it is never out of sight.
Widget _planBody() {
final int overdueCount = _overdue.length;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (overdueCount > 0) ...[
_overdueCallout(overdueCount),
const SizedBox(height: 24),
],
if (_insight != null) ...[
_insightCard(_insight!),
const SizedBox(height: 24),
],
sectionBreak(
"The plan",
caption: "${_plan.length} committed",
trailing: _plan.isEmpty
? null
: text(formatMinutes(_plannedMinutes()), 12, TextType.Bold,
color: colorGrey2),
),
if (_plan.isEmpty)
emptyState(
CupertinoIcons.square_list,
"Nothing committed today",
"An empty plan is a decision too. Add something you actually intend to do.",
)
else
..._plan.map(_commitmentRow),
const SizedBox(height: 28),
_quickLinks(),
const SizedBox(height: 24),
roundedCornerButton(
"Commit to something",
_onAddCommitment,
icon: CupertinoIcons.add,
enabled: StandingEngine.permitsNewCommitment(_standing),
),
if (!StandingEngine.permitsNewCommitment(_standing)) ...[
const SizedBox(height: 10),
text(
ToneEngine.standingBody(_standing, _user.tone),
12,
TextType.Regular,
color: colorGrey2,
align: TextAlign.center,
),
],
],
);
}
/// Grounded: the plan is hidden entirely and only the mess is shown.
Widget _groundedBody() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
text("YOUR PLANS ARE HIDDEN", 10, TextType.Bold,
color: colorGrey2, letterSpacing: 1.2),
const SizedBox(height: 10),
displayTitle(
_standing == Standing.Lockdown
? "One at a time."
: "Clear this first.",
),
const SizedBox(height: 12),
text(
ToneEngine.standingBody(_standing, _user.tone),
14,
TextType.Regular,
color: colorGrey2,
height: 1.55,
),
const SizedBox(height: 28),
card(
background: standingBackground(_standing),
borderColor: standingColor(_standing).withValues(alpha: 0.20),
child: Row(
children: [
Expanded(
child: labelled(
"Open overdue",
"${_overdue.length}",
valueSize: 26,
valueType: TextType.Light,
valueColor: standingColor(_standing),
),
),
Expanded(
child: labelled(
"Debt to clear",
formatDebt(
StandingEngine.debtToNextTierDown(_debt, _standing)),
valueSize: 26,
valueType: TextType.Light,
valueColor: standingColor(_standing),
),
),
],
),
),
const SizedBox(height: 24),
sectionBreak("Outstanding", caption: "${_overdue.length} items"),
if (_overdue.isEmpty)
emptyState(
CupertinoIcons.checkmark_seal,
"The queue is empty",
"Your standing will recover as the debt decays.",
)
else
..._overdue.take(_standing == Standing.Lockdown ? 1 : _overdue.length)
.map(_commitmentRow),
const SizedBox(height: 24),
roundedCornerButton(
_standing == Standing.Lockdown ? "Deal with this one" : "Open the queue",
_onOpenOverdue,
background: standingColor(_standing),
icon: CupertinoIcons.arrow_right,
),
],
);
}
/// Distress: the strict persona drops entirely. This is the difference
/// between a product people keep and one they resent.
Widget _distressBody() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
text("A NOTE", 10, TextType.Bold, color: colorGrey2, letterSpacing: 1.2),
const SizedBox(height: 10),
displayTitle(ToneEngine.distressHeadline()),
const SizedBox(height: 14),
text(
ToneEngine.distressBody(),
15,
TextType.Regular,
color: colorGrey2,
height: 1.6,
),
const SizedBox(height: 28),
card(
background: colorStandingGoodBg,
borderColor: colorPositive.withValues(alpha: 0.20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text("PAUSED", 9, TextType.Bold,
color: colorPositive, letterSpacing: 1.2),
const SizedBox(height: 8),
text("Debt is not accruing right now.", 16, TextType.Medium,
color: colorPrimaryDark),
const SizedBox(height: 6),
text(
"Nothing you miss this week is counting against you.",
13,
TextType.Regular,
color: colorGrey2,
height: 1.5,
),
],
),
),
const SizedBox(height: 24),
sectionBreak("Three things", caption: "that actually matter"),
..._plan
.where((item) =>
item.commitmentClass == CommitmentClass.NonNegotiable)
.take(3)
.map(_commitmentRow),
const SizedBox(height: 24),
outlinedActionButton("Open settings", _onOpenSettings,
icon: CupertinoIcons.slider_horizontal_3),
],
);
}
Widget _overdueCallout(int count) {
return card(
background: colorStandingGroundedBg,
borderColor: colorStandingGrounded.withValues(alpha: 0.20),
onTap: _onOpenOverdue,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 44,
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
color: colorStandingGrounded,
borderRadius: BorderRadius.circular(13),
),
child: text("$count", 17, TextType.Bold, color: colorWhite),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text("OVERDUE", 9, TextType.Bold,
color: colorStandingGrounded, letterSpacing: 1.2),
const SizedBox(height: 5),
text(
count >= Thresholds.maxOpenOverdue
? "You are at the cap. Nothing new until this drops."
: "$count item${count == 1 ? "" : "s"} past the window.",
14,
TextType.Medium,
color: colorPrimaryDark,
),
],
),
),
Icon(CupertinoIcons.chevron_right,
size: 15, color: colorStandingGrounded),
],
),
);
}
/// The excuse confrontation. One pattern, stated plainly, with the
/// suggestion attached.
Widget _insightCard(ExcuseCluster cluster) {
return card(
background: colorPrimaryDark,
borderColor: colorPrimaryDark,
onTap: _onOpenExcuses,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
text("PATTERN", 9, TextType.Bold,
color: colorWhite.withValues(alpha: 0.45),
letterSpacing: 1.2),
text("${cluster.occurrences}×", 11, TextType.Bold,
color: colorWhite.withValues(alpha: 0.45)),
],
),
const SizedBox(height: 12),
text(cluster.insight, 15, TextType.Regular,
color: colorWhite, height: 1.55),
],
),
);
}
Widget _commitmentRow(Commitment item) {
final bool late = item.windowClosed &&
item.status != CommitmentStatus.Completed &&
item.status != CommitmentStatus.LateCompleted;
final Color accent = classColor(item.commitmentClass);
return Container(
margin: const EdgeInsets.only(bottom: 10),
child: card(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 14),
onTap: _onOpenOverdue,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 3,
height: 42,
margin: const EdgeInsets.only(right: 14, top: 2),
decoration: BoxDecoration(
color: accent,
borderRadius: BorderRadius.circular(4),
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text(item.title, 15, TextType.Medium,
color: colorPrimaryDark,
maxLines: 2,
overflow: TextOverflow.ellipsis),
const SizedBox(height: 8),
Row(
children: [
text(formatWindow(item), 11, TextType.Regular,
color: colorGrey2),
const SizedBox(width: 10),
Container(width: 3, height: 3, decoration: BoxDecoration(
color: colorGrey, shape: BoxShape.circle)),
const SizedBox(width: 10),
text(formatMinutes(item.estMinutes), 11,
TextType.Regular, color: colorGrey2),
],
),
if (late) ...[
const SizedBox(height: 10),
Row(
children: [
pill(
overdueLabel(item),
colorStandingGrounded,
colorStandingGroundedBg,
textSize: 9,
),
const SizedBox(width: 6),
pill(
"${formatDebt(DebtEngine.commitmentDebt(item))}",
colorGrey2,
colorMuted,
textSize: 9,
),
],
),
],
],
),
),
const SizedBox(width: 10),
pill(
classLabel(item.commitmentClass),
accent,
classBackground(item.commitmentClass),
textSize: 9,
),
],
),
),
);
}
Widget _quickLinks() {
return Row(
children: [
Expanded(
child: _quickLink(
CupertinoIcons.flag_fill,
"Goals",
_onOpenGoals,
),
),
const SizedBox(width: 10),
Expanded(
child: _quickLink(
CupertinoIcons.chart_bar_alt_fill,
"Report",
_onOpenReportCard,
),
),
const SizedBox(width: 10),
Expanded(
child: _quickLink(
CupertinoIcons.flame_fill,
"Training",
_onOpenTraining,
),
),
],
);
}
Widget _quickLink(IconData icon, String label, VoidCallback onTap) {
return card(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 16),
onTap: onTap,
child: Row(
children: [
Icon(icon, size: 17, color: colorPrimaryDark),
const SizedBox(width: 10),
Expanded(
child: text(label, 13, TextType.Medium,
color: colorPrimaryDark, maxLines: 1,
overflow: TextOverflow.ellipsis),
),
],
),
);
}
double _plannedMinutes() {
double total = 0;
for (Commitment item in _plan) {
total = total + item.estMinutes;
}
return total;
}
// ── ConnectHome ───────────────────────────────────────────────────────────
@override
void onUserLoaded(UserDetails details) {
setState(() {
_user = details;
});
}
@override
void onPlanLoaded(List<Commitment> plan) {
setState(() {
_plan = plan;
});
}
@override
void onOverdueLoaded(List<Commitment> overdue) {
setState(() {
_overdue = overdue;
});
}
@override
void onStandingResolved(Standing standing, double debtScore) {
setState(() {
_standing = standing;
_debt = debtScore;
});
}
@override
void onExcuseInsight(ExcuseCluster? cluster) {
setState(() {
_insight = cluster;
});
}
@override
void onDistressDetected() {
setState(() {
_distressed = true;
});
}
@override
void onCreationBlocked(String reason) {
_model?.showApplicationNotification(
NotificationType.warning,
"Not right now",
reason,
true,
true,
null,
);
}
}