diff --git a/frontend/lib/Grounded/designs/Checkbox.dart b/frontend/lib/Grounded/designs/Checkbox.dart new file mode 100644 index 0000000..231f472 --- /dev/null +++ b/frontend/lib/Grounded/designs/Checkbox.dart @@ -0,0 +1,216 @@ +import 'package:flutter/material.dart'; + +import '../about/internal/application/TextType.dart'; +import '../utils/Colors.dart'; +import 'text/Text.dart'; + +/// The tick box. Square, hairline border when empty, filled with the accent +/// and a white check when set — and it animates, because the tick is the one +/// moment of satisfaction the app allows itself. +class TickBox extends StatelessWidget { + final bool checked; + + final VoidCallback? onTap; + + /// The accent used when checked. Defaults to the app's positive green; + /// callers pass the class colour so the row reads as its own weight. + final Color? accent; + + final double size; + + /// Renders muted and ignores taps — used for items that are settled. + final bool locked; + + const TickBox({ + super.key, + required this.checked, + this.onTap, + this.accent, + this.size = 24, + this.locked = false, + }); + + @override + Widget build(BuildContext context) { + final Color tone = locked ? colorGrey : (accent ?? colorPositive); + + return GestureDetector( + onTap: locked ? null : onTap, + // A bare box is a small target; the padding brings it up to a + // comfortable tap area without changing the drawn size. + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 2), + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + curve: Curves.easeOut, + width: size, + height: size, + decoration: BoxDecoration( + color: checked ? tone : Colors.transparent, + borderRadius: BorderRadius.circular(7), + border: Border.all( + color: checked ? tone : colorGrey.withValues(alpha: 0.55), + width: 1.6, + ), + ), + child: AnimatedOpacity( + duration: const Duration(milliseconds: 140), + opacity: checked ? 1 : 0, + child: Icon( + Icons.check_rounded, + size: size * 0.68, + color: colorWhite, + ), + ), + ), + ), + ); + } +} + +/// A task line: tick box, then the title struck through once it is done. +/// Everything else about the row — window, class, cost — is the caller's, +/// passed in as [detail] so this stays one shape used everywhere. +class TickRow extends StatelessWidget { + final bool checked; + + final String title; + + final VoidCallback? onTick; + + final VoidCallback? onTap; + + final Color? accent; + + /// Secondary line beneath the title. + final Widget? detail; + + /// Trailing widget — a class pill, a chevron. + final Widget? trailing; + + /// Struck through and muted, but not tickable. + final bool locked; + + const TickRow({ + super.key, + required this.checked, + required this.title, + this.onTick, + this.onTap, + this.accent, + this.detail, + this.trailing, + this.locked = false, + }); + + @override + Widget build(BuildContext context) { + final bool struck = checked || locked; + + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TickBox( + checked: checked, + onTap: onTick, + accent: accent, + locked: locked, + ), + const SizedBox(width: 13), + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 9), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // The strikethrough is the whole point of the tick: the + // line stays in place so the day still reads as a record + // of what was asked, not just what is left. + AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 160), + style: TextStyle( + fontSize: 15, + fontFamily: struck ? "GroundedRegular" : "GroundedMedium", + color: struck ? colorGrey2 : colorPrimaryDark, + decoration: struck + ? TextDecoration.lineThrough + : TextDecoration.none, + decorationColor: colorGrey2, + decorationThickness: 1.5, + height: 1.35, + ), + child: Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + if (detail != null) ...[ + const SizedBox(height: 6), + DefaultTextStyle.merge( + style: TextStyle( + decoration: TextDecoration.none, + color: colorGrey2, + ), + child: detail!, + ), + ], + ], + ), + ), + ), + if (trailing != null) ...[ + const SizedBox(width: 10), + Padding( + padding: const EdgeInsets.only(top: 8), + child: trailing!, + ), + ], + ], + ), + ), + ); + } +} + +/// "Add new task" — the centred, low-key affordance beneath a day's list. +Widget addTaskAffordance( + VoidCallback onTap, { + String label = "Add new task", + bool enabled = true, +}) { + final Color tone = enabled ? colorPositive : colorGrey; + + return GestureDetector( + onTap: enabled ? onTap : null, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 18), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 46, + height: 46, + alignment: Alignment.center, + decoration: BoxDecoration( + color: tone.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: tone.withValues(alpha: 0.25), width: 1), + ), + child: Icon(Icons.add_rounded, size: 22, color: tone), + ), + const SizedBox(height: 9), + text(label, 12, TextType.Regular, color: colorGrey2), + ], + ), + ), + ); +} diff --git a/frontend/lib/Grounded/designs/buttons/Buttons.dart b/frontend/lib/Grounded/designs/buttons/Buttons.dart index e5360c9..f3570d4 100644 --- a/frontend/lib/Grounded/designs/buttons/Buttons.dart +++ b/frontend/lib/Grounded/designs/buttons/Buttons.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import '../../about/internal/application/TextType.dart'; import '../../utils/Colors.dart'; -import '../Component.dart'; import '../text/Text.dart'; /// The primary action. One per screen — if a screen appears to need two, one diff --git a/frontend/lib/Grounded/see/goal/ConnectGoalDetail.dart b/frontend/lib/Grounded/see/goal/ConnectGoalDetail.dart index 5b96e85..6d6697c 100644 --- a/frontend/lib/Grounded/see/goal/ConnectGoalDetail.dart +++ b/frontend/lib/Grounded/see/goal/ConnectGoalDetail.dart @@ -6,4 +6,10 @@ abstract class ConnectGoalDetail { /// The task is ready to run — hand off to the full-screen runner. void onTaskReady(Commitment task); + + /// A tick landed. [late] is true when the window had already closed. + void onTaskTicked(Commitment task, bool late); + + /// The tick failed; the row goes back to unchecked. + void onTickFailed(Commitment task); } diff --git a/frontend/lib/Grounded/see/goal/GoalDetailState.dart b/frontend/lib/Grounded/see/goal/GoalDetailState.dart index adb35a8..f0630aa 100644 --- a/frontend/lib/Grounded/see/goal/GoalDetailState.dart +++ b/frontend/lib/Grounded/see/goal/GoalDetailState.dart @@ -4,11 +4,12 @@ import 'package:stacked/stacked.dart'; import '../../about/external/data/Commitment.dart'; import '../../about/external/data/Goal.dart'; -import '../../about/internal/application/CommitmentClass.dart'; import '../../about/internal/application/CommitmentStatus.dart'; +import '../../about/internal/application/NotificationType.dart'; import '../../about/internal/application/ProofType.dart'; import '../../about/internal/application/TextType.dart'; import '../../configs/Navigator.dart'; +import '../../designs/Checkbox.dart'; import '../../designs/Component.dart'; import '../../designs/Responsive.dart'; import '../../designs/Shell.dart'; @@ -33,6 +34,9 @@ class GoalDetailState extends State bool _changed = false; + /// Ticked locally, awaiting the server. + final Set _ticked = {}; + @override Widget build(BuildContext context) { return ViewModelBuilder.reactive( @@ -70,6 +74,20 @@ class GoalDetailState extends State _model?.startTask(task); } + /// Ticking completes the task without opening the runner — the runner is for + /// work you actually want timed. + void _onTick(Commitment task) { + if (task.id == null || _ticked.contains(task.id)) { + return; + } + + setState(() { + _ticked.add(task.id!); + }); + + _model?.tick(task); + } + void _onAddTask() async { final result = await GroundedNavigation() .navigateToPageWithData(const NewCommitment(), context); @@ -180,51 +198,29 @@ class GoalDetailState extends State crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 3, - height: 38, - margin: const EdgeInsets.only(right: 14, top: 2), - decoration: BoxDecoration( - color: accent, - borderRadius: BorderRadius.circular(4), + TickRow( + checked: _ticked.contains(task.id), + title: task.title, + accent: accent, + onTick: () => _onTick(task), + detail: Row( + children: [ + text(formatWindow(task), 11, TextType.Regular, + color: colorGrey2), + const SizedBox(width: 9), + Container( + width: 3, + height: 3, + decoration: BoxDecoration( + color: colorGrey, shape: BoxShape.circle), ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - text(task.title, 16, TextType.Medium, - color: colorPrimaryDark, - maxLines: 2, - overflow: TextOverflow.ellipsis), - const SizedBox(height: 7), - Row( - children: [ - text(formatWindow(task), 11, TextType.Regular, - color: colorGrey2), - const SizedBox(width: 9), - Container( - width: 3, - height: 3, - decoration: BoxDecoration( - color: colorGrey, shape: BoxShape.circle), - ), - const SizedBox(width: 9), - text(formatMinutes(task.estMinutes), 11, - TextType.Regular, color: colorGrey2), - ], - ), - ], - ), - ), - const SizedBox(width: 8), - pill(proofLabel(task.proofType), colorGrey2, colorMuted, - textSize: 9), - ], + const SizedBox(width: 9), + text(formatMinutes(task.estMinutes), 11, TextType.Regular, + color: colorGrey2), + ], + ), + trailing: pill(proofLabel(task.proofType), colorGrey2, colorMuted, + textSize: 9), ), if (late) ...[ const SizedBox(height: 12), @@ -258,26 +254,15 @@ class GoalDetailState extends State margin: const EdgeInsets.only(bottom: 8), child: card( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13), - child: Row( - children: [ - Icon( - late - ? CupertinoIcons.checkmark_circle - : CupertinoIcons.checkmark_circle_fill, - size: 17, - color: late ? colorStandingWarned : colorPositive, - ), - const SizedBox(width: 12), - Expanded( - child: text(task.title, 13, TextType.Regular, - color: colorGrey2, - maxLines: 1, - overflow: TextOverflow.ellipsis), - ), - if (late) - pill("Late", colorStandingWarned, colorStandingWarnedBg, - textSize: 9), - ], + child: TickRow( + checked: true, + locked: true, + title: task.title, + accent: late ? colorStandingWarned : colorPositive, + trailing: late + ? pill("Late", colorStandingWarned, colorStandingWarnedBg, + textSize: 9) + : null, ), ), ); @@ -288,11 +273,38 @@ class GoalDetailState extends State @override void onGoalLoaded(Goal goal, List tasks) { setState(() { + _ticked.removeWhere((id) => tasks.every((task) => task.id != id)); _goal = goal; _tasks = tasks; }); } + @override + void onTaskTicked(Commitment task, bool late) { + _changed = true; + _model?.loadTasks(_goal); + + if (!late) { + return; + } + + _model?.showApplicationNotification( + NotificationType.warning, + "Late complete", + "The window had already closed. This is recorded as a late complete — it reduces the debt but does not clear it.", + true, + true, + null, + ); + } + + @override + void onTickFailed(Commitment task) { + setState(() { + _ticked.remove(task.id); + }); + } + @override void onTaskReady(Commitment task) async { // The runner takes over the whole screen — a task you are running is the diff --git a/frontend/lib/Grounded/see/goal/GoalsState.dart b/frontend/lib/Grounded/see/goal/GoalsState.dart index 633b8b7..682e47b 100644 --- a/frontend/lib/Grounded/see/goal/GoalsState.dart +++ b/frontend/lib/Grounded/see/goal/GoalsState.dart @@ -5,7 +5,6 @@ import 'package:stacked/stacked.dart'; import '../../about/external/data/Goal.dart'; import '../../about/external/initial/GoalRequest.dart'; import '../../about/internal/application/CommitmentClass.dart'; -import '../../about/internal/application/NavigatorType.dart'; import '../../about/internal/application/TextType.dart'; import '../../configs/Navigator.dart'; import '../../designs/Component.dart'; diff --git a/frontend/lib/Grounded/see/goal/ViewGoalDetail.dart b/frontend/lib/Grounded/see/goal/ViewGoalDetail.dart index 5369829..158f582 100644 --- a/frontend/lib/Grounded/see/goal/ViewGoalDetail.dart +++ b/frontend/lib/Grounded/see/goal/ViewGoalDetail.dart @@ -1,5 +1,6 @@ import '../../about/external/data/Commitment.dart'; import '../../about/external/data/Goal.dart'; +import '../../about/external/initial/CompletionRequest.dart'; import '../../about/external/initial/IdRequest.dart'; import '../../utils/ObjectConvertors.dart'; import '../parent/ParentViewModel.dart'; @@ -27,6 +28,23 @@ class ViewGoalDetail extends ParentViewModel { } } + /// Completes a task straight from the goal list. + void tick(Commitment task) async { + if (!await hasNetwork(() => tick(task))) return; + + try { + await getDataManager().completeCommitmentEntry(CompletionRequest( + commitmentId: task.id ?? "", + proofType: task.proofType.name, + )); + + connection.onTaskTicked(task, task.wouldBeLate); + } catch (e) { + connection.onTickFailed(task); + handleError(e, () => tick(task), () => dismissError(), "Retry"); + } + } + /// Stashes the task as the active one before the runner opens, so the /// ongoing notification and any relaunch land back on the right thing. void startTask(Commitment task) async { diff --git a/frontend/lib/Grounded/see/home/ConnectHome.dart b/frontend/lib/Grounded/see/home/ConnectHome.dart index dad1be4..3e0eb61 100644 --- a/frontend/lib/Grounded/see/home/ConnectHome.dart +++ b/frontend/lib/Grounded/see/home/ConnectHome.dart @@ -21,4 +21,11 @@ abstract class ConnectHome { /// Creating a commitment is refused at this standing. void onCreationBlocked(String reason); + + /// A tick landed. [late] is true when the window had already closed, so the + /// row can show it was recorded as a late complete rather than a clean one. + void onTicked(Commitment item, bool late); + + /// The tick failed and the row has to go back to unchecked. + void onTickFailed(Commitment item); } diff --git a/frontend/lib/Grounded/see/home/HomeState.dart b/frontend/lib/Grounded/see/home/HomeState.dart index 45949e1..b0c2e0f 100644 --- a/frontend/lib/Grounded/see/home/HomeState.dart +++ b/frontend/lib/Grounded/see/home/HomeState.dart @@ -10,9 +10,9 @@ import '../../about/internal/application/NavigatorType.dart'; import '../../about/internal/application/NotificationType.dart'; import '../../about/internal/application/Standing.dart'; import '../../about/internal/application/TextType.dart'; -import '../../about/internal/application/ToneLevel.dart'; import '../../about/internal/application/UserDetails.dart'; import '../../configs/Navigator.dart'; +import '../../designs/Checkbox.dart'; import '../../designs/Component.dart'; import '../../designs/Responsive.dart'; import '../../designs/Shell.dart'; @@ -20,7 +20,6 @@ import '../../designs/buttons/Buttons.dart'; import '../../designs/text/Text.dart'; import '../../utils/Colors.dart'; import '../../utils/CommonUtils.dart'; -import '../../utils/DebtEngine.dart'; import '../../utils/StandingEngine.dart'; import '../../utils/Thresholds.dart'; import '../../utils/ToneEngine.dart'; @@ -52,6 +51,11 @@ class HomeState extends State implements ConnectHome { bool _distressed = false; + /// Ids ticked locally but not yet confirmed by the server. The row reads as + /// done immediately so the tick feels instant, and rolls back if the call + /// fails. + final Set _ticked = {}; + @override Widget build(BuildContext context) { return ViewModelBuilder.reactive( @@ -101,6 +105,18 @@ class HomeState extends State implements ConnectHome { } } + void _onTick(Commitment item) { + if (item.id == null || _ticked.contains(item.id)) { + return; + } + + setState(() { + _ticked.add(item.id!); + }); + + _model?.tick(item); + } + void _onOpenReportCard() { GroundedNavigation().navigateToPage( NavigatorType.justOpen, const ReportCardScreen(), context); @@ -272,9 +288,14 @@ class HomeState extends State implements ConnectHome { "Nothing committed today", "An empty plan is a decision too. Add something you actually intend to do.", ) - else + else ...[ ..._plan.map(_commitmentRow), - const SizedBox(height: 28), + addTaskAffordance( + _onAddCommitment, + enabled: StandingEngine.permitsNewCommitment(_standing), + ), + ], + const SizedBox(height: 12), _quickLinks(), const SizedBox(height: 24), roundedCornerButton( @@ -499,83 +520,68 @@ class HomeState extends State implements ConnectHome { ); } + /// A task line with its tick box. Completed items keep their place, struck + /// through — the day stays a record of what was asked, not just what is left. Widget _commitmentRow(Commitment item) { - final bool late = item.windowClosed && - item.status != CommitmentStatus.Completed && - item.status != CommitmentStatus.LateCompleted; + final bool settled = item.status == CommitmentStatus.Completed || + item.status == CommitmentStatus.LateCompleted; + + final bool ticked = settled || _ticked.contains(item.id); + + final bool late = item.windowClosed && !settled; final Color accent = classColor(item.commitmentClass); return Container( - margin: const EdgeInsets.only(bottom: 10), + margin: const EdgeInsets.only(bottom: 6), child: card( - padding: const EdgeInsets.fromLTRB(14, 14, 14, 14), - onTap: _onOpenOverdue, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 3, - height: 42, - margin: const EdgeInsets.only(right: 14, top: 2), - decoration: BoxDecoration( - color: accent, - borderRadius: BorderRadius.circular(4), + padding: const EdgeInsets.fromLTRB(12, 8, 14, 8), + child: TickRow( + checked: ticked, + title: item.title, + accent: item.status == CommitmentStatus.LateCompleted + ? colorStandingWarned + : accent, + onTick: settled ? null : () => _onTick(item), + onTap: _onOpenOverdue, + detail: Row( + children: [ + text(formatWindow(item), 11, TextType.Regular, + color: colorGrey2), + const SizedBox(width: 10), + Container( + width: 3, + height: 3, + decoration: + BoxDecoration(color: colorGrey, shape: BoxShape.circle), ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - text(item.title, 15, TextType.Medium, - color: colorPrimaryDark, - maxLines: 2, - overflow: TextOverflow.ellipsis), - const SizedBox(height: 8), - Row( - children: [ - text(formatWindow(item), 11, TextType.Regular, - color: colorGrey2), - const SizedBox(width: 10), - Container(width: 3, height: 3, decoration: BoxDecoration( - color: colorGrey, shape: BoxShape.circle)), - const SizedBox(width: 10), - text(formatMinutes(item.estMinutes), 11, - TextType.Regular, color: colorGrey2), - ], - ), - if (late) ...[ - const SizedBox(height: 10), - Row( - children: [ - pill( - overdueLabel(item), - colorStandingGrounded, - colorStandingGroundedBg, - textSize: 9, - ), - const SizedBox(width: 6), - pill( - "−${formatDebt(DebtEngine.commitmentDebt(item))}", - colorGrey2, - colorMuted, - textSize: 9, - ), - ], - ), - ], - ], - ), - ), - const SizedBox(width: 10), - pill( - classLabel(item.commitmentClass), - accent, - classBackground(item.commitmentClass), - textSize: 9, - ), - ], + const SizedBox(width: 10), + text(formatMinutes(item.estMinutes), 11, TextType.Regular, + color: colorGrey2), + if (late) ...[ + const SizedBox(width: 10), + pill( + overdueLabel(item), + colorStandingGrounded, + colorStandingGroundedBg, + textSize: 9, + ), + ], + if (item.status == CommitmentStatus.LateCompleted) ...[ + const SizedBox(width: 10), + pill("Late", colorStandingWarned, colorStandingWarnedBg, + textSize: 9), + ], + ], + ), + trailing: ticked + ? null + : pill( + classLabel(item.commitmentClass), + accent, + classBackground(item.commitmentClass), + textSize: 9, + ), ), ), ); @@ -650,6 +656,9 @@ class HomeState extends State implements ConnectHome { void onPlanLoaded(List plan) { setState(() { _plan = plan; + // The server is now authoritative; anything it reports as settled no + // longer needs the local optimistic flag. + _ticked.removeWhere((id) => plan.every((item) => item.id != id)); }); } @@ -682,6 +691,31 @@ class HomeState extends State implements ConnectHome { }); } + @override + void onTicked(Commitment item, bool late) { + if (!late) { + return; + } + + // A clean tick needs no comment. A late one does — otherwise the user + // learns that the window never mattered. + _model?.showApplicationNotification( + NotificationType.warning, + "Late complete", + "The window had already closed, so this is recorded as a late complete. It reduces the debt but does not clear it.", + true, + true, + null, + ); + } + + @override + void onTickFailed(Commitment item) { + setState(() { + _ticked.remove(item.id); + }); + } + @override void onCreationBlocked(String reason) { _model?.showApplicationNotification( diff --git a/frontend/lib/Grounded/see/home/ViewHome.dart b/frontend/lib/Grounded/see/home/ViewHome.dart index 6be05b6..374526b 100644 --- a/frontend/lib/Grounded/see/home/ViewHome.dart +++ b/frontend/lib/Grounded/see/home/ViewHome.dart @@ -6,6 +6,7 @@ import '../../about/external/data/pages/request/PageAndSort.dart'; import '../../about/external/data/pages/request/Pageable.dart'; import '../../about/external/data/pages/request/Sort.dart'; import '../../about/external/data/pages/response/CommitmentPage.dart'; +import '../../about/external/initial/CompletionRequest.dart'; import '../../about/external/initial/ReportCardRequest.dart'; import '../../about/internal/application/CommitmentClass.dart'; import '../../about/internal/application/Standing.dart'; @@ -141,6 +142,30 @@ class ViewHome extends ParentViewModel { } } + /// Ticking a row completes it. The window decides whether that lands as a + /// clean complete or a late one — the client never claims which. + void tick(Commitment item) async { + if (!await hasNetwork(() => tick(item))) return; + + final bool late = item.wouldBeLate; + + try { + await getDataManager().completeCommitmentEntry(CompletionRequest( + commitmentId: item.id ?? "", + proofType: item.proofType.name, + )); + + connection.onTicked(item, late); + + // Clearing an item moves the debt, so the standing has to be re-derived + // rather than left showing the number from before the tick. + loadPlan(); + } catch (e) { + connection.onTickFailed(item); + handleError(e, () => tick(item), () => dismissError(), "Retry"); + } + } + /// The gate on creating anything new. Grounded blocks everything; Warned /// blocks electives only. void requestNewCommitment(Standing standing, CommitmentClass intended) async { diff --git a/frontend/lib/Grounded/see/overdue/OverdueQueueState.dart b/frontend/lib/Grounded/see/overdue/OverdueQueueState.dart index b1a228b..a7589a0 100644 --- a/frontend/lib/Grounded/see/overdue/OverdueQueueState.dart +++ b/frontend/lib/Grounded/see/overdue/OverdueQueueState.dart @@ -9,6 +9,7 @@ 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'; @@ -36,6 +37,9 @@ class OverdueQueueState extends State bool _changed = false; + /// Ticked locally, awaiting the server. Rolls back on failure. + final Set _ticked = {}; + @override Widget build(BuildContext context) { return ViewModelBuilder.reactive( @@ -66,6 +70,27 @@ class OverdueQueueState extends State 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. @@ -526,31 +551,19 @@ class OverdueQueueState extends State 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, - ), - ], + 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( @@ -625,6 +638,7 @@ class OverdueQueueState extends State @override void onQueueLoaded(List queue, Standing standing, double debt) { setState(() { + _ticked.removeWhere((id) => queue.every((item) => item.id != id)); _queue = queue; _standing = standing; _debt = debt;