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

@@ -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),
],
),
),
);
}

View File

@@ -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