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
This commit is contained in:
alvocool
2026-07-31 21:51:15 +03:00
parent 16bff634b5
commit c88887b639
10 changed files with 496 additions and 166 deletions

View File

@@ -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<Home> 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<String> _ticked = <String>{};
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewHome>.reactive(
@@ -101,6 +105,18 @@ class HomeState extends State<Home> 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<Home> 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<Home> 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<Home> implements ConnectHome {
void onPlanLoaded(List<Commitment> 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<Home> 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(