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:
216
frontend/lib/Grounded/designs/Checkbox.dart
Normal file
216
frontend/lib/Grounded/designs/Checkbox.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user