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
312 lines
11 KiB
Dart
312 lines
11 KiB
Dart
import 'package:flutter/cupertino.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:stacked/stacked.dart';
|
|
|
|
import '../../about/external/data/Goal.dart';
|
|
import '../../about/external/initial/GoalRequest.dart';
|
|
import '../../about/internal/application/CommitmentClass.dart';
|
|
import '../../about/internal/application/NavigatorType.dart';
|
|
import '../../about/internal/application/TextType.dart';
|
|
import '../../configs/Navigator.dart';
|
|
import '../../designs/Component.dart';
|
|
import '../../designs/Responsive.dart';
|
|
import '../../designs/Shell.dart';
|
|
import '../../designs/buttons/Buttons.dart';
|
|
import '../../designs/input/InputFields.dart';
|
|
import '../../designs/text/Text.dart';
|
|
import '../../utils/Colors.dart';
|
|
import '../../utils/CommonUtils.dart';
|
|
import '../../utils/Validators.dart';
|
|
import 'ConnectGoals.dart';
|
|
import 'GoalDetail.dart';
|
|
import 'Goals.dart';
|
|
import 'ViewGoals.dart';
|
|
|
|
class GoalsState extends State<Goals> implements ConnectGoals {
|
|
ViewGoals? _model;
|
|
|
|
List<Goal> _goals = <Goal>[];
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ViewModelBuilder<ViewGoals>.reactive(
|
|
viewModelBuilder: () => ViewGoals(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?.loadGoals();
|
|
}
|
|
|
|
void _onBack() {
|
|
Navigator.pop(context);
|
|
}
|
|
|
|
void _onOpenGoal(Goal goal) async {
|
|
final result = await GroundedNavigation()
|
|
.navigateToPageWithData(GoalDetail(goal: goal), context);
|
|
|
|
if (result == true) {
|
|
_model?.loadGoals();
|
|
}
|
|
}
|
|
|
|
void _onNewGoal() {
|
|
_openGoalSheet();
|
|
}
|
|
|
|
/// Goals are lightweight on purpose — a name and a default class. The
|
|
/// weight lives on the tasks inside them.
|
|
void _openGoalSheet() {
|
|
final TextEditingController title = TextEditingController();
|
|
final TextEditingController description = TextEditingController();
|
|
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
|
|
|
|
CommitmentClass defaultClass = CommitmentClass.Standard;
|
|
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
barrierColor: colorPrimaryDark.withValues(alpha: 0.6),
|
|
builder: (BuildContext sheetContext) {
|
|
return StatefulBuilder(
|
|
builder: (BuildContext sheetContext, StateSetter setSheetState) {
|
|
return Padding(
|
|
padding: EdgeInsets.only(
|
|
bottom: MediaQuery.of(sheetContext).viewInsets.bottom,
|
|
),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: colorSheetBackground,
|
|
borderRadius: const BorderRadius.only(
|
|
topLeft: Radius.circular(28),
|
|
topRight: Radius.circular(28),
|
|
),
|
|
),
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
|
|
child: Form(
|
|
key: formKey,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Center(
|
|
child: Container(
|
|
width: 36,
|
|
height: 4,
|
|
margin: const EdgeInsets.only(bottom: 20),
|
|
decoration: BoxDecoration(
|
|
color: colorGrey.withValues(alpha: 0.35),
|
|
borderRadius: BorderRadius.circular(999),
|
|
),
|
|
),
|
|
),
|
|
text("NEW GOAL", 9, TextType.Bold,
|
|
color: colorGrey2, letterSpacing: 1.2),
|
|
const SizedBox(height: 10),
|
|
text("What are you\nworking toward?", 26,
|
|
TextType.Light,
|
|
color: colorPrimaryDark, height: 1.2),
|
|
const SizedBox(height: 20),
|
|
inputField(
|
|
"Goal",
|
|
title,
|
|
hint: "Workout, thesis, get the flat sorted…",
|
|
validator: Validators.title,
|
|
),
|
|
const SizedBox(height: 18),
|
|
inputField(
|
|
"Why it matters",
|
|
description,
|
|
hint: "Optional, but it helps on the bad days",
|
|
maxLines: 3,
|
|
),
|
|
const SizedBox(height: 20),
|
|
text("TASKS DEFAULT TO", 9, TextType.Bold,
|
|
color: colorGrey2, letterSpacing: 1.0),
|
|
const SizedBox(height: 10),
|
|
segmentedSelector<CommitmentClass>(
|
|
options: CommitmentClass.values,
|
|
selected: defaultClass,
|
|
label: classLabel,
|
|
onSelected: (value) => setSheetState(() {
|
|
defaultClass = value;
|
|
}),
|
|
activeColor: classColor(defaultClass),
|
|
),
|
|
const SizedBox(height: 24),
|
|
roundedCornerButton(
|
|
"Create goal",
|
|
() {
|
|
if (formKey.currentState?.validate() != true) {
|
|
return;
|
|
}
|
|
Navigator.pop(sheetContext);
|
|
_model?.save(GoalRequest(
|
|
title: title.text.trim(),
|
|
description: description.text.trim(),
|
|
defaultClass: defaultClass.name,
|
|
startDate: DateTime.now().toIso8601String(),
|
|
));
|
|
},
|
|
icon: CupertinoIcons.add,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Center(
|
|
child: textButton("Cancel",
|
|
() => Navigator.pop(sheetContext),
|
|
textSize: 13),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _mobileView(BoxConstraints constraints) {
|
|
return Sheet(
|
|
eyebrow: "Grounded",
|
|
title: "Goals",
|
|
onBack: _onBack,
|
|
action: chromeAction(CupertinoIcons.add, _onNewGoal),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
displayTitle("What you are\nworking toward."),
|
|
const SizedBox(height: 14),
|
|
text(
|
|
"A goal holds the tasks that get you there. The goal never carries debt — the tasks inside it do.",
|
|
14,
|
|
TextType.Regular,
|
|
color: colorGrey2,
|
|
height: 1.55,
|
|
),
|
|
const SizedBox(height: 28),
|
|
if (_goals.isEmpty)
|
|
emptyState(
|
|
CupertinoIcons.flag,
|
|
"No goals yet",
|
|
"Create one — Workout, say — then put the actual sessions inside it.",
|
|
)
|
|
else
|
|
..._goals.map(_goalCard),
|
|
const SizedBox(height: 24),
|
|
roundedCornerButton("New goal", _onNewGoal,
|
|
icon: CupertinoIcons.add),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _goalCard(Goal goal) {
|
|
final Color accent =
|
|
goal.slipping ? colorStandingGrounded : colorPrimaryDark;
|
|
|
|
return Container(
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
child: card(
|
|
onTap: () => _onOpenGoal(goal),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
text(goal.title, 19, TextType.Light,
|
|
color: colorPrimaryDark,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis),
|
|
if (goal.description.isNotEmpty) ...[
|
|
const SizedBox(height: 6),
|
|
text(goal.description, 12, TextType.Regular,
|
|
color: colorGrey2,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
if (goal.overdueTasks > 0)
|
|
pill("${goal.overdueTasks} late", colorStandingGrounded,
|
|
colorStandingGroundedBg, textSize: 9),
|
|
],
|
|
),
|
|
const SizedBox(height: 18),
|
|
meter(goal.progress, fill: accent),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: labelled(
|
|
"Done",
|
|
"${goal.completedTasks} of ${goal.totalTasks}",
|
|
valueSize: 13,
|
|
),
|
|
),
|
|
Expanded(
|
|
child: labelled(
|
|
"Remaining",
|
|
"${goal.remainingTasks}",
|
|
valueSize: 13,
|
|
),
|
|
),
|
|
Expanded(
|
|
child: labelled(
|
|
"Debt",
|
|
formatDebt(goal.debtContribution),
|
|
valueSize: 13,
|
|
valueColor: goal.debtContribution > 0
|
|
? colorStandingGrounded
|
|
: colorPrimaryDark,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
void onGoalsLoaded(List<Goal> goals) {
|
|
setState(() {
|
|
_goals = goals;
|
|
});
|
|
}
|
|
|
|
@override
|
|
void onGoalSaved() {
|
|
_model?.loadGoals();
|
|
}
|
|
}
|