Files
GroundedHelper/frontend/lib/Grounded/see/home/HomeState.dart
alvocool c88887b639 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
2026-07-31 21:51:15 +03:00

731 lines
22 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:stacked/stacked.dart';
import '../../about/external/data/Commitment.dart';
import '../../about/external/data/ExcuseCluster.dart';
import '../../about/internal/application/CommitmentClass.dart';
import '../../about/internal/application/CommitmentStatus.dart';
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/UserDetails.dart';
import '../../configs/Navigator.dart';
import '../../designs/Checkbox.dart';
import '../../designs/Component.dart';
import '../../designs/Responsive.dart';
import '../../designs/Shell.dart';
import '../../designs/buttons/Buttons.dart';
import '../../designs/text/Text.dart';
import '../../utils/Colors.dart';
import '../../utils/CommonUtils.dart';
import '../../utils/StandingEngine.dart';
import '../../utils/Thresholds.dart';
import '../../utils/ToneEngine.dart';
import '../commitment/NewCommitment.dart';
import '../excuse/ExcuseReport.dart';
import '../goal/Goals.dart';
import '../overdue/OverdueQueue.dart';
import '../reportcard/ReportCardScreen.dart';
import '../settings/Settings.dart';
import '../training/Training.dart';
import 'ConnectHome.dart';
import 'Home.dart';
import 'ViewHome.dart';
class HomeState extends State<Home> implements ConnectHome {
ViewHome? _model;
UserDetails _user = UserDetails(pic: '', name: '');
List<Commitment> _plan = <Commitment>[];
List<Commitment> _overdue = <Commitment>[];
Standing _standing = Standing.Good;
double _debt = 0;
ExcuseCluster? _insight;
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(
viewModelBuilder: () => ViewHome(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?.initialise();
}
// ── Handlers ──────────────────────────────────────────────────────────────
void _onOpenOverdue() async {
final result = await GroundedNavigation()
.navigateToPageWithData(const OverdueQueue(), context);
if (result == true) {
_model?.loadPlan();
}
}
void _onAddCommitment() async {
if (!StandingEngine.permitsNewCommitment(_standing)) {
_model?.requestNewCommitment(_standing, CommitmentClass.Standard);
return;
}
final result = await GroundedNavigation()
.navigateToPageWithData(const NewCommitment(), context);
if (result == true) {
_model?.loadPlan();
}
}
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);
}
void _onOpenGoals() async {
final result = await GroundedNavigation()
.navigateToPageWithData(const Goals(), context);
if (result == true) {
_model?.loadPlan();
}
}
void _onOpenTraining() {
GroundedNavigation()
.navigateToPage(NavigatorType.justOpen, const Training(), context);
}
void _onOpenSettings() {
GroundedNavigation()
.navigateToPage(NavigatorType.justOpen, const Settings(), context);
}
void _onOpenExcuses() {
GroundedNavigation()
.navigateToPage(NavigatorType.justOpen, const ExcuseReport(), context);
}
// ── Views ─────────────────────────────────────────────────────────────────
Widget _mobileView(BoxConstraints constraints) {
// Grounded and Lockdown replace the home screen with the overdue queue —
// you do not get to look at your nice plans, only at your mess.
final bool queueIsHome =
StandingEngine.showsOverdueQueueAsHome(_standing) && !_distressed;
return Sheet(
eyebrow: _user.name.isEmpty ? "Grounded" : _user.name,
title: queueIsHome ? "What you owe" : "Today",
chrome: _distressed ? colorPrimaryDark : _chromeFor(_standing),
banner: _standingBanner(),
action: chromeAction(
CupertinoIcons.person,
_onOpenSettings,
dotted: _user.sickMode,
dotColor: colorStandingWarned,
),
child: _distressed
? _distressBody()
: queueIsHome
? _groundedBody()
: _planBody(),
);
}
/// The chrome carries the standing colour, so the tier is legible before a
/// single word is read.
Color _chromeFor(Standing standing) {
switch (standing) {
case Standing.Good:
return colorPrimaryDark;
case Standing.Warned:
return colorPrimaryDark;
case Standing.Grounded:
return colorStandingGrounded;
case Standing.Lockdown:
return colorStandingLockdown;
}
}
/// The debt strip that sits in the black chrome under the title.
Widget _standingBanner() {
if (_distressed) {
return const SizedBox.shrink();
}
final Color tone = standingColor(_standing);
return Container(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
decoration: BoxDecoration(
color: colorWhite.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(16),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
color: _standing == Standing.Good ? tone : colorWhite,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
text(
standingLabel(_standing).toUpperCase(),
9,
TextType.Bold,
color: colorWhite.withValues(alpha: 0.75),
letterSpacing: 1.2,
),
],
),
const SizedBox(height: 8),
text(
ToneEngine.standingHeadline(_standing, _user.tone),
16,
TextType.Medium,
color: colorWhite,
),
],
),
),
const SizedBox(width: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
text("DEBT", 9, TextType.Bold,
color: colorWhite.withValues(alpha: 0.45),
letterSpacing: 1.0),
const SizedBox(height: 4),
text(formatDebt(_debt), 30, TextType.Light, color: colorWhite),
],
),
],
),
);
}
/// The normal day: the plan, with the overdue count kept visible above it so
/// it is never out of sight.
Widget _planBody() {
final int overdueCount = _overdue.length;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (overdueCount > 0) ...[
_overdueCallout(overdueCount),
const SizedBox(height: 24),
],
if (_insight != null) ...[
_insightCard(_insight!),
const SizedBox(height: 24),
],
sectionBreak(
"The plan",
caption: "${_plan.length} committed",
trailing: _plan.isEmpty
? null
: text(formatMinutes(_plannedMinutes()), 12, TextType.Bold,
color: colorGrey2),
),
if (_plan.isEmpty)
emptyState(
CupertinoIcons.square_list,
"Nothing committed today",
"An empty plan is a decision too. Add something you actually intend to do.",
)
else ...[
..._plan.map(_commitmentRow),
addTaskAffordance(
_onAddCommitment,
enabled: StandingEngine.permitsNewCommitment(_standing),
),
],
const SizedBox(height: 12),
_quickLinks(),
const SizedBox(height: 24),
roundedCornerButton(
"Commit to something",
_onAddCommitment,
icon: CupertinoIcons.add,
enabled: StandingEngine.permitsNewCommitment(_standing),
),
if (!StandingEngine.permitsNewCommitment(_standing)) ...[
const SizedBox(height: 10),
text(
ToneEngine.standingBody(_standing, _user.tone),
12,
TextType.Regular,
color: colorGrey2,
align: TextAlign.center,
),
],
],
);
}
/// Grounded: the plan is hidden entirely and only the mess is shown.
Widget _groundedBody() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
text("YOUR PLANS ARE HIDDEN", 10, TextType.Bold,
color: colorGrey2, letterSpacing: 1.2),
const SizedBox(height: 10),
displayTitle(
_standing == Standing.Lockdown
? "One at a time."
: "Clear this first.",
),
const SizedBox(height: 12),
text(
ToneEngine.standingBody(_standing, _user.tone),
14,
TextType.Regular,
color: colorGrey2,
height: 1.55,
),
const SizedBox(height: 28),
card(
background: standingBackground(_standing),
borderColor: standingColor(_standing).withValues(alpha: 0.20),
child: Row(
children: [
Expanded(
child: labelled(
"Open overdue",
"${_overdue.length}",
valueSize: 26,
valueType: TextType.Light,
valueColor: standingColor(_standing),
),
),
Expanded(
child: labelled(
"Debt to clear",
formatDebt(
StandingEngine.debtToNextTierDown(_debt, _standing)),
valueSize: 26,
valueType: TextType.Light,
valueColor: standingColor(_standing),
),
),
],
),
),
const SizedBox(height: 24),
sectionBreak("Outstanding", caption: "${_overdue.length} items"),
if (_overdue.isEmpty)
emptyState(
CupertinoIcons.checkmark_seal,
"The queue is empty",
"Your standing will recover as the debt decays.",
)
else
..._overdue.take(_standing == Standing.Lockdown ? 1 : _overdue.length)
.map(_commitmentRow),
const SizedBox(height: 24),
roundedCornerButton(
_standing == Standing.Lockdown ? "Deal with this one" : "Open the queue",
_onOpenOverdue,
background: standingColor(_standing),
icon: CupertinoIcons.arrow_right,
),
],
);
}
/// Distress: the strict persona drops entirely. This is the difference
/// between a product people keep and one they resent.
Widget _distressBody() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
text("A NOTE", 10, TextType.Bold, color: colorGrey2, letterSpacing: 1.2),
const SizedBox(height: 10),
displayTitle(ToneEngine.distressHeadline()),
const SizedBox(height: 14),
text(
ToneEngine.distressBody(),
15,
TextType.Regular,
color: colorGrey2,
height: 1.6,
),
const SizedBox(height: 28),
card(
background: colorStandingGoodBg,
borderColor: colorPositive.withValues(alpha: 0.20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text("PAUSED", 9, TextType.Bold,
color: colorPositive, letterSpacing: 1.2),
const SizedBox(height: 8),
text("Debt is not accruing right now.", 16, TextType.Medium,
color: colorPrimaryDark),
const SizedBox(height: 6),
text(
"Nothing you miss this week is counting against you.",
13,
TextType.Regular,
color: colorGrey2,
height: 1.5,
),
],
),
),
const SizedBox(height: 24),
sectionBreak("Three things", caption: "that actually matter"),
..._plan
.where((item) =>
item.commitmentClass == CommitmentClass.NonNegotiable)
.take(3)
.map(_commitmentRow),
const SizedBox(height: 24),
outlinedActionButton("Open settings", _onOpenSettings,
icon: CupertinoIcons.slider_horizontal_3),
],
);
}
Widget _overdueCallout(int count) {
return card(
background: colorStandingGroundedBg,
borderColor: colorStandingGrounded.withValues(alpha: 0.20),
onTap: _onOpenOverdue,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 44,
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
color: colorStandingGrounded,
borderRadius: BorderRadius.circular(13),
),
child: text("$count", 17, TextType.Bold, color: colorWhite),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text("OVERDUE", 9, TextType.Bold,
color: colorStandingGrounded, letterSpacing: 1.2),
const SizedBox(height: 5),
text(
count >= Thresholds.maxOpenOverdue
? "You are at the cap. Nothing new until this drops."
: "$count item${count == 1 ? "" : "s"} past the window.",
14,
TextType.Medium,
color: colorPrimaryDark,
),
],
),
),
Icon(CupertinoIcons.chevron_right,
size: 15, color: colorStandingGrounded),
],
),
);
}
/// The excuse confrontation. One pattern, stated plainly, with the
/// suggestion attached.
Widget _insightCard(ExcuseCluster cluster) {
return card(
background: colorPrimaryDark,
borderColor: colorPrimaryDark,
onTap: _onOpenExcuses,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
text("PATTERN", 9, TextType.Bold,
color: colorWhite.withValues(alpha: 0.45),
letterSpacing: 1.2),
text("${cluster.occurrences}×", 11, TextType.Bold,
color: colorWhite.withValues(alpha: 0.45)),
],
),
const SizedBox(height: 12),
text(cluster.insight, 15, TextType.Regular,
color: colorWhite, height: 1.55),
],
),
);
}
/// 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 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: 6),
child: card(
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),
),
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,
),
),
),
);
}
Widget _quickLinks() {
return Row(
children: [
Expanded(
child: _quickLink(
CupertinoIcons.flag_fill,
"Goals",
_onOpenGoals,
),
),
const SizedBox(width: 10),
Expanded(
child: _quickLink(
CupertinoIcons.chart_bar_alt_fill,
"Report",
_onOpenReportCard,
),
),
const SizedBox(width: 10),
Expanded(
child: _quickLink(
CupertinoIcons.flame_fill,
"Training",
_onOpenTraining,
),
),
],
);
}
Widget _quickLink(IconData icon, String label, VoidCallback onTap) {
return card(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 16),
onTap: onTap,
child: Row(
children: [
Icon(icon, size: 17, color: colorPrimaryDark),
const SizedBox(width: 10),
Expanded(
child: text(label, 13, TextType.Medium,
color: colorPrimaryDark, maxLines: 1,
overflow: TextOverflow.ellipsis),
),
],
),
);
}
double _plannedMinutes() {
double total = 0;
for (Commitment item in _plan) {
total = total + item.estMinutes;
}
return total;
}
// ── ConnectHome ───────────────────────────────────────────────────────────
@override
void onUserLoaded(UserDetails details) {
setState(() {
_user = details;
});
}
@override
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));
});
}
@override
void onOverdueLoaded(List<Commitment> overdue) {
setState(() {
_overdue = overdue;
});
}
@override
void onStandingResolved(Standing standing, double debtScore) {
setState(() {
_standing = standing;
_debt = debtScore;
});
}
@override
void onExcuseInsight(ExcuseCluster? cluster) {
setState(() {
_insight = cluster;
});
}
@override
void onDistressDetected() {
setState(() {
_distressed = true;
});
}
@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(
NotificationType.warning,
"Not right now",
reason,
true,
true,
null,
);
}
}