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 implements ConnectOverdueQueue { ViewOverdueQueue? _model; List _queue = []; Standing _standing = Standing.Good; double _debt = 0; bool _changed = false; @override Widget build(BuildContext context) { return ViewModelBuilder.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 formKey = GlobalKey(); 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 formKey = GlobalKey(); 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 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, ); } }