Files
GroundedHelper/frontend/lib/Grounded/see/commitment/NewCommitmentState.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

553 lines
18 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/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();
}
}