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:
691
frontend/lib/Grounded/see/overdue/OverdueQueueState.dart
Normal file
691
frontend/lib/Grounded/see/overdue/OverdueQueueState.dart
Normal file
@@ -0,0 +1,691 @@
|
||||
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/CompletionRequest.dart';
|
||||
import '../../about/internal/application/CommitmentClass.dart';
|
||||
import '../../about/internal/application/NotificationType.dart';
|
||||
import '../../about/internal/application/ProofType.dart';
|
||||
import '../../about/internal/application/Standing.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/Colors.dart';
|
||||
import '../../utils/CommonUtils.dart';
|
||||
import '../../utils/DebtEngine.dart';
|
||||
import '../../utils/Thresholds.dart';
|
||||
import '../../utils/Validators.dart';
|
||||
import 'ConnectOverdueQueue.dart';
|
||||
import 'OverdueQueue.dart';
|
||||
import 'ViewOverdueQueue.dart';
|
||||
|
||||
class OverdueQueueState extends State<OverdueQueue>
|
||||
implements ConnectOverdueQueue {
|
||||
ViewOverdueQueue? _model;
|
||||
|
||||
List<Commitment> _queue = <Commitment>[];
|
||||
|
||||
Standing _standing = Standing.Good;
|
||||
|
||||
double _debt = 0;
|
||||
|
||||
bool _changed = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewOverdueQueue>.reactive(
|
||||
viewModelBuilder: () => ViewOverdueQueue(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?.loadQueue();
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
void _onBack() {
|
||||
Navigator.pop(context, _changed);
|
||||
}
|
||||
|
||||
void _onComplete(Commitment item) {
|
||||
// Honour proof settles immediately; everything else has to produce its
|
||||
// artefact before the completion is accepted.
|
||||
if (item.proofType == ProofType.Honour) {
|
||||
_model?.complete(
|
||||
item,
|
||||
CompletionRequest(
|
||||
commitmentId: item.id ?? "",
|
||||
proofType: item.proofType.name,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_openProofSheet(item);
|
||||
}
|
||||
|
||||
void _onDefer(Commitment item) {
|
||||
_openDeferralSheet(item);
|
||||
}
|
||||
|
||||
void _onAbandon(Commitment item) {
|
||||
_openAbandonSheet(item);
|
||||
}
|
||||
|
||||
void _onAmnesty(Commitment item) {
|
||||
_model?.spendAmnesty(item);
|
||||
}
|
||||
|
||||
// ── Sheets ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The deferral sheet. The excuse field is the whole point of the screen —
|
||||
/// free text, minimum length, no template buttons to route around it.
|
||||
void _openDeferralSheet(Commitment item) {
|
||||
final TextEditingController excuse = TextEditingController();
|
||||
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
barrierColor: colorPrimaryDark.withValues(alpha: 0.6),
|
||||
builder: (BuildContext sheetContext) {
|
||||
return StatefulBuilder(
|
||||
builder: (BuildContext sheetContext, StateSetter setSheetState) {
|
||||
final int remaining =
|
||||
Thresholds.maxDeferralsPerTask - item.deferralCount;
|
||||
|
||||
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("DEFERRING", 9, TextType.Bold,
|
||||
color: colorGrey2, letterSpacing: 1.2),
|
||||
const SizedBox(height: 10),
|
||||
text(item.title, 26, TextType.Light,
|
||||
color: colorPrimaryDark, height: 1.2),
|
||||
const SizedBox(height: 14),
|
||||
text(
|
||||
remaining <= 1
|
||||
? "This is the last deferral this task gets. After it, the only options are completing or abandoning."
|
||||
: "$remaining deferrals left on this task.",
|
||||
13,
|
||||
TextType.Regular,
|
||||
color: colorGrey2,
|
||||
height: 1.5,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
excuseField(
|
||||
excuse,
|
||||
Thresholds.minExcuseLength,
|
||||
validator: Validators.excuse,
|
||||
onChanged: (value) => setSheetState(() {}),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
roundedCornerButton(
|
||||
"Defer with this reason",
|
||||
() {
|
||||
if (formKey.currentState?.validate() != true) {
|
||||
return;
|
||||
}
|
||||
Navigator.pop(sheetContext);
|
||||
_model?.defer(item, excuse.text);
|
||||
},
|
||||
icon: CupertinoIcons.clock,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Center(
|
||||
child: textButton(
|
||||
"Cancel",
|
||||
() => Navigator.pop(sheetContext),
|
||||
textSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _openAbandonSheet(Commitment item) {
|
||||
final TextEditingController reason = TextEditingController();
|
||||
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
barrierColor: colorPrimaryDark.withValues(alpha: 0.6),
|
||||
builder: (BuildContext sheetContext) {
|
||||
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("ABANDONING", 9, TextType.Bold,
|
||||
color: colorStandingLockdown, letterSpacing: 1.2),
|
||||
const SizedBox(height: 10),
|
||||
text(item.title, 26, TextType.Light,
|
||||
color: colorPrimaryDark, height: 1.2),
|
||||
const SizedBox(height: 14),
|
||||
card(
|
||||
background: colorStandingLockdownBg,
|
||||
borderColor:
|
||||
colorStandingLockdown.withValues(alpha: 0.20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text("THE COST", 9, TextType.Bold,
|
||||
color: colorStandingLockdown, letterSpacing: 1.2),
|
||||
const SizedBox(height: 8),
|
||||
text(
|
||||
"Abandoning is the most expensive outcome there is. It costs double weight and will not decay for 30 days.",
|
||||
13,
|
||||
TextType.Regular,
|
||||
color: colorPrimaryDark,
|
||||
height: 1.5,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
inputField(
|
||||
"Reason",
|
||||
reason,
|
||||
hint: "Why is this never happening?",
|
||||
validator: Validators.excuse,
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
destructiveButton(
|
||||
"Abandon permanently",
|
||||
() {
|
||||
if (formKey.currentState?.validate() != true) {
|
||||
return;
|
||||
}
|
||||
Navigator.pop(sheetContext);
|
||||
_model?.abandon(item, reason.text);
|
||||
},
|
||||
icon: CupertinoIcons.xmark_circle,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Center(
|
||||
child: textButton(
|
||||
"Keep it",
|
||||
() => Navigator.pop(sheetContext),
|
||||
textSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Proof types other than Honour need their artefact. The sheet states what
|
||||
/// is required rather than letting the user tap a checkbox and move on.
|
||||
void _openProofSheet(Commitment item) {
|
||||
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("PROOF REQUIRED", 9, TextType.Bold,
|
||||
color: colorGrey2, letterSpacing: 1.2),
|
||||
const SizedBox(height: 10),
|
||||
text(proofLabel(item.proofType), 26, TextType.Light,
|
||||
color: colorPrimaryDark, height: 1.2),
|
||||
const SizedBox(height: 14),
|
||||
text(
|
||||
_proofDescription(item),
|
||||
14,
|
||||
TextType.Regular,
|
||||
color: colorGrey2,
|
||||
height: 1.55,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
roundedCornerButton(
|
||||
_proofAction(item.proofType),
|
||||
() {
|
||||
Navigator.pop(sheetContext);
|
||||
_model?.complete(
|
||||
item,
|
||||
CompletionRequest(
|
||||
commitmentId: item.id ?? "",
|
||||
proofType: item.proofType.name,
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: _proofIcon(item.proofType),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Center(
|
||||
child: textButton(
|
||||
"Not now",
|
||||
() => Navigator.pop(sheetContext),
|
||||
textSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _proofDescription(Commitment item) {
|
||||
switch (item.proofType) {
|
||||
case ProofType.Honour:
|
||||
return "Your word is enough for this one.";
|
||||
case ProofType.Photo:
|
||||
return "Camera only — no gallery imports. The timestamp is embedded and near-duplicate submissions are flagged.";
|
||||
case ProofType.Timer:
|
||||
return "A foreground session of at least ${item.proofTimerMinutes} minutes. Leaving the app pauses the clock.";
|
||||
case ProofType.Location:
|
||||
return "You need to have actually been there. Dwell time inside the geofence counts, passing by does not.";
|
||||
case ProofType.Health:
|
||||
return "Your health platform has to confirm a workout inside the window.";
|
||||
case ProofType.Witness:
|
||||
return "Your accountability partner confirms this one.";
|
||||
}
|
||||
}
|
||||
|
||||
String _proofAction(ProofType type) {
|
||||
switch (type) {
|
||||
case ProofType.Honour:
|
||||
return "Mark complete";
|
||||
case ProofType.Photo:
|
||||
return "Open camera";
|
||||
case ProofType.Timer:
|
||||
return "Start the timer";
|
||||
case ProofType.Location:
|
||||
return "Check my location";
|
||||
case ProofType.Health:
|
||||
return "Check health data";
|
||||
case ProofType.Witness:
|
||||
return "Request confirmation";
|
||||
}
|
||||
}
|
||||
|
||||
IconData _proofIcon(ProofType type) {
|
||||
switch (type) {
|
||||
case ProofType.Honour:
|
||||
return CupertinoIcons.checkmark;
|
||||
case ProofType.Photo:
|
||||
return CupertinoIcons.camera_fill;
|
||||
case ProofType.Timer:
|
||||
return CupertinoIcons.timer;
|
||||
case ProofType.Location:
|
||||
return CupertinoIcons.location_fill;
|
||||
case ProofType.Health:
|
||||
return CupertinoIcons.heart_fill;
|
||||
case ProofType.Witness:
|
||||
return CupertinoIcons.person_2_fill;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Views ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Widget _mobileView(BoxConstraints constraints) {
|
||||
return Sheet(
|
||||
eyebrow: "Outstanding",
|
||||
title: "What you owe",
|
||||
chrome: _queue.isEmpty ? colorPrimaryDark : standingColor(_standing),
|
||||
onBack: _onBack,
|
||||
banner: _debtBanner(),
|
||||
child: _queue.isEmpty
|
||||
? emptyState(
|
||||
CupertinoIcons.checkmark_seal,
|
||||
"Nothing outstanding",
|
||||
"The queue is empty. Your standing recovers as the remaining debt decays.",
|
||||
accent: colorPositive,
|
||||
)
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
sectionBreak(
|
||||
"The queue",
|
||||
caption: "oldest first",
|
||||
trailing: pill(
|
||||
"${_queue.length} / ${Thresholds.maxOpenOverdue}",
|
||||
_queue.length >= Thresholds.maxOpenOverdue
|
||||
? colorStandingGrounded
|
||||
: colorGrey2,
|
||||
_queue.length >= Thresholds.maxOpenOverdue
|
||||
? colorStandingGroundedBg
|
||||
: colorMuted,
|
||||
textSize: 9,
|
||||
),
|
||||
),
|
||||
..._queue.map(_queueRow),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _debtBanner() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
|
||||
decoration: BoxDecoration(
|
||||
color: colorWhite.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text("TOTAL DEBT", 9, TextType.Bold,
|
||||
color: colorWhite.withValues(alpha: 0.55),
|
||||
letterSpacing: 1.2),
|
||||
const SizedBox(height: 6),
|
||||
text(formatDebt(_debt), 32, TextType.Light, color: colorWhite),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text("STANDING", 9, TextType.Bold,
|
||||
color: colorWhite.withValues(alpha: 0.55),
|
||||
letterSpacing: 1.2),
|
||||
const SizedBox(height: 6),
|
||||
text(standingLabel(_standing), 15, TextType.Medium,
|
||||
color: colorWhite),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _queueRow(Commitment item) {
|
||||
final Color accent = classColor(item.commitmentClass);
|
||||
|
||||
final bool deferrable =
|
||||
item.deferrableUnder(Thresholds.maxDeferralsPerTask);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: card(
|
||||
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(item.title, 17, TextType.Medium,
|
||||
color: colorPrimaryDark, maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
const SizedBox(height: 8),
|
||||
text(formatWindow(item), 11, TextType.Regular,
|
||||
color: colorGrey2),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
pill(
|
||||
classLabel(item.commitmentClass),
|
||||
accent,
|
||||
classBackground(item.commitmentClass),
|
||||
textSize: 9,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: labelled(
|
||||
"Overdue",
|
||||
overdueLabel(item),
|
||||
valueSize: 13,
|
||||
valueColor: colorStandingGrounded,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: labelled(
|
||||
"Costing",
|
||||
formatDebt(DebtEngine.commitmentDebt(item)),
|
||||
valueSize: 13,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: labelled(
|
||||
"Deferred",
|
||||
"${item.deferralCount}/${Thresholds.maxDeferralsPerTask}",
|
||||
valueSize: 13,
|
||||
valueColor:
|
||||
deferrable ? colorPrimaryDark : colorStandingGrounded,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
hairline(margin: const EdgeInsets.symmetric(vertical: 16)),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: roundedCornerButton(
|
||||
"Complete",
|
||||
() => _onComplete(item),
|
||||
icon: CupertinoIcons.checkmark,
|
||||
verticalPadding: 13,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: outlinedActionButton(
|
||||
deferrable ? "Defer" : "No deferrals",
|
||||
() => _onDefer(item),
|
||||
enabled: deferrable &&
|
||||
item.commitmentClass != CommitmentClass.NonNegotiable,
|
||||
icon: CupertinoIcons.clock,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
textButton("Amnesty", () => _onAmnesty(item),
|
||||
textSize: 12, color: colorGrey2),
|
||||
textButton("Abandon", () => _onAbandon(item),
|
||||
textSize: 12, color: colorStandingLockdown),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── ConnectOverdueQueue ───────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onQueueLoaded(List<Commitment> queue, Standing standing, double debt) {
|
||||
setState(() {
|
||||
_queue = queue;
|
||||
_standing = standing;
|
||||
_debt = debt;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onItemCleared(Commitment item, double reliefApplied, String verb) {
|
||||
_changed = true;
|
||||
|
||||
_model?.showApplicationNotification(
|
||||
verb == "Abandoned"
|
||||
? NotificationType.warning
|
||||
: NotificationType.success,
|
||||
verb,
|
||||
verb == "Late complete"
|
||||
? "Recorded as a late complete. It reduces the debt but does not clear it — the miss stays in your history."
|
||||
: reliefApplied > 0
|
||||
? "${formatDebt(reliefApplied)} came off your debt."
|
||||
: "Recorded.",
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void onDeferralRefused(String reason) {
|
||||
_model?.showApplicationNotification(
|
||||
NotificationType.warning,
|
||||
"Not deferrable",
|
||||
reason,
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void onAmnestySpent(int remaining) {
|
||||
_changed = true;
|
||||
|
||||
_model?.showApplicationNotification(
|
||||
NotificationType.success,
|
||||
"Amnesty applied",
|
||||
"No questions asked. $remaining token${remaining == 1 ? "" : "s"} left this month.",
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void onAmnestyRefused() {
|
||||
_model?.showApplicationNotification(
|
||||
NotificationType.info,
|
||||
"No tokens left",
|
||||
"You have spent this month's amnesty. They reset at the start of next month.",
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user