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 implements ConnectNewCommitment { ViewNewCommitment? _model; final GlobalKey _formKey = GlobalKey(); 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.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 _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( 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( options: EnergyCost.values, selected: _energy, label: (value) => value.name, onSelected: _onEnergySelected, ), const SizedBox(height: 28), sectionBreak("Proof", caption: "the checkbox is the enemy"), segmentedSelector( 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(); } }