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 '../../utils/Colors.dart';
|
||||
import '../Component.dart';
|
||||
import '../text/Text.dart';
|
||||
|
||||
/// 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.
|
||||
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/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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -21,4 +21,11 @@ abstract class ConnectHome {
|
||||
|
||||
/// Creating a commitment is refused at this standing.
|
||||
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/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(
|
||||
|
||||
@@ -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/Sort.dart';
|
||||
import '../../about/external/data/pages/response/CommitmentPage.dart';
|
||||
import '../../about/external/initial/CompletionRequest.dart';
|
||||
import '../../about/external/initial/ReportCardRequest.dart';
|
||||
import '../../about/internal/application/CommitmentClass.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
|
||||
/// blocks electives only.
|
||||
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/Standing.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../designs/Checkbox.dart';
|
||||
import '../../designs/Component.dart';
|
||||
import '../../designs/Responsive.dart';
|
||||
import '../../designs/Shell.dart';
|
||||
@@ -36,6 +37,9 @@ class OverdueQueueState extends State<OverdueQueue>
|
||||
|
||||
bool _changed = false;
|
||||
|
||||
/// Ticked locally, awaiting the server. Rolls back on failure.
|
||||
final Set<String> _ticked = <String>{};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewOverdueQueue>.reactive(
|
||||
@@ -66,6 +70,27 @@ class OverdueQueueState extends State<OverdueQueue>
|
||||
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) {
|
||||
// Honour proof settles immediately; everything else has to produce its
|
||||
// artefact before the completion is accepted.
|
||||
@@ -526,31 +551,19 @@ class OverdueQueueState extends State<OverdueQueue>
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text(item.title, 17, TextType.Medium,
|
||||
color: colorPrimaryDark, maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
const SizedBox(height: 8),
|
||||
text(formatWindow(item), 11, TextType.Regular,
|
||||
color: colorGrey2),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
pill(
|
||||
classLabel(item.commitmentClass),
|
||||
accent,
|
||||
classBackground(item.commitmentClass),
|
||||
textSize: 9,
|
||||
),
|
||||
],
|
||||
TickRow(
|
||||
checked: _ticked.contains(item.id),
|
||||
title: item.title,
|
||||
accent: accent,
|
||||
onTick: () => _onTick(item),
|
||||
detail: text(formatWindow(item), 11, TextType.Regular,
|
||||
color: colorGrey2),
|
||||
trailing: pill(
|
||||
classLabel(item.commitmentClass),
|
||||
accent,
|
||||
classBackground(item.commitmentClass),
|
||||
textSize: 9,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
@@ -625,6 +638,7 @@ class OverdueQueueState extends State<OverdueQueue>
|
||||
@override
|
||||
void onQueueLoaded(List<Commitment> queue, Standing standing, double debt) {
|
||||
setState(() {
|
||||
_ticked.removeWhere((id) => queue.every((item) => item.id != id));
|
||||
_queue = queue;
|
||||
_standing = standing;
|
||||
_debt = debt;
|
||||
|
||||
Reference in New Issue
Block a user