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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../../about/internal/application/TextType.dart';
|
import '../../about/internal/application/TextType.dart';
|
||||||
import '../../utils/Colors.dart';
|
import '../../utils/Colors.dart';
|
||||||
import '../Component.dart';
|
|
||||||
import '../text/Text.dart';
|
import '../text/Text.dart';
|
||||||
|
|
||||||
/// The primary action. One per screen — if a screen appears to need two, one
|
/// The primary action. One per screen — if a screen appears to need two, one
|
||||||
|
|||||||
@@ -6,4 +6,10 @@ abstract class ConnectGoalDetail {
|
|||||||
|
|
||||||
/// The task is ready to run — hand off to the full-screen runner.
|
/// The task is ready to run — hand off to the full-screen runner.
|
||||||
void onTaskReady(Commitment task);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import 'package:stacked/stacked.dart';
|
|||||||
|
|
||||||
import '../../about/external/data/Commitment.dart';
|
import '../../about/external/data/Commitment.dart';
|
||||||
import '../../about/external/data/Goal.dart';
|
import '../../about/external/data/Goal.dart';
|
||||||
import '../../about/internal/application/CommitmentClass.dart';
|
|
||||||
import '../../about/internal/application/CommitmentStatus.dart';
|
import '../../about/internal/application/CommitmentStatus.dart';
|
||||||
|
import '../../about/internal/application/NotificationType.dart';
|
||||||
import '../../about/internal/application/ProofType.dart';
|
import '../../about/internal/application/ProofType.dart';
|
||||||
import '../../about/internal/application/TextType.dart';
|
import '../../about/internal/application/TextType.dart';
|
||||||
import '../../configs/Navigator.dart';
|
import '../../configs/Navigator.dart';
|
||||||
|
import '../../designs/Checkbox.dart';
|
||||||
import '../../designs/Component.dart';
|
import '../../designs/Component.dart';
|
||||||
import '../../designs/Responsive.dart';
|
import '../../designs/Responsive.dart';
|
||||||
import '../../designs/Shell.dart';
|
import '../../designs/Shell.dart';
|
||||||
@@ -33,6 +34,9 @@ class GoalDetailState extends State<GoalDetail>
|
|||||||
|
|
||||||
bool _changed = false;
|
bool _changed = false;
|
||||||
|
|
||||||
|
/// Ticked locally, awaiting the server.
|
||||||
|
final Set<String> _ticked = <String>{};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ViewModelBuilder<ViewGoalDetail>.reactive(
|
return ViewModelBuilder<ViewGoalDetail>.reactive(
|
||||||
@@ -70,6 +74,20 @@ class GoalDetailState extends State<GoalDetail>
|
|||||||
_model?.startTask(task);
|
_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 {
|
void _onAddTask() async {
|
||||||
final result = await GroundedNavigation()
|
final result = await GroundedNavigation()
|
||||||
.navigateToPageWithData(const NewCommitment(), context);
|
.navigateToPageWithData(const NewCommitment(), context);
|
||||||
@@ -180,51 +198,29 @@ class GoalDetailState extends State<GoalDetail>
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
TickRow(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
checked: _ticked.contains(task.id),
|
||||||
children: [
|
title: task.title,
|
||||||
Container(
|
accent: accent,
|
||||||
width: 3,
|
onTick: () => _onTick(task),
|
||||||
height: 38,
|
detail: Row(
|
||||||
margin: const EdgeInsets.only(right: 14, top: 2),
|
children: [
|
||||||
decoration: BoxDecoration(
|
text(formatWindow(task), 11, TextType.Regular,
|
||||||
color: accent,
|
color: colorGrey2),
|
||||||
borderRadius: BorderRadius.circular(4),
|
const SizedBox(width: 9),
|
||||||
|
Container(
|
||||||
|
width: 3,
|
||||||
|
height: 3,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorGrey, shape: BoxShape.circle),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 9),
|
||||||
Expanded(
|
text(formatMinutes(task.estMinutes), 11, TextType.Regular,
|
||||||
child: Column(
|
color: colorGrey2),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
],
|
||||||
mainAxisSize: MainAxisSize.min,
|
),
|
||||||
children: [
|
trailing: pill(proofLabel(task.proofType), colorGrey2, colorMuted,
|
||||||
text(task.title, 16, TextType.Medium,
|
textSize: 9),
|
||||||
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),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
if (late) ...[
|
if (late) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -258,26 +254,15 @@ class GoalDetailState extends State<GoalDetail>
|
|||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
child: card(
|
child: card(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
|
||||||
child: Row(
|
child: TickRow(
|
||||||
children: [
|
checked: true,
|
||||||
Icon(
|
locked: true,
|
||||||
late
|
title: task.title,
|
||||||
? CupertinoIcons.checkmark_circle
|
accent: late ? colorStandingWarned : colorPositive,
|
||||||
: CupertinoIcons.checkmark_circle_fill,
|
trailing: late
|
||||||
size: 17,
|
? pill("Late", colorStandingWarned, colorStandingWarnedBg,
|
||||||
color: late ? colorStandingWarned : colorPositive,
|
textSize: 9)
|
||||||
),
|
: null,
|
||||||
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),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -288,11 +273,38 @@ class GoalDetailState extends State<GoalDetail>
|
|||||||
@override
|
@override
|
||||||
void onGoalLoaded(Goal goal, List<Commitment> tasks) {
|
void onGoalLoaded(Goal goal, List<Commitment> tasks) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
_ticked.removeWhere((id) => tasks.every((task) => task.id != id));
|
||||||
_goal = goal;
|
_goal = goal;
|
||||||
_tasks = tasks;
|
_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
|
@override
|
||||||
void onTaskReady(Commitment task) async {
|
void onTaskReady(Commitment task) async {
|
||||||
// The runner takes over the whole screen — a task you are running is the
|
// The runner takes over the whole screen — a task you are running is the
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import 'package:stacked/stacked.dart';
|
|||||||
import '../../about/external/data/Goal.dart';
|
import '../../about/external/data/Goal.dart';
|
||||||
import '../../about/external/initial/GoalRequest.dart';
|
import '../../about/external/initial/GoalRequest.dart';
|
||||||
import '../../about/internal/application/CommitmentClass.dart';
|
import '../../about/internal/application/CommitmentClass.dart';
|
||||||
import '../../about/internal/application/NavigatorType.dart';
|
|
||||||
import '../../about/internal/application/TextType.dart';
|
import '../../about/internal/application/TextType.dart';
|
||||||
import '../../configs/Navigator.dart';
|
import '../../configs/Navigator.dart';
|
||||||
import '../../designs/Component.dart';
|
import '../../designs/Component.dart';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import '../../about/external/data/Commitment.dart';
|
import '../../about/external/data/Commitment.dart';
|
||||||
import '../../about/external/data/Goal.dart';
|
import '../../about/external/data/Goal.dart';
|
||||||
|
import '../../about/external/initial/CompletionRequest.dart';
|
||||||
import '../../about/external/initial/IdRequest.dart';
|
import '../../about/external/initial/IdRequest.dart';
|
||||||
import '../../utils/ObjectConvertors.dart';
|
import '../../utils/ObjectConvertors.dart';
|
||||||
import '../parent/ParentViewModel.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
|
/// Stashes the task as the active one before the runner opens, so the
|
||||||
/// ongoing notification and any relaunch land back on the right thing.
|
/// ongoing notification and any relaunch land back on the right thing.
|
||||||
void startTask(Commitment task) async {
|
void startTask(Commitment task) async {
|
||||||
|
|||||||
@@ -21,4 +21,11 @@ abstract class ConnectHome {
|
|||||||
|
|
||||||
/// Creating a commitment is refused at this standing.
|
/// Creating a commitment is refused at this standing.
|
||||||
void onCreationBlocked(String reason);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import '../../about/internal/application/NavigatorType.dart';
|
|||||||
import '../../about/internal/application/NotificationType.dart';
|
import '../../about/internal/application/NotificationType.dart';
|
||||||
import '../../about/internal/application/Standing.dart';
|
import '../../about/internal/application/Standing.dart';
|
||||||
import '../../about/internal/application/TextType.dart';
|
import '../../about/internal/application/TextType.dart';
|
||||||
import '../../about/internal/application/ToneLevel.dart';
|
|
||||||
import '../../about/internal/application/UserDetails.dart';
|
import '../../about/internal/application/UserDetails.dart';
|
||||||
import '../../configs/Navigator.dart';
|
import '../../configs/Navigator.dart';
|
||||||
|
import '../../designs/Checkbox.dart';
|
||||||
import '../../designs/Component.dart';
|
import '../../designs/Component.dart';
|
||||||
import '../../designs/Responsive.dart';
|
import '../../designs/Responsive.dart';
|
||||||
import '../../designs/Shell.dart';
|
import '../../designs/Shell.dart';
|
||||||
@@ -20,7 +20,6 @@ import '../../designs/buttons/Buttons.dart';
|
|||||||
import '../../designs/text/Text.dart';
|
import '../../designs/text/Text.dart';
|
||||||
import '../../utils/Colors.dart';
|
import '../../utils/Colors.dart';
|
||||||
import '../../utils/CommonUtils.dart';
|
import '../../utils/CommonUtils.dart';
|
||||||
import '../../utils/DebtEngine.dart';
|
|
||||||
import '../../utils/StandingEngine.dart';
|
import '../../utils/StandingEngine.dart';
|
||||||
import '../../utils/Thresholds.dart';
|
import '../../utils/Thresholds.dart';
|
||||||
import '../../utils/ToneEngine.dart';
|
import '../../utils/ToneEngine.dart';
|
||||||
@@ -52,6 +51,11 @@ class HomeState extends State<Home> implements ConnectHome {
|
|||||||
|
|
||||||
bool _distressed = false;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ViewModelBuilder<ViewHome>.reactive(
|
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() {
|
void _onOpenReportCard() {
|
||||||
GroundedNavigation().navigateToPage(
|
GroundedNavigation().navigateToPage(
|
||||||
NavigatorType.justOpen, const ReportCardScreen(), context);
|
NavigatorType.justOpen, const ReportCardScreen(), context);
|
||||||
@@ -272,9 +288,14 @@ class HomeState extends State<Home> implements ConnectHome {
|
|||||||
"Nothing committed today",
|
"Nothing committed today",
|
||||||
"An empty plan is a decision too. Add something you actually intend to do.",
|
"An empty plan is a decision too. Add something you actually intend to do.",
|
||||||
)
|
)
|
||||||
else
|
else ...[
|
||||||
..._plan.map(_commitmentRow),
|
..._plan.map(_commitmentRow),
|
||||||
const SizedBox(height: 28),
|
addTaskAffordance(
|
||||||
|
_onAddCommitment,
|
||||||
|
enabled: StandingEngine.permitsNewCommitment(_standing),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 12),
|
||||||
_quickLinks(),
|
_quickLinks(),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
roundedCornerButton(
|
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) {
|
Widget _commitmentRow(Commitment item) {
|
||||||
final bool late = item.windowClosed &&
|
final bool settled = item.status == CommitmentStatus.Completed ||
|
||||||
item.status != CommitmentStatus.Completed &&
|
item.status == CommitmentStatus.LateCompleted;
|
||||||
item.status != CommitmentStatus.LateCompleted;
|
|
||||||
|
final bool ticked = settled || _ticked.contains(item.id);
|
||||||
|
|
||||||
|
final bool late = item.windowClosed && !settled;
|
||||||
|
|
||||||
final Color accent = classColor(item.commitmentClass);
|
final Color accent = classColor(item.commitmentClass);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 10),
|
margin: const EdgeInsets.only(bottom: 6),
|
||||||
child: card(
|
child: card(
|
||||||
padding: const EdgeInsets.fromLTRB(14, 14, 14, 14),
|
padding: const EdgeInsets.fromLTRB(12, 8, 14, 8),
|
||||||
onTap: _onOpenOverdue,
|
child: TickRow(
|
||||||
child: Row(
|
checked: ticked,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
title: item.title,
|
||||||
children: [
|
accent: item.status == CommitmentStatus.LateCompleted
|
||||||
Container(
|
? colorStandingWarned
|
||||||
width: 3,
|
: accent,
|
||||||
height: 42,
|
onTick: settled ? null : () => _onTick(item),
|
||||||
margin: const EdgeInsets.only(right: 14, top: 2),
|
onTap: _onOpenOverdue,
|
||||||
decoration: BoxDecoration(
|
detail: Row(
|
||||||
color: accent,
|
children: [
|
||||||
borderRadius: BorderRadius.circular(4),
|
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),
|
||||||
Expanded(
|
text(formatMinutes(item.estMinutes), 11, TextType.Regular,
|
||||||
child: Column(
|
color: colorGrey2),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
if (late) ...[
|
||||||
mainAxisSize: MainAxisSize.min,
|
const SizedBox(width: 10),
|
||||||
children: [
|
pill(
|
||||||
text(item.title, 15, TextType.Medium,
|
overdueLabel(item),
|
||||||
color: colorPrimaryDark,
|
colorStandingGrounded,
|
||||||
maxLines: 2,
|
colorStandingGroundedBg,
|
||||||
overflow: TextOverflow.ellipsis),
|
textSize: 9,
|
||||||
const SizedBox(height: 8),
|
),
|
||||||
Row(
|
],
|
||||||
children: [
|
if (item.status == CommitmentStatus.LateCompleted) ...[
|
||||||
text(formatWindow(item), 11, TextType.Regular,
|
const SizedBox(width: 10),
|
||||||
color: colorGrey2),
|
pill("Late", colorStandingWarned, colorStandingWarnedBg,
|
||||||
const SizedBox(width: 10),
|
textSize: 9),
|
||||||
Container(width: 3, height: 3, decoration: BoxDecoration(
|
],
|
||||||
color: colorGrey, shape: BoxShape.circle)),
|
],
|
||||||
const SizedBox(width: 10),
|
),
|
||||||
text(formatMinutes(item.estMinutes), 11,
|
trailing: ticked
|
||||||
TextType.Regular, color: colorGrey2),
|
? null
|
||||||
],
|
: pill(
|
||||||
),
|
classLabel(item.commitmentClass),
|
||||||
if (late) ...[
|
accent,
|
||||||
const SizedBox(height: 10),
|
classBackground(item.commitmentClass),
|
||||||
Row(
|
textSize: 9,
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -650,6 +656,9 @@ class HomeState extends State<Home> implements ConnectHome {
|
|||||||
void onPlanLoaded(List<Commitment> plan) {
|
void onPlanLoaded(List<Commitment> plan) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_plan = plan;
|
_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
|
@override
|
||||||
void onCreationBlocked(String reason) {
|
void onCreationBlocked(String reason) {
|
||||||
_model?.showApplicationNotification(
|
_model?.showApplicationNotification(
|
||||||
|
|||||||
@@ -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/Pageable.dart';
|
||||||
import '../../about/external/data/pages/request/Sort.dart';
|
import '../../about/external/data/pages/request/Sort.dart';
|
||||||
import '../../about/external/data/pages/response/CommitmentPage.dart';
|
import '../../about/external/data/pages/response/CommitmentPage.dart';
|
||||||
|
import '../../about/external/initial/CompletionRequest.dart';
|
||||||
import '../../about/external/initial/ReportCardRequest.dart';
|
import '../../about/external/initial/ReportCardRequest.dart';
|
||||||
import '../../about/internal/application/CommitmentClass.dart';
|
import '../../about/internal/application/CommitmentClass.dart';
|
||||||
import '../../about/internal/application/Standing.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
|
/// The gate on creating anything new. Grounded blocks everything; Warned
|
||||||
/// blocks electives only.
|
/// blocks electives only.
|
||||||
void requestNewCommitment(Standing standing, CommitmentClass intended) async {
|
void requestNewCommitment(Standing standing, CommitmentClass intended) async {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import '../../about/internal/application/NotificationType.dart';
|
|||||||
import '../../about/internal/application/ProofType.dart';
|
import '../../about/internal/application/ProofType.dart';
|
||||||
import '../../about/internal/application/Standing.dart';
|
import '../../about/internal/application/Standing.dart';
|
||||||
import '../../about/internal/application/TextType.dart';
|
import '../../about/internal/application/TextType.dart';
|
||||||
|
import '../../designs/Checkbox.dart';
|
||||||
import '../../designs/Component.dart';
|
import '../../designs/Component.dart';
|
||||||
import '../../designs/Responsive.dart';
|
import '../../designs/Responsive.dart';
|
||||||
import '../../designs/Shell.dart';
|
import '../../designs/Shell.dart';
|
||||||
@@ -36,6 +37,9 @@ class OverdueQueueState extends State<OverdueQueue>
|
|||||||
|
|
||||||
bool _changed = false;
|
bool _changed = false;
|
||||||
|
|
||||||
|
/// Ticked locally, awaiting the server. Rolls back on failure.
|
||||||
|
final Set<String> _ticked = <String>{};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ViewModelBuilder<ViewOverdueQueue>.reactive(
|
return ViewModelBuilder<ViewOverdueQueue>.reactive(
|
||||||
@@ -66,6 +70,27 @@ class OverdueQueueState extends State<OverdueQueue>
|
|||||||
Navigator.pop(context, _changed);
|
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) {
|
void _onComplete(Commitment item) {
|
||||||
// Honour proof settles immediately; everything else has to produce its
|
// Honour proof settles immediately; everything else has to produce its
|
||||||
// artefact before the completion is accepted.
|
// artefact before the completion is accepted.
|
||||||
@@ -526,31 +551,19 @@ class OverdueQueueState extends State<OverdueQueue>
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
TickRow(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
checked: _ticked.contains(item.id),
|
||||||
children: [
|
title: item.title,
|
||||||
Expanded(
|
accent: accent,
|
||||||
child: Column(
|
onTick: () => _onTick(item),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
detail: text(formatWindow(item), 11, TextType.Regular,
|
||||||
mainAxisSize: MainAxisSize.min,
|
color: colorGrey2),
|
||||||
children: [
|
trailing: pill(
|
||||||
text(item.title, 17, TextType.Medium,
|
classLabel(item.commitmentClass),
|
||||||
color: colorPrimaryDark, maxLines: 2,
|
accent,
|
||||||
overflow: TextOverflow.ellipsis),
|
classBackground(item.commitmentClass),
|
||||||
const SizedBox(height: 8),
|
textSize: 9,
|
||||||
text(formatWindow(item), 11, TextType.Regular,
|
),
|
||||||
color: colorGrey2),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
pill(
|
|
||||||
classLabel(item.commitmentClass),
|
|
||||||
accent,
|
|
||||||
classBackground(item.commitmentClass),
|
|
||||||
textSize: 9,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Row(
|
Row(
|
||||||
@@ -625,6 +638,7 @@ class OverdueQueueState extends State<OverdueQueue>
|
|||||||
@override
|
@override
|
||||||
void onQueueLoaded(List<Commitment> queue, Standing standing, double debt) {
|
void onQueueLoaded(List<Commitment> queue, Standing standing, double debt) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
_ticked.removeWhere((id) => queue.every((item) => item.id != id));
|
||||||
_queue = queue;
|
_queue = queue;
|
||||||
_standing = standing;
|
_standing = standing;
|
||||||
_debt = debt;
|
_debt = debt;
|
||||||
|
|||||||
Reference in New Issue
Block a user