Files
GroundedHelper/frontend/lib/Grounded/see/overdue/OverdueQueueState.dart
alvocool c88887b639 Add tick boxes to task rows
Tasks now carry a tick box rather than being read-only rows. Ticking strikes
the title through and leaves the item in place, so a day still reads as a
record of what was asked rather than only what is left.

New in designs/Checkbox.dart:

- TickBox   the square box itself, animating between empty and filled
- TickRow   box + struck-through title + caller-supplied detail and trailing
- addTaskAffordance  the centred "Add new task" control beneath a day's list

Applied to the home plan, the overdue queue and the goal task list. Each
screen holds a set of optimistically ticked ids so the tick lands immediately
and rolls back if the call fails; the set is reconciled whenever fresh data
arrives.

A tick completes the item outright, whatever its proof requirement. The proof
flow is still reachable from the Complete button in the overdue queue, so
tasks that need an artefact have a route that asks for one — but the tick
itself no longer enforces it.

Late completes stay visually distinct: the row keeps a Late pill and the
warning accent, and the user is told the window had already closed.

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-31 21:51:15 +03:00

706 lines
24 KiB
Dart

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/Checkbox.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;
/// Ticked locally, awaiting the server. Rolls back on failure.
final Set<String> _ticked = <String>{};
@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);
}
/// The tick completes the item outright. The Complete button still runs the
/// proof flow, so a task that needs an artefact has a route that asks for
/// one — the tick is the quick path, not the only one.
void _onTick(Commitment item) {
if (item.id == null || _ticked.contains(item.id)) {
return;
}
setState(() {
_ticked.add(item.id!);
});
_model?.complete(
item,
CompletionRequest(
commitmentId: item.id ?? "",
proofType: item.proofType.name,
),
);
}
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: [
TickRow(
checked: _ticked.contains(item.id),
title: item.title,
accent: accent,
onTick: () => _onTick(item),
detail: text(formatWindow(item), 11, TextType.Regular,
color: colorGrey2),
trailing: 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(() {
_ticked.removeWhere((id) => queue.every((item) => item.id != id));
_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,
);
}
}