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
This commit is contained in:
552
frontend/lib/Grounded/see/commitment/NewCommitmentState.dart
Normal file
552
frontend/lib/Grounded/see/commitment/NewCommitmentState.dart
Normal file
@@ -0,0 +1,552 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../about/external/data/Commitment.dart';
|
||||
import '../../about/external/initial/CommitmentRequest.dart';
|
||||
import '../../about/internal/application/CommitmentClass.dart';
|
||||
import '../../about/internal/application/EnergyCost.dart';
|
||||
import '../../about/internal/application/NotificationType.dart';
|
||||
import '../../about/internal/application/ProofType.dart';
|
||||
import '../../about/internal/application/TextType.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/CapacityEngine.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import '../../utils/CommonUtils.dart';
|
||||
import '../../utils/Validators.dart';
|
||||
import 'ConnectNewCommitment.dart';
|
||||
import 'NewCommitment.dart';
|
||||
import 'ViewNewCommitment.dart';
|
||||
|
||||
class NewCommitmentState extends State<NewCommitment>
|
||||
implements ConnectNewCommitment {
|
||||
ViewNewCommitment? _model;
|
||||
|
||||
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
||||
|
||||
final TextEditingController _title = TextEditingController();
|
||||
|
||||
final TextEditingController _category = TextEditingController();
|
||||
|
||||
final TextEditingController _estimate = TextEditingController();
|
||||
|
||||
CommitmentClass _class = CommitmentClass.Standard;
|
||||
|
||||
EnergyCost _energy = EnergyCost.Medium;
|
||||
|
||||
ProofType _proof = ProofType.Honour;
|
||||
|
||||
DateTime? _windowStart;
|
||||
|
||||
DateTime? _windowEnd;
|
||||
|
||||
double _multiplier = 1.0;
|
||||
|
||||
String _windowError = "";
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewNewCommitment>.reactive(
|
||||
viewModelBuilder: () => ViewNewCommitment(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() {
|
||||
// A window, not a date: the default opens now and closes in two hours, so
|
||||
// the field is never left as a bare day.
|
||||
final DateTime now = DateTime.now();
|
||||
setState(() {
|
||||
_windowStart = now;
|
||||
_windowEnd = now.add(const Duration(hours: 2));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
void _onBack() {
|
||||
Navigator.pop(context, false);
|
||||
}
|
||||
|
||||
void _onCategoryChanged(String value) {
|
||||
_model?.resolveMultiplier(value.trim());
|
||||
}
|
||||
|
||||
void _onClassSelected(CommitmentClass value) {
|
||||
setState(() {
|
||||
_class = value;
|
||||
// Non-negotiables carry real consequences, so they default to real proof
|
||||
// rather than the honour checkbox.
|
||||
if (value == CommitmentClass.NonNegotiable &&
|
||||
_proof == ProofType.Honour) {
|
||||
_proof = ProofType.Photo;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onEnergySelected(EnergyCost value) {
|
||||
setState(() {
|
||||
_energy = value;
|
||||
});
|
||||
}
|
||||
|
||||
void _onProofSelected(ProofType value) {
|
||||
setState(() {
|
||||
_proof = value;
|
||||
});
|
||||
}
|
||||
|
||||
void _onPickWindowStart() async {
|
||||
final DateTime? picked = await _pickMoment(_windowStart);
|
||||
if (picked == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_windowStart = picked;
|
||||
if (_windowEnd == null || !_windowEnd!.isAfter(picked)) {
|
||||
_windowEnd = picked.add(const Duration(hours: 2));
|
||||
}
|
||||
_windowError = "";
|
||||
});
|
||||
}
|
||||
|
||||
void _onPickWindowEnd() async {
|
||||
final DateTime? picked = await _pickMoment(_windowEnd);
|
||||
if (picked == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_windowEnd = picked;
|
||||
_windowError = Validators.window(_windowStart, _windowEnd) ?? "";
|
||||
});
|
||||
}
|
||||
|
||||
Future<DateTime?> _pickMoment(DateTime? initial) async {
|
||||
final DateTime base = initial ?? DateTime.now();
|
||||
|
||||
final DateTime? day = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: base,
|
||||
firstDate: DateTime.now().subtract(const Duration(days: 1)),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||
);
|
||||
|
||||
if (day == null || !mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final TimeOfDay? time = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay.fromDateTime(base),
|
||||
);
|
||||
|
||||
if (time == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return DateTime(day.year, day.month, day.day, time.hour, time.minute);
|
||||
}
|
||||
|
||||
void _onSave() {
|
||||
final String? windowIssue = Validators.window(_windowStart, _windowEnd);
|
||||
|
||||
if (windowIssue != null) {
|
||||
setState(() {
|
||||
_windowError = windowIssue;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (_formKey.currentState?.validate() != true) {
|
||||
return;
|
||||
}
|
||||
|
||||
_model?.save(_buildRequest(), _buildCandidate());
|
||||
}
|
||||
|
||||
CommitmentRequest _buildRequest() {
|
||||
return CommitmentRequest(
|
||||
commitmentClass: _class.name,
|
||||
title: _title.text.trim(),
|
||||
category: _category.text.trim(),
|
||||
dueStart: _windowStart?.toIso8601String() ?? "",
|
||||
dueEnd: _windowEnd?.toIso8601String() ?? "",
|
||||
estMinutes: int.tryParse(_estimate.text.trim()) ?? 0,
|
||||
energy: _energy.name,
|
||||
proofType: _proof.name,
|
||||
proofTimerMinutes:
|
||||
_proof == ProofType.Timer ? int.tryParse(_estimate.text.trim()) ?? 0 : 0,
|
||||
);
|
||||
}
|
||||
|
||||
Commitment _buildCandidate() {
|
||||
return Commitment(
|
||||
commitmentClass: _class,
|
||||
title: _title.text.trim(),
|
||||
category: _category.text.trim(),
|
||||
dueStart: _windowStart,
|
||||
dueEnd: _windowEnd,
|
||||
estMinutes: int.tryParse(_estimate.text.trim()) ?? 0,
|
||||
energy: _energy,
|
||||
proofType: _proof,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Views ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Widget _mobileView(BoxConstraints constraints) {
|
||||
final int estimate = int.tryParse(_estimate.text.trim()) ?? 0;
|
||||
|
||||
final bool multiplierWorthShowing = _multiplier > 1.15 && estimate > 0;
|
||||
|
||||
return Sheet(
|
||||
eyebrow: "New",
|
||||
title: "Commitment",
|
||||
onBack: _onBack,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
displayTitle("What are you\ncommitting to?"),
|
||||
const SizedBox(height: 28),
|
||||
inputField(
|
||||
"Title",
|
||||
_title,
|
||||
hint: "Say it the way you would say it out loud",
|
||||
validator: Validators.title,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
inputField(
|
||||
"Category",
|
||||
_category,
|
||||
hint: "Admin, gym, deep work…",
|
||||
onChanged: _onCategoryChanged,
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
sectionBreak("Class", caption: "decides the weight"),
|
||||
segmentedSelector<CommitmentClass>(
|
||||
options: CommitmentClass.values,
|
||||
selected: _class,
|
||||
label: classLabel,
|
||||
onSelected: _onClassSelected,
|
||||
activeColor: classColor(_class),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
text(
|
||||
_classDescription(_class),
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: colorGrey2,
|
||||
height: 1.5,
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
sectionBreak("Window", caption: "not just a day"),
|
||||
if (_windowError.isNotEmpty) ...[
|
||||
text(_windowError, 11, TextType.Regular, color: colorNegative),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: selectField(
|
||||
"Opens",
|
||||
_windowStart == null ? "" : formatDateTime(_windowStart),
|
||||
_onPickWindowStart,
|
||||
icon: CupertinoIcons.calendar,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: selectField(
|
||||
"Closes",
|
||||
_windowEnd == null ? "" : formatDateTime(_windowEnd),
|
||||
_onPickWindowEnd,
|
||||
icon: CupertinoIcons.calendar,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
text(
|
||||
"When this window closes, the item goes overdue. It does not roll over to tomorrow.",
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: colorGrey2,
|
||||
height: 1.5,
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
sectionBreak("Effort"),
|
||||
inputField(
|
||||
"Estimated minutes",
|
||||
_estimate,
|
||||
hint: "How long you think it takes",
|
||||
validator: Validators.estimateMinutes,
|
||||
keyboard: TextInputType.number,
|
||||
onChanged: (value) => setState(() {}),
|
||||
),
|
||||
if (multiplierWorthShowing) ...[
|
||||
const SizedBox(height: 12),
|
||||
card(
|
||||
background: colorStandingWarnedBg,
|
||||
borderColor: colorStandingWarned.withValues(alpha: 0.20),
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(CupertinoIcons.info_circle_fill,
|
||||
size: 15, color: colorStandingWarned),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: text(
|
||||
"On ${_category.text.trim().isEmpty ? "this category" : _category.text.trim()} you historically take ${_multiplier.toStringAsFixed(1)}× your estimate. Your day is planned against ${formatMinutes(estimate * _multiplier)}.",
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: colorPrimaryDark,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
text("ENERGY COST", 9, TextType.Bold,
|
||||
color: colorGrey2, letterSpacing: 1.0),
|
||||
const SizedBox(height: 10),
|
||||
segmentedSelector<EnergyCost>(
|
||||
options: EnergyCost.values,
|
||||
selected: _energy,
|
||||
label: (value) => value.name,
|
||||
onSelected: _onEnergySelected,
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
sectionBreak("Proof", caption: "the checkbox is the enemy"),
|
||||
segmentedSelector<ProofType>(
|
||||
options: ProofType.values,
|
||||
selected: _proof,
|
||||
label: proofLabel,
|
||||
onSelected: _onProofSelected,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
text(
|
||||
_proofDescription(_proof),
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: colorGrey2,
|
||||
height: 1.5,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
roundedCornerButton(
|
||||
"Commit",
|
||||
_onSave,
|
||||
icon: CupertinoIcons.checkmark,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Center(
|
||||
child: text(
|
||||
"You can change the details later. You cannot change the history.",
|
||||
11,
|
||||
TextType.Regular,
|
||||
color: colorGrey2,
|
||||
align: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _classDescription(CommitmentClass value) {
|
||||
switch (value) {
|
||||
case CommitmentClass.NonNegotiable:
|
||||
return "Never deferrable, heaviest debt, hardest escalation. Meds, deadlines, rent.";
|
||||
case CommitmentClass.Standard:
|
||||
return "Normal weight, two deferrals, then it is complete or abandon.";
|
||||
case CommitmentClass.Elective:
|
||||
return "No debt if you miss it. Auto-archives if it sits untouched.";
|
||||
}
|
||||
}
|
||||
|
||||
String _proofDescription(ProofType value) {
|
||||
switch (value) {
|
||||
case ProofType.Honour:
|
||||
return "A plain checkbox. Fine for trivia, worthless for anything you actually lie to yourself about.";
|
||||
case ProofType.Photo:
|
||||
return "Camera only, no gallery. Timestamped, and near-duplicate photos get flagged.";
|
||||
case ProofType.Timer:
|
||||
return "A foreground session. Backgrounding the app pauses the clock.";
|
||||
case ProofType.Location:
|
||||
return "Geofence dwell. Being near it does not count as being there.";
|
||||
case ProofType.Health:
|
||||
return "Your health platform confirms the workout happened inside the window.";
|
||||
case ProofType.Witness:
|
||||
return "Someone else confirms it. The hardest one to talk your way around.";
|
||||
}
|
||||
}
|
||||
|
||||
/// The capacity refusal. It shows the arithmetic rather than just saying no,
|
||||
/// because the point is to make overcommitment visible.
|
||||
void _openCapacitySheet(CapacityVerdict verdict) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
barrierColor: colorPrimaryDark.withValues(alpha: 0.6),
|
||||
builder: (BuildContext sheetContext) {
|
||||
return 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: 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("OVER CAPACITY", 9, TextType.Bold,
|
||||
color: colorStandingWarned, letterSpacing: 1.2),
|
||||
const SizedBox(height: 10),
|
||||
text("This day is already full.", 26, TextType.Light,
|
||||
color: colorPrimaryDark, height: 1.2),
|
||||
const SizedBox(height: 14),
|
||||
text(verdict.message, 14, TextType.Regular,
|
||||
color: colorGrey2, height: 1.55),
|
||||
const SizedBox(height: 24),
|
||||
card(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: labelled(
|
||||
"Planned",
|
||||
formatMinutes(verdict.projectedMinutes),
|
||||
valueSize: 20,
|
||||
valueType: TextType.Light,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: labelled(
|
||||
"You do",
|
||||
formatMinutes(verdict.historicalMinutes),
|
||||
valueSize: 20,
|
||||
valueType: TextType.Light,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: labelled(
|
||||
"Cut",
|
||||
formatMinutes(verdict.excessMinutes),
|
||||
valueSize: 20,
|
||||
valueType: TextType.Light,
|
||||
valueColor: colorStandingGrounded,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
text(
|
||||
"Chronic overdue is usually an overcommitment problem wearing a laziness costume. Cutting something now is the cheapest fix available.",
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: colorGrey2,
|
||||
height: 1.5,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
roundedCornerButton(
|
||||
"Let me cut something",
|
||||
() => Navigator.pop(sheetContext),
|
||||
icon: CupertinoIcons.scissors,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Center(
|
||||
child: textButton(
|
||||
"Add it anyway",
|
||||
() {
|
||||
Navigator.pop(sheetContext);
|
||||
_model?.saveAnyway(_buildRequest());
|
||||
},
|
||||
textSize: 12,
|
||||
color: colorGrey2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── ConnectNewCommitment ──────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onSaved() {
|
||||
_model?.showApplicationNotification(
|
||||
NotificationType.success,
|
||||
"Committed",
|
||||
"It is on the record now. The window closes ${formatDateTime(_windowEnd)}.",
|
||||
true,
|
||||
true,
|
||||
() {
|
||||
Navigator.pop(context, true);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void onCapacityBlocked(CapacityVerdict verdict) {
|
||||
_openCapacitySheet(verdict);
|
||||
}
|
||||
|
||||
@override
|
||||
void onMultiplierResolved(double multiplier) {
|
||||
setState(() {
|
||||
_multiplier = multiplier;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_title.dispose();
|
||||
_category.dispose();
|
||||
_estimate.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user