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

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

View File

@@ -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<GoalDetail>
bool _changed = false;
/// Ticked locally, awaiting the server.
final Set<String> _ticked = <String>{};
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewGoalDetail>.reactive(
@@ -70,6 +74,20 @@ class GoalDetailState extends State<GoalDetail>
_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<GoalDetail>
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<GoalDetail>
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<GoalDetail>
@override
void onGoalLoaded(Goal goal, List<Commitment> 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

View File

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

View File

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