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
311 lines
11 KiB
Dart
311 lines
11 KiB
Dart
import 'package:flutter/cupertino.dart';
|
|
import 'package:flutter/material.dart';
|
|
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/TextType.dart';
|
|
import '../../configs/Navigator.dart';
|
|
import '../../designs/Component.dart';
|
|
import '../../designs/Responsive.dart';
|
|
import '../../designs/Shell.dart';
|
|
import '../../designs/buttons/Buttons.dart';
|
|
import '../../designs/input/InputFields.dart';
|
|
import '../../designs/text/Text.dart';
|
|
import '../../utils/Colors.dart';
|
|
import '../../utils/CommonUtils.dart';
|
|
import '../../utils/Validators.dart';
|
|
import 'ConnectGoals.dart';
|
|
import 'GoalDetail.dart';
|
|
import 'Goals.dart';
|
|
import 'ViewGoals.dart';
|
|
|
|
class GoalsState extends State<Goals> implements ConnectGoals {
|
|
ViewGoals? _model;
|
|
|
|
List<Goal> _goals = <Goal>[];
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ViewModelBuilder<ViewGoals>.reactive(
|
|
viewModelBuilder: () => ViewGoals(context, this),
|
|
onViewModelReady: (viewModel) {
|
|
_model = viewModel;
|
|
_initiate();
|
|
},
|
|
builder: (context, viewModel, child) => LayoutBuilder(
|
|
builder: (BuildContext context, BoxConstraints constraints) {
|
|
return Responsive(
|
|
mobile: _mobileView(constraints),
|
|
tablet: _mobileView(constraints),
|
|
desktop: _mobileView(constraints),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
void _initiate() {
|
|
_model?.loadGoals();
|
|
}
|
|
|
|
void _onBack() {
|
|
Navigator.pop(context);
|
|
}
|
|
|
|
void _onOpenGoal(Goal goal) async {
|
|
final result = await GroundedNavigation()
|
|
.navigateToPageWithData(GoalDetail(goal: goal), context);
|
|
|
|
if (result == true) {
|
|
_model?.loadGoals();
|
|
}
|
|
}
|
|
|
|
void _onNewGoal() {
|
|
_openGoalSheet();
|
|
}
|
|
|
|
/// Goals are lightweight on purpose — a name and a default class. The
|
|
/// weight lives on the tasks inside them.
|
|
void _openGoalSheet() {
|
|
final TextEditingController title = TextEditingController();
|
|
final TextEditingController description = TextEditingController();
|
|
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
|
|
|
|
CommitmentClass defaultClass = CommitmentClass.Standard;
|
|
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
barrierColor: colorPrimaryDark.withValues(alpha: 0.6),
|
|
builder: (BuildContext sheetContext) {
|
|
return StatefulBuilder(
|
|
builder: (BuildContext sheetContext, StateSetter setSheetState) {
|
|
return Padding(
|
|
padding: EdgeInsets.only(
|
|
bottom: MediaQuery.of(sheetContext).viewInsets.bottom,
|
|
),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: colorSheetBackground,
|
|
borderRadius: const BorderRadius.only(
|
|
topLeft: Radius.circular(28),
|
|
topRight: Radius.circular(28),
|
|
),
|
|
),
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
|
|
child: Form(
|
|
key: formKey,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Center(
|
|
child: Container(
|
|
width: 36,
|
|
height: 4,
|
|
margin: const EdgeInsets.only(bottom: 20),
|
|
decoration: BoxDecoration(
|
|
color: colorGrey.withValues(alpha: 0.35),
|
|
borderRadius: BorderRadius.circular(999),
|
|
),
|
|
),
|
|
),
|
|
text("NEW GOAL", 9, TextType.Bold,
|
|
color: colorGrey2, letterSpacing: 1.2),
|
|
const SizedBox(height: 10),
|
|
text("What are you\nworking toward?", 26,
|
|
TextType.Light,
|
|
color: colorPrimaryDark, height: 1.2),
|
|
const SizedBox(height: 20),
|
|
inputField(
|
|
"Goal",
|
|
title,
|
|
hint: "Workout, thesis, get the flat sorted…",
|
|
validator: Validators.title,
|
|
),
|
|
const SizedBox(height: 18),
|
|
inputField(
|
|
"Why it matters",
|
|
description,
|
|
hint: "Optional, but it helps on the bad days",
|
|
maxLines: 3,
|
|
),
|
|
const SizedBox(height: 20),
|
|
text("TASKS DEFAULT TO", 9, TextType.Bold,
|
|
color: colorGrey2, letterSpacing: 1.0),
|
|
const SizedBox(height: 10),
|
|
segmentedSelector<CommitmentClass>(
|
|
options: CommitmentClass.values,
|
|
selected: defaultClass,
|
|
label: classLabel,
|
|
onSelected: (value) => setSheetState(() {
|
|
defaultClass = value;
|
|
}),
|
|
activeColor: classColor(defaultClass),
|
|
),
|
|
const SizedBox(height: 24),
|
|
roundedCornerButton(
|
|
"Create goal",
|
|
() {
|
|
if (formKey.currentState?.validate() != true) {
|
|
return;
|
|
}
|
|
Navigator.pop(sheetContext);
|
|
_model?.save(GoalRequest(
|
|
title: title.text.trim(),
|
|
description: description.text.trim(),
|
|
defaultClass: defaultClass.name,
|
|
startDate: DateTime.now().toIso8601String(),
|
|
));
|
|
},
|
|
icon: CupertinoIcons.add,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Center(
|
|
child: textButton("Cancel",
|
|
() => Navigator.pop(sheetContext),
|
|
textSize: 13),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _mobileView(BoxConstraints constraints) {
|
|
return Sheet(
|
|
eyebrow: "Grounded",
|
|
title: "Goals",
|
|
onBack: _onBack,
|
|
action: chromeAction(CupertinoIcons.add, _onNewGoal),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
displayTitle("What you are\nworking toward."),
|
|
const SizedBox(height: 14),
|
|
text(
|
|
"A goal holds the tasks that get you there. The goal never carries debt — the tasks inside it do.",
|
|
14,
|
|
TextType.Regular,
|
|
color: colorGrey2,
|
|
height: 1.55,
|
|
),
|
|
const SizedBox(height: 28),
|
|
if (_goals.isEmpty)
|
|
emptyState(
|
|
CupertinoIcons.flag,
|
|
"No goals yet",
|
|
"Create one — Workout, say — then put the actual sessions inside it.",
|
|
)
|
|
else
|
|
..._goals.map(_goalCard),
|
|
const SizedBox(height: 24),
|
|
roundedCornerButton("New goal", _onNewGoal,
|
|
icon: CupertinoIcons.add),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _goalCard(Goal goal) {
|
|
final Color accent =
|
|
goal.slipping ? colorStandingGrounded : colorPrimaryDark;
|
|
|
|
return Container(
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
child: card(
|
|
onTap: () => _onOpenGoal(goal),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
text(goal.title, 19, TextType.Light,
|
|
color: colorPrimaryDark,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis),
|
|
if (goal.description.isNotEmpty) ...[
|
|
const SizedBox(height: 6),
|
|
text(goal.description, 12, TextType.Regular,
|
|
color: colorGrey2,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
if (goal.overdueTasks > 0)
|
|
pill("${goal.overdueTasks} late", colorStandingGrounded,
|
|
colorStandingGroundedBg, textSize: 9),
|
|
],
|
|
),
|
|
const SizedBox(height: 18),
|
|
meter(goal.progress, fill: accent),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: labelled(
|
|
"Done",
|
|
"${goal.completedTasks} of ${goal.totalTasks}",
|
|
valueSize: 13,
|
|
),
|
|
),
|
|
Expanded(
|
|
child: labelled(
|
|
"Remaining",
|
|
"${goal.remainingTasks}",
|
|
valueSize: 13,
|
|
),
|
|
),
|
|
Expanded(
|
|
child: labelled(
|
|
"Debt",
|
|
formatDebt(goal.debtContribution),
|
|
valueSize: 13,
|
|
valueColor: goal.debtContribution > 0
|
|
? colorStandingGrounded
|
|
: colorPrimaryDark,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
void onGoalsLoaded(List<Goal> goals) {
|
|
setState(() {
|
|
_goals = goals;
|
|
});
|
|
}
|
|
|
|
@override
|
|
void onGoalSaved() {
|
|
_model?.loadGoals();
|
|
}
|
|
}
|