Files
GroundedHelper/frontend/lib/Grounded/see/overdue/OverdueQueueState.dart
alvocool d3431ca5de Switch to Inter, round the corners softly and make the nav switch tabs
Inter replaces General Sans (Inter Display for titles and big numbers).
Corners come back from before the squaring commit, scaled to a soft set
(16 cards, 14 buttons, 12 rows, 8 chips) and applied to the screens built
since. The nav is now a cluster of round frosted buttons that switches
Today, Routines, Goals, Groups and Progress in place inside Home: no
pushed pages, no back arrows, and Android back returns to Today.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
2026-11-09 07:08:46 +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(16),
topRight: Radius.circular(16),
),
),
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(14),
),
),
),
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(16),
topRight: Radius.circular(16),
),
),
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(14),
),
),
),
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(16),
topRight: Radius.circular(16),
),
),
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(14),
),
),
),
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(12),
),
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,
);
}
}