Files
GroundedHelper/frontend/lib/Grounded/see/home/HomeState.dart
alvocool 4548e1f164 Rebuild Home around a forest header with streak, score and water
Home now opens on a deep green header: greeting, a Today / Weekly toggle,
a seven-day streak with a leaf on every kept day, minutes held with a +
ring, plan thumbnails, the report-card letter as the score, and one bar per
commitment class (weekly view swaps in per-day bars and class rates). The
+ opens an "Add to today" sheet over the blurred day. A water tracker sits
below the header, kept on the device. The nav marks the selected tab as a
filled block and drops the side action button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
2026-11-02 20:58:41 +03:00

2177 lines
69 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 'dart:ui';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:stacked/stacked.dart';
import '../../about/external/data/Commitment.dart';
import '../../about/external/data/ExcuseCluster.dart';
import '../../about/external/data/ReportCard.dart';
import '../../about/external/data/RoutineChain.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/RoutineKind.dart';
import '../../about/internal/application/Standing.dart';
import '../../about/internal/application/TextType.dart';
import '../../about/internal/application/TodoItem.dart';
import '../../about/internal/application/UserDetails.dart';
import '../../configs/Navigator.dart';
import '../../designs/AmbientBackground.dart';
import '../../designs/Checkbox.dart';
import '../../designs/Component.dart';
import '../../designs/FloatingNav.dart';
import '../../designs/GlassSurface.dart';
import '../../designs/ProgressRing.dart';
import '../../designs/Responsive.dart';
import '../../designs/Shell.dart';
import '../../designs/buttons/Buttons.dart';
import '../../designs/input/InputFields.dart';
import '../../designs/scene/SceneArt.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 '../routine/RoutineRunner.dart';
import '../routine/Routines.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>[];
List<RoutineChain> _routines = <RoutineChain>[];
List<TodoItem> _todos = <TodoItem>[];
final TextEditingController _todoInput = TextEditingController();
final FocusNode _todoFocus = FocusNode();
final GlobalKey _todoKey = GlobalKey();
ReportCard? _week;
bool _weekly = false;
int _waterMl = 0;
static const int _waterTarget = 2000;
static const int _waterStep = 250;
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();
}
@override
void dispose() {
_todoInput.dispose();
_todoFocus.dispose();
super.dispose();
}
// ── Derived ───────────────────────────────────────────────────────────────
bool _isDone(Commitment item) {
return item.status == CommitmentStatus.Completed ||
item.status == CommitmentStatus.LateCompleted ||
_ticked.contains(item.id);
}
int get _doneCount => _plan.where(_isDone).length;
/// Today's routines that are still open, soonest first; the whole library
/// when nothing is due.
List<RoutineChain> get _upNext {
final DateTime now = DateTime.now();
final List<RoutineChain> open = _routines
.where((item) => item.scheduledOn(now) && !item.complete)
.toList();
return (open.isEmpty ? _routines : open).take(6).toList();
}
// ── 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 _onOpenRoutines() async {
await GroundedNavigation()
.navigateToPageWithData(const Routines(), context);
_model?.loadRoutines();
}
void _onOpenKind(RoutineKind kind) async {
await GroundedNavigation()
.navigateToPageWithData(Routines(kind: kind), context);
_model?.loadRoutines();
}
void _onStartRoutine(RoutineChain routine) async {
if (routine.steps.isEmpty) {
_onOpenRoutines();
return;
}
final result = await GroundedNavigation().navigateToPageWithData(
RoutineRunner(routine: routine.copy()), context);
if (result == true) {
_model?.loadRoutines();
}
}
void _onOpenSettings() {
GroundedNavigation()
.navigateToPage(NavigatorType.justOpen, const Settings(), context);
}
void _onOpenExcuses() {
GroundedNavigation()
.navigateToPage(NavigatorType.justOpen, const ExcuseReport(), context);
}
void _onNavToday() {}
void _onShowToday() {
setState(() {
_weekly = false;
});
}
void _onShowWeekly() {
setState(() {
_weekly = true;
});
}
void _onAddWater() {
_onSetWaterDrops((_waterMl ~/ _waterStep) + 1);
}
/// Tapping the last filled drop empties it again, so a mis-tap is undoable.
void _onSetWaterDrops(int drops) {
final int current = _waterMl ~/ _waterStep;
final int next = drops == current ? drops - 1 : drops;
setState(() {
_waterMl = (next * _waterStep).clamp(0, _waterTarget);
});
_model?.saveWater(_waterMl);
}
void _onOpenAddSheet() {
showGeneralDialog(
context: context,
barrierDismissible: false,
barrierColor: colorNight.withValues(alpha: 0),
transitionDuration: const Duration(milliseconds: 320),
pageBuilder: (sheetContext, animation, secondary) =>
_addSheet(sheetContext),
transitionBuilder: (sheetContext, animation, secondary, child) {
final Animation<double> curve =
CurvedAnimation(parent: animation, curve: Curves.easeOutCubic);
return FadeTransition(
opacity: curve,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.12), end: Offset.zero)
.animate(curve),
child: child,
),
);
},
);
}
void _onCloseAddSheet() {
Navigator.of(context).pop();
}
void _onAddPickCommitment() {
_onCloseAddSheet();
_onAddCommitment();
}
void _onAddPickWorkout() {
_onCloseAddSheet();
_onOpenKind(RoutineKind.Workout);
}
void _onAddPickMeditation() {
_onCloseAddSheet();
_onOpenKind(RoutineKind.Meditation);
}
void _onAddPickMustDo() {
_onCloseAddSheet();
_onOpenKind(RoutineKind.MustDo);
}
void _onAddPickGoal() {
_onCloseAddSheet();
_onOpenGoals();
}
void _onAddPickTodo() {
_onCloseAddSheet();
final BuildContext? target = _todoKey.currentContext;
if (target != null) {
Scrollable.ensureVisible(target,
duration: const Duration(milliseconds: 400),
curve: Curves.easeOutCubic,
alignment: 0.1);
}
_todoFocus.requestFocus();
}
void _onAddTodo(String value) {
final String title = value.trim();
if (title.isEmpty) {
return;
}
setState(() {
_todos.insert(
0,
TodoItem(
id: DateTime.now().microsecondsSinceEpoch.toString(),
title: title));
});
_todoInput.clear();
_model?.saveTodos(_todos);
}
void _onAddTodoPressed() {
_onAddTodo(_todoInput.text);
}
void _onToggleTodo(TodoItem item) {
setState(() {
item.done = !item.done;
});
_model?.saveTodos(_todos);
}
void _onRemoveTodo(TodoItem item) {
setState(() {
_todos.remove(item);
});
_model?.saveTodos(_todos);
}
void _onClearDoneTodos() {
setState(() {
_todos.removeWhere((item) => item.done);
});
_model?.saveTodos(_todos);
}
// ── 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 AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light,
child: Scaffold(
backgroundColor: colorAmbientMid,
body: AmbientBackground(
child: Stack(
children: [
SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 130),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
_header(),
Padding(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (!_distressed) ...[
_waterCard(),
const SizedBox(height: 28),
],
if (_distressed)
_frosted(_distressBody())
else if (queueIsHome)
_frosted(_groundedBody())
else
_planBody(),
],
),
),
],
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: FloatingNav(
selected: 0,
entries: <NavEntry>[
(
icon: CupertinoIcons.house_fill,
label: "Today",
onTap: _onNavToday
),
(
icon: CupertinoIcons.square_stack_3d_up_fill,
label: "Routines",
onTap: _onOpenRoutines
),
(
icon: CupertinoIcons.flag_fill,
label: "Goals",
onTap: _onOpenGoals
),
(
icon: CupertinoIcons.chart_bar_alt_fill,
label: "Insights",
onTap: _onOpenReportCard
),
(
icon: CupertinoIcons.flame_fill,
label: "Training",
onTap: _onOpenTraining
),
],
).animate().fadeIn(duration: 400.ms, delay: 250.ms).slideY(
begin: 0.4,
end: 0,
duration: 500.ms,
delay: 250.ms,
curve: Curves.easeOutCubic),
),
],
),
),
),
);
}
// ── Header ────────────────────────────────────────────────────────────────
/// The deep forest block: who you are, the week as a streak, and the day
/// (or the week) in numbers.
Widget _header() {
return Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[colorHeaderTop, colorHeaderBottom],
),
boxShadow: [
BoxShadow(
color: Color(0x2E15281F),
blurRadius: 30,
offset: Offset(0, 12),
),
],
),
padding: EdgeInsets.fromLTRB(
20, MediaQuery.of(context).padding.top + 14, 20, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
_headerTopRow(),
const SizedBox(height: 22),
_periodToggle(),
const SizedBox(height: 16),
_streakPanel(),
const SizedBox(height: 24),
AnimatedSwitcher(
duration: const Duration(milliseconds: 260),
child: _weekly
? KeyedSubtree(
key: const ValueKey("weekly"), child: _weeklyNumbers())
: KeyedSubtree(
key: const ValueKey("today"), child: _todayNumbers()),
),
const SizedBox(height: 20),
_standingLine(),
],
),
).animate().fadeIn(duration: 450.ms);
}
Widget _headerTopRow() {
final String name = _user.name.isEmpty ? "there" : _user.name;
return Row(
children: [
_avatar(54),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
text(_greetingWord(), 14, TextType.Regular,
color: colorHeaderMuted),
const SizedBox(width: 6),
Icon(_greetingIcon(), size: 15, color: colorLime),
],
),
const SizedBox(height: 3),
text(name, 22, TextType.Medium,
color: colorWhite,
maxLines: 1,
overflow: TextOverflow.ellipsis),
],
),
),
glassCircle(
CupertinoIcons.bell,
_onOpenOverdue,
size: 46,
onDark: true,
dotted: _overdue.isNotEmpty,
dotColor: colorBarNonNegotiable,
),
const SizedBox(width: 8),
glassCircle(
CupertinoIcons.person,
_onOpenSettings,
size: 46,
onDark: true,
dotted: _user.sickMode,
dotColor: colorBarStandard,
),
],
);
}
Widget _periodToggle() {
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: colorHeaderPanel,
border: Border.all(color: colorHeaderLine),
),
child: Row(
children: [
Expanded(child: _periodTab("Today", !_weekly, _onShowToday)),
Expanded(child: _periodTab("Weekly", _weekly, _onShowWeekly)),
],
),
);
}
Widget _periodTab(String label, bool active, VoidCallback onTap) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
padding: const EdgeInsets.symmetric(vertical: 13),
alignment: Alignment.center,
color: active ? colorHeaderSelected : colorHeaderSelected.withValues(alpha: 0),
child: text(label, 16, active ? TextType.Medium : TextType.Regular,
color: active ? colorWhite : colorHeaderMuted),
),
);
}
/// "8 day streak" over the last seven days, ending today, with a leaf on
/// every day that was kept.
Widget _streakPanel() {
final DateTime now = DateTime.now();
final DateTime first = DateTime(now.year, now.month, now.day)
.subtract(const Duration(days: 6));
const List<String> days = <String>[
"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"
];
return Container(
padding: const EdgeInsets.fromLTRB(18, 16, 18, 18),
decoration: BoxDecoration(
color: colorHeaderPanel,
border: Border.all(color: colorHeaderLine),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
text("${_streak()}", 28, TextType.Medium, color: colorWhite),
const SizedBox(width: 8),
text("day streak", 16, TextType.Regular,
color: colorHeaderMuted),
const Spacer(),
Icon(Icons.eco, size: 32, color: colorLime),
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
for (int index = 0; index < 7; index++)
_streakDay(
days[first.add(Duration(days: index)).weekday - 1],
first.add(Duration(days: index)),
index == 6,
_kept(6 - index)),
],
),
],
),
);
}
Widget _streakDay(String label, DateTime date, bool today, bool kept) {
return SizedBox(
width: 40,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
text(label, 14, today ? TextType.Medium : TextType.Regular,
color: today ? colorWhite : colorHeaderMuted),
const SizedBox(height: 10),
SizedBox(
height: 26,
child: Center(
child: kept
? Icon(Icons.eco, size: 24, color: colorBarElective)
: text("${date.day}", 16,
today ? TextType.Medium : TextType.Regular,
color: today ? colorWhite : colorHeaderMuted),
),
),
],
),
);
}
/// The day in numbers: minutes held against planned, the + ring that opens
/// the add sheet, the plan as thumbnails, the score, and the three classes.
Widget _todayNumbers() {
final double fraction = _plan.isEmpty ? 0 : _doneCount / _plan.length;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text("Minutes held", 15, TextType.Regular,
color: colorHeaderMuted),
const SizedBox(height: 4),
_bigFigure(_thousands(_heldMinutes().round()),
"/${_thousands(_plannedMinutes().round())} min"),
],
),
),
GestureDetector(
onTap: _onOpenAddSheet,
child: ProgressRing(
fraction: fraction,
size: 70,
stroke: 4,
fill: colorBarElective,
track: colorHeaderLine,
child: Icon(CupertinoIcons.add, size: 30, color: colorWhite),
),
),
],
),
const SizedBox(height: 18),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (Commitment item in _plan.take(3)) ...[
_planThumb(item),
const SizedBox(width: 6),
],
const Spacer(),
_score(),
],
),
const SizedBox(height: 22),
Row(
children: [
for (CommitmentClass value in CommitmentClass.values) ...[
Expanded(child: _todayClassBar(value)),
if (value != CommitmentClass.values.last)
const SizedBox(width: 18),
],
],
),
],
);
}
/// The week in numbers: the average kept, a bar per day, and the classes.
Widget _weeklyNumbers() {
final Map<int, double> byDay = _week?.completionByWeekday ?? <int, double>{};
final double average = byDay.isEmpty
? 0
: byDay.values.reduce((a, b) => a + b) / byDay.length;
const List<String> letters = <String>["M", "T", "W", "T", "F", "S", "S"];
final DateTime now = DateTime.now();
final List<int> order = <int>[
for (int daysAgo = 6; daysAgo >= 0; daysAgo--)
now.subtract(Duration(days: daysAgo)).weekday
];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text("Kept this week", 15, TextType.Regular,
color: colorHeaderMuted),
const SizedBox(height: 4),
_bigFigure("${(average * 100).round()}", "% on average"),
],
),
),
_score(),
],
),
const SizedBox(height: 20),
SizedBox(
height: 118,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (int day in order) ...[
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 400),
curve: Curves.easeOutCubic,
height: 90 * (byDay[day] ?? 0).clamp(0.04, 1.0),
color: (byDay[day] ?? 0) >= _keptFloor
? colorBarElective
: colorHeaderLine,
),
const SizedBox(height: 8),
text(letters[day - 1], 13,
day == now.weekday
? TextType.Medium
: TextType.Regular,
color: day == now.weekday
? colorWhite
: colorHeaderMuted),
],
),
),
if (day != order.last) const SizedBox(width: 10),
],
],
),
),
const SizedBox(height: 22),
Row(
children: [
for (CommitmentClass value in CommitmentClass.values) ...[
Expanded(child: _weeklyClassBar(value)),
if (value != CommitmentClass.values.last)
const SizedBox(width: 18),
],
],
),
],
);
}
Widget _bigFigure(String value, String unit) {
return Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
text(value, 38, TextType.Medium, color: colorWhite, letterSpacing: -1),
const SizedBox(width: 4),
Flexible(
child: text(unit, 16, TextType.Regular,
color: colorHeaderMuted, maxLines: 1),
),
],
);
}
Widget _planThumb(Commitment item) {
return Container(
width: 74,
height: 74,
decoration: BoxDecoration(
border: Border.all(color: colorWhite, width: 2),
),
child: Opacity(
opacity: _isDone(item) ? 0.55 : 1,
child: SceneArt(
palette: classScene(item.commitmentClass),
seed: sceneSeed(item.title),
figure: _classIcon(item.commitmentClass),
figureScale: 0.46,
figureAlignment: const Alignment(0, 0.1),
),
),
);
}
/// The report-card letter, large, with "Score" under it.
Widget _score() {
final String grade =
(_week?.grade.isNotEmpty ?? false) ? _week!.grade : "–";
return GestureDetector(
onTap: _onOpenReportCard,
behavior: HitTestBehavior.opaque,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
text(grade, 46, TextType.Medium, color: colorWhite, height: 1),
const SizedBox(height: 2),
text("Score", 16, TextType.Regular, color: colorHeaderMuted),
],
),
);
}
Widget _todayClassBar(CommitmentClass value) {
final List<Commitment> items =
_plan.where((item) => item.commitmentClass == value).toList();
final int done = items.where(_isDone).length;
return _bar(
_barLabel(value),
items.isEmpty ? 0 : done / items.length,
"$done/${items.length} done",
_barColor(value),
);
}
Widget _weeklyClassBar(CommitmentClass value) {
final double rate = _week?.completionByClass[value.name] ?? 0;
return _bar(_barLabel(value), rate, "${(rate * 100).round()}% kept",
_barColor(value));
}
Widget _bar(String label, double fraction, String caption, Color fill) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text(label, 14, TextType.Medium, color: colorWhite, maxLines: 1),
const SizedBox(height: 9),
Container(
height: 7,
color: colorHeaderLine,
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: fraction.clamp(0.0, 1.0),
child: Container(color: fill),
),
),
const SizedBox(height: 9),
text(caption, 13, TextType.Regular, color: colorHeaderMuted),
],
);
}
/// Standing, debt and overdue in one quiet line under the numbers.
Widget _standingLine() {
final Color tone = _standing == Standing.Good
? colorBarElective
: _standing == Standing.Warned
? colorBarStandard
: colorBarNonNegotiable;
return GestureDetector(
onTap: _overdue.isEmpty ? _onOpenReportCard : _onOpenOverdue,
behavior: HitTestBehavior.opaque,
child: Container(
padding: const EdgeInsets.only(top: 16),
decoration: BoxDecoration(
border: Border(top: BorderSide(color: colorHeaderLine)),
),
child: Row(
children: [
Container(width: 8, height: 8, color: tone),
const SizedBox(width: 8),
text(standingLabel(_standing), 13, TextType.Medium, color: tone),
const SizedBox(width: 10),
Expanded(
child: text(
"Debt ${formatDebt(_debt)} · ${_overdue.length} overdue",
13,
TextType.Regular,
color: colorHeaderMuted,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Icon(CupertinoIcons.chevron_right,
size: 14, color: colorHeaderMuted),
],
),
),
);
}
// ── Water ─────────────────────────────────────────────────────────────────
/// Water against the daily target, as drops you tap to fill.
Widget _waterCard() {
final int filled = (_waterMl / _waterStep).floor();
return GlassSurface(
tint: colorGlassStrong,
padding: const EdgeInsets.fromLTRB(20, 18, 16, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text("Water", 15, TextType.Regular, color: colorInkSoft),
const SizedBox(height: 4),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
text(_thousands(_waterMl), 34, TextType.Medium,
color: colorInk, letterSpacing: -1),
const SizedBox(width: 4),
text("/${_thousands(_waterTarget)} ml", 16,
TextType.Regular,
color: colorInkSoft),
],
),
],
),
),
glassCircle(CupertinoIcons.add, _onAddWater, size: 52),
],
),
const SizedBox(height: 18),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
for (int index = 0; index < _waterTarget ~/ _waterStep; index++)
GestureDetector(
onTap: () => _onSetWaterDrops(index + 1),
behavior: HitTestBehavior.opaque,
child: AnimatedScale(
duration: const Duration(milliseconds: 240),
curve: Curves.easeOutBack,
scale: index < filled ? 1 : 0.9,
child: Icon(
Icons.water_drop,
size: 38,
color: index < filled ? colorWater : colorWaterEmpty,
),
),
),
],
),
],
),
);
}
// ── Add sheet ─────────────────────────────────────────────────────────────
/// Everything you can add, over the blurred day — a close button floating
/// above a white sheet of tiles.
Widget _addSheet(BuildContext sheetContext) {
return Stack(
children: [
Positioned.fill(
child: GestureDetector(
onTap: _onCloseAddSheet,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(color: colorNight.withValues(alpha: 0.35)),
),
),
),
Align(
alignment: Alignment.bottomCenter,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: _onCloseAddSheet,
child: Container(
width: 54,
height: 54,
color: colorWhite,
child: Icon(CupertinoIcons.xmark, size: 22, color: colorInk),
),
),
const SizedBox(height: 16),
Container(
width: double.infinity,
color: colorWhite,
padding: EdgeInsets.fromLTRB(
18, 14, 18, MediaQuery.of(sheetContext).padding.bottom + 22),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Center(
child: Container(
width: 40, height: 4, color: colorWaterEmpty),
),
const SizedBox(height: 18),
text("Add to today", 24, TextType.Medium,
color: colorInk, align: TextAlign.center),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: _addTile(CupertinoIcons.checkmark_seal,
"Commitment", _onAddPickCommitment)),
const SizedBox(width: 10),
Expanded(
child: _addTile(kindIcon(RoutineKind.Workout),
"Workout", _onAddPickWorkout)),
const SizedBox(width: 10),
Expanded(
child: _addTile(kindIcon(RoutineKind.Meditation),
"Meditate", _onAddPickMeditation)),
],
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: _addWideTile(kindIcon(RoutineKind.MustDo),
"Must-do chain", _onAddPickMustDo)),
const SizedBox(width: 10),
Expanded(
child: _addWideTile(CupertinoIcons.flag,
"Goal", _onAddPickGoal)),
],
),
const SizedBox(height: 10),
_addWideTile(CupertinoIcons.square_list, "Quick to-do",
_onAddPickTodo),
],
),
),
],
),
),
],
);
}
Widget _addTile(IconData icon, String label, VoidCallback onTap) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 22),
color: colorAmbientMid,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 26, color: colorInk),
const SizedBox(height: 10),
text(label, 15, TextType.Regular, color: colorInk),
],
),
),
);
}
Widget _addWideTile(IconData icon, String label, VoidCallback onTap) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 22),
color: colorAmbientMid,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 24, color: colorInk),
const SizedBox(width: 12),
Flexible(
child: text(label, 16, TextType.Regular,
color: colorInk, maxLines: 1),
),
],
),
),
);
}
// ── Header helpers ────────────────────────────────────────────────────────
/// A portrait cut from the painted landscape, with the initial on it.
Widget _avatar(double size) {
final String initial =
_user.name.isEmpty ? "G" : _user.name.substring(0, 1).toUpperCase();
return Container(
width: size,
height: size,
decoration: BoxDecoration(
border: Border.all(color: colorWhite, width: 2),
),
child: Stack(
fit: StackFit.expand,
children: [
SceneArt(palette: sceneWorkout, seed: sceneSeed(_user.name)),
Center(
child: text(initial, size * 0.4, TextType.Medium,
color: colorWhite),
),
],
),
);
}
String _greetingWord() {
final int hour = DateTime.now().hour;
if (hour < 12) return "Good morning";
if (hour < 17) return "Good afternoon";
return "Good evening";
}
IconData _greetingIcon() {
final int hour = DateTime.now().hour;
if (hour < 17) return CupertinoIcons.sun_max_fill;
return CupertinoIcons.moon_stars_fill;
}
/// A day counts as kept at 80% of what was promised.
static const double _keptFloor = 0.8;
/// Whether the day [daysAgo] back was kept. Today counts once everything
/// on the plan is done.
bool _kept(int daysAgo) {
if (daysAgo == 0) {
return _plan.isNotEmpty && _doneCount == _plan.length;
}
final int weekday =
DateTime.now().subtract(Duration(days: daysAgo)).weekday;
return (_week?.completionByWeekday[weekday] ?? 0) >= _keptFloor;
}
/// Kept days in a row, counted back from yesterday, plus today once kept.
int _streak() {
int count = _kept(0) ? 1 : 0;
for (int daysAgo = 1; daysAgo <= 6; daysAgo++) {
if (!_kept(daysAgo)) break;
count++;
}
return count;
}
String _barLabel(CommitmentClass value) {
switch (value) {
case CommitmentClass.NonNegotiable:
return "Must";
case CommitmentClass.Standard:
return "Standard";
case CommitmentClass.Elective:
return "Elective";
}
}
Color _barColor(CommitmentClass value) {
switch (value) {
case CommitmentClass.NonNegotiable:
return colorBarNonNegotiable;
case CommitmentClass.Standard:
return colorBarStandard;
case CommitmentClass.Elective:
return colorBarElective;
}
}
double _heldMinutes() {
double total = 0;
for (Commitment item in _plan.where(_isDone)) {
total = total + item.estMinutes;
}
return total;
}
String _thousands(int value) {
final String digits = value.toString();
final StringBuffer out = StringBuffer();
for (int index = 0; index < digits.length; index++) {
if (index > 0 && (digits.length - index) % 3 == 0) {
out.write(",");
}
out.write(digits[index]);
}
return out.toString();
}
/// Grounded and distress bodies keep their copy; they sit on a frosted
/// panel so they belong to the same surface as everything else.
Widget _frosted(Widget child) {
return Container(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 24),
decoration: BoxDecoration(
color: colorFrost,
borderRadius: BorderRadius.zero,
border: Border.all(color: colorGlassBorder),
),
child: child,
);
}
/// The normal day: categories, the up-next rail, the pattern and the plan.
Widget _planBody() {
final int overdueCount = _overdue.length;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (overdueCount > 0) ...[
_overdueCallout(overdueCount),
const SizedBox(height: 26),
],
sectionBreak("Your day",
trailing: _viewAll("View all", _onOpenRoutines)),
_categoryRail(),
const SizedBox(height: 28),
_todoSection(),
const SizedBox(height: 28),
if (_upNext.isNotEmpty) ...[
sectionBreak("Up next",
caption: "${_upNext.length} routines",
trailing: _viewAll("Library", _onOpenRoutines)),
_featuredRail(),
const SizedBox(height: 28),
],
if (_insight != null) ...[
_insightCard(_insight!),
const SizedBox(height: 28),
],
sectionBreak(
"The plan",
caption: "$_doneCount of ${_plan.length} done",
trailing: _plan.isEmpty
? null
: text(formatMinutes(_plannedMinutes()), 12, TextType.Medium,
color: colorInkSoft),
),
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 ...[
for (int index = 0; index < _plan.length; index++)
_commitmentRow(_plan[index])
.animate()
.fadeIn(duration: 350.ms, delay: (60 * index).ms)
.slideX(
begin: 0.06,
end: 0,
duration: 400.ms,
delay: (60 * index).ms,
curve: Curves.easeOut),
addTaskAffordance(
_onAddCommitment,
enabled: StandingEngine.permitsNewCommitment(_standing),
),
],
if (!StandingEngine.permitsNewCommitment(_standing)) ...[
const SizedBox(height: 4),
text(
ToneEngine.standingBody(_standing, _user.tone),
12,
TextType.Regular,
color: colorInkSoft,
align: TextAlign.center,
),
],
],
);
}
Widget _viewAll(String label, VoidCallback onTap) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: text(label, 12, TextType.Regular, color: colorInkSoft),
),
);
}
/// Round painted portraits — tap one to open that part of the day.
Widget _categoryRail() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_category(sceneWorkout, kindIcon(RoutineKind.Workout), "Workout", 3,
() => _onOpenKind(RoutineKind.Workout)),
_category(sceneMeditation, kindIcon(RoutineKind.Meditation),
"Meditate", 5, () => _onOpenKind(RoutineKind.Meditation)),
_category(sceneMustDo, kindIcon(RoutineKind.MustDo), "Must-do", 8,
() => _onOpenKind(RoutineKind.MustDo)),
_category(goalScene(goalAccents[4]), CupertinoIcons.flag_fill,
"Goals", 13, _onOpenGoals),
_category(goalScene(goalAccents[2]),
CupertinoIcons.chart_bar_alt_fill, "Report", 21, _onOpenReportCard),
],
);
}
Widget _category(List<Color> palette, IconData icon, String label, int seed,
VoidCallback onTap) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 74,
height: 74,
decoration: BoxDecoration(
border: Border.all(color: colorWhite, width: 2.5),
boxShadow: const [
BoxShadow(
color: Color(0x1F2A3A30),
blurRadius: 14,
offset: Offset(0, 6),
),
],
),
child: ClipRect(
child: SceneArt(
palette: palette,
seed: seed,
figure: icon,
figureScale: 0.48,
figureAlignment: const Alignment(0, 0.25),
),
),
),
const SizedBox(height: 8),
text(label, 12, TextType.Regular, color: colorInk),
],
),
);
}
Widget _featuredRail() {
final List<RoutineChain> items = _upNext;
return SizedBox(
height: 290,
child: ListView.separated(
scrollDirection: Axis.horizontal,
clipBehavior: Clip.none,
itemCount: items.length,
separatorBuilder: (context, index) => const SizedBox(width: 12),
itemBuilder: (context, index) => _featuredCard(items[index]),
),
);
}
/// The image-led card: a painted scene, a glass chip, the title and three
/// light numbers along the bottom.
Widget _featuredCard(RoutineChain routine) {
final Color label = colorWhite.withValues(alpha: 0.75);
final bool workout = routine.kind == RoutineKind.Workout;
return GestureDetector(
onTap: () => _onStartRoutine(routine),
child: Container(
width: 244,
decoration: BoxDecoration(
borderRadius: BorderRadius.zero,
boxShadow: const [
BoxShadow(
color: Color(0x262A3A30),
blurRadius: 24,
offset: Offset(0, 10),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.zero,
child: Stack(
fit: StackFit.expand,
children: [
SceneArt(
palette: scenePalette(routine.kind),
seed: sceneSeed(routine.title),
figure: kindIcon(routine.kind),
figureScale: 0.44,
figureAlignment: const Alignment(0.1, -0.25),
shade: true,
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
GlassSurface(
onDark: true,
radius: BorderRadius.zero,
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 5),
child: text(
routine.startMinute >= 0
? formatStartMinute(routine.startMinute)
: kindLabel(routine.kind),
10,
TextType.Medium,
color: colorWhite,
),
),
const Spacer(),
glassCircle(CupertinoIcons.play_fill,
() => _onStartRoutine(routine),
size: 40, onDark: true),
],
),
const Spacer(),
text(routine.title, 20, TextType.Medium,
color: colorWhite,
maxLines: 2,
overflow: TextOverflow.ellipsis),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: _cardStat(
"${(routine.totalSeconds / 60).round()}",
CupertinoIcons.clock,
"Min",
label),
),
Expanded(
child: _cardStat("${routine.steps.length}",
CupertinoIcons.list_bullet, "Steps", label),
),
Expanded(
child: _cardStat(
workout
? "${routine.totalSets}"
: "${(routine.partialCompletion * 100).round()}%",
workout
? CupertinoIcons.repeat
: CupertinoIcons.chart_pie,
workout ? "Sets" : "Held",
label),
),
],
),
],
),
),
],
),
),
),
);
}
Widget _cardStat(String value, IconData icon, String caption, Color tone) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text(value, 24, TextType.Light, color: colorWhite),
const SizedBox(height: 2),
Row(
children: [
Icon(icon, size: 10, color: tone),
const SizedBox(width: 3),
text(caption, 9, TextType.Regular, color: tone),
],
),
],
);
}
/// The quick to-do list: things to remember that are not promises. No
/// window, no debt. Open items first, done ones struck through below.
Widget _todoSection() {
final List<TodoItem> open = _todos.where((item) => !item.done).toList();
final List<TodoItem> done = _todos.where((item) => item.done).toList();
return Column(
key: _todoKey,
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
sectionBreak(
"To-do",
caption: "${open.length} open, no debt",
trailing: done.isEmpty
? null
: _viewAll("Clear done", _onClearDoneTodos),
),
GlassSurface(
tint: colorGlassStrong,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 6, 6, 6),
child: Row(
children: [
Icon(CupertinoIcons.pencil, size: 20, color: colorInkSoft),
const SizedBox(width: 12),
Expanded(
child: quickAddField(
_todoInput, "Add a to-do", _onAddTodo,
focusNode: _todoFocus),
),
GestureDetector(
onTap: _onAddTodoPressed,
behavior: HitTestBehavior.opaque,
child: Container(
width: 48,
height: 48,
color: colorNight,
alignment: Alignment.center,
child: Icon(CupertinoIcons.add,
size: 22, color: colorLime),
),
),
],
),
),
for (TodoItem item in <TodoItem>[...open, ...done])
_todoRow(item),
if (_todos.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 20),
child: text(
"Nothing to remember. Jot anything here; it stays on this phone.",
13,
TextType.Regular,
color: colorInkSoft,
),
),
],
),
),
],
);
}
Widget _todoRow(TodoItem item) {
return Container(
decoration: BoxDecoration(
border: Border(top: BorderSide(color: colorGlassBorder)),
),
padding: const EdgeInsets.fromLTRB(14, 4, 4, 4),
child: Row(
children: [
GestureDetector(
onTap: () => _onToggleTodo(item),
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.all(6),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 30,
height: 30,
decoration: BoxDecoration(
color: item.done ? colorPrimary : colorWhite,
border: Border.all(
color: item.done
? colorPrimary
: colorInk.withValues(alpha: 0.25),
width: 2,
),
),
child: item.done
? Icon(Icons.check_rounded, size: 20, color: colorWhite)
: null,
),
),
),
const SizedBox(width: 10),
Expanded(
child: GestureDetector(
onTap: () => _onToggleTodo(item),
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: text(
item.title,
16,
TextType.Regular,
color: item.done ? colorInkSoft : colorInk,
decoration: item.done ? TextDecoration.lineThrough : null,
),
),
),
),
GestureDetector(
onTap: () => _onRemoveTodo(item),
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.all(14),
child: Icon(CupertinoIcons.xmark, size: 16, color: colorInkSoft),
),
),
],
),
);
}
/// 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: colorInkSoft, 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: colorInkSoft,
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: colorInkSoft, letterSpacing: 1.2),
const SizedBox(height: 10),
displayTitle(ToneEngine.distressHeadline()),
const SizedBox(height: 14),
text(
ToneEngine.distressBody(),
15,
TextType.Regular,
color: colorInkSoft,
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: colorInk),
const SizedBox(height: 6),
text(
"Nothing you miss this week is counting against you.",
13,
TextType.Regular,
color: colorInkSoft,
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),
],
);
}
/// Overdue, as a warm card with a painted strip — impossible to miss,
/// still part of the same world.
Widget _overdueCallout(int count) {
return GestureDetector(
onTap: _onOpenOverdue,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.zero,
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[colorOverdueWarm, colorOverdueWarmEnd],
),
border: Border.all(color: colorWhite, width: 1.5),
boxShadow: const [
BoxShadow(
color: Color(0x1AB5442F),
blurRadius: 24,
offset: Offset(0, 10),
),
],
),
padding: const EdgeInsets.all(18),
child: Row(
children: [
Container(
width: 58,
height: 58,
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: const RadialGradient(
center: Alignment(-0.3, -0.4),
colors: <Color>[colorOverdueGlow, colorStandingGrounded],
),
boxShadow: <BoxShadow>[
BoxShadow(
color: colorStandingGrounded.withValues(alpha: 0.35),
blurRadius: 12,
offset: const Offset(0, 5),
),
],
),
child: text("$count", 19, TextType.Medium, color: colorWhite),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text("Overdue", 11, TextType.Medium,
color: colorStandingGrounded),
const SizedBox(height: 3),
text(
count >= Thresholds.maxOpenOverdue
? "You are at the cap. Nothing new until this drops."
: "$count item${count == 1 ? "" : "s"} past the window.",
15,
TextType.Medium,
color: colorInk,
),
],
),
),
glassCircle(CupertinoIcons.arrow_right, _onOpenOverdue, size: 44),
],
),
),
);
}
/// The excuse confrontation. One pattern, stated plainly, on the dark
/// night card with the lime spark.
Widget _insightCard(ExcuseCluster cluster) {
return GestureDetector(
onTap: _onOpenExcuses,
child: Container(
padding: const EdgeInsets.fromLTRB(22, 22, 22, 24),
decoration: BoxDecoration(
borderRadius: BorderRadius.zero,
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[colorNightLift, colorNight],
),
boxShadow: const [
BoxShadow(
color: Color(0x332A3A30),
blurRadius: 24,
offset: Offset(0, 10),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Icon(CupertinoIcons.sparkles, size: 14, color: colorLime),
const SizedBox(width: 6),
text("Pattern spotted", 12, TextType.Medium, color: colorLime),
const Spacer(),
text("${cluster.occurrences}× this month", 11,
TextType.Regular,
color: colorWhite.withValues(alpha: 0.5)),
],
),
const SizedBox(height: 12),
text(cluster.insight, 18, TextType.Regular,
color: colorWhite, height: 1.5),
],
),
),
);
}
IconData _classIcon(CommitmentClass value) {
switch (value) {
case CommitmentClass.NonNegotiable:
return CupertinoIcons.flame_fill;
case CommitmentClass.Standard:
return CupertinoIcons.bolt_fill;
case CommitmentClass.Elective:
return CupertinoIcons.sun_max_fill;
}
}
/// A task row: painted thumbnail, the class as a coloured kicker, the title,
/// the window and cost, and a round tick on the right.
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 = item.status == CommitmentStatus.LateCompleted
? colorStandingWarned
: classColor(item.commitmentClass);
return Container(
margin: const EdgeInsets.only(bottom: 12),
child: card(
padding: const EdgeInsets.fromLTRB(10, 10, 14, 10),
onTap: _onOpenOverdue,
child: Row(
children: [
SizedBox(
width: 88,
height: 88,
child: ClipRRect(
borderRadius: BorderRadius.zero,
child: Opacity(
opacity: ticked ? 0.45 : 1,
child: SceneArt(
palette: classScene(item.commitmentClass),
seed: sceneSeed(item.title),
figure: _classIcon(item.commitmentClass),
figureScale: 0.46,
figureAlignment: const Alignment(0, 0.1),
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text(
late
? overdueLabel(item)
: item.status == CommitmentStatus.LateCompleted
? "Completed late"
: classLabel(item.commitmentClass),
10,
TextType.Medium,
color: late ? colorStandingGrounded : accent,
),
const SizedBox(height: 3),
text(
item.title,
17,
TextType.Medium,
color: ticked ? colorInkSoft : colorInk,
maxLines: 2,
overflow: TextOverflow.ellipsis,
decoration: ticked ? TextDecoration.lineThrough : null,
),
const SizedBox(height: 5),
Row(
children: [
Icon(CupertinoIcons.clock, size: 11,
color: colorInkSoft),
const SizedBox(width: 4),
Flexible(
child: text(formatWindow(item), 11, TextType.Regular,
color: colorInkSoft,
maxLines: 1,
overflow: TextOverflow.ellipsis),
),
const SizedBox(width: 10),
Icon(CupertinoIcons.timer, size: 11,
color: colorInkSoft),
const SizedBox(width: 4),
text(formatMinutes(item.estMinutes), 11,
TextType.Regular,
color: colorInkSoft),
],
),
],
),
),
const SizedBox(width: 8),
_roundTick(ticked, accent, settled ? null : () => _onTick(item)),
],
),
),
);
}
/// The tick as a ring: hollow while open, filled with a check once done.
Widget _roundTick(bool checked, Color accent, VoidCallback? onTap) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.all(4),
child: AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutBack,
width: 44,
height: 44,
decoration: BoxDecoration(
color: checked ? colorPrimary : colorWhite,
border: Border.all(
color: checked ? colorPrimary : accent.withValues(alpha: 0.35),
width: 2,
),
boxShadow: checked
? <BoxShadow>[
BoxShadow(
color: colorPrimary.withValues(alpha: 0.35),
blurRadius: 10,
offset: const Offset(0, 4),
),
]
: const [],
),
child: checked
? Icon(Icons.check_rounded, size: 24, color: colorWhite)
: null,
),
),
);
}
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 onWeekLoaded(ReportCard? week) {
setState(() {
_week = week;
});
}
@override
void onWaterLoaded(int ml) {
setState(() {
_waterMl = ml;
});
}
@override
void onTodosLoaded(List<TodoItem> todos) {
setState(() {
_todos = todos;
});
}
@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 onRoutinesLoaded(List<RoutineChain> routines) {
setState(() {
_routines = routines;
});
}
@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,
);
}
}