Files
GroundedHelper/frontend/lib/Grounded/see/home/HomeState.dart
alvocool 9c858b8146 Square every corner, enlarge the main surfaces and add a to-do list
Rounded rectangles and round controls are now square across the app; only
status dots, glows, the sun, rings and the breathing circle stay round.
Cards, rows, thumbnails, featured cards, category tiles, the standing card
and the nav are larger. Home gets a quick To-do list kept on the device
(TodoItem in InternalMemory), seeded with sample items in mock mode.

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

1502 lines
48 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: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/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();
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();
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 _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 Scaffold(
backgroundColor: colorAmbientMid,
body: AmbientBackground(
child: Stack(
children: [
SafeArea(
bottom: false,
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 130),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
_topBar(),
const SizedBox(height: 26),
_greeting(queueIsHome),
const SizedBox(height: 24),
if (!_distressed) ...[
_standingCard(),
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,
actionIcon: CupertinoIcons.add,
onAction: _onAddCommitment,
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.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),
),
],
),
),
);
}
Widget _topBar() {
return Row(
children: [
_avatar(54),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text(
"${weekdayName(DateTime.now().weekday)}, ${DateTime.now().day} ${_monthName(DateTime.now().month)}",
12,
TextType.Regular,
color: colorInkSoft,
),
const SizedBox(height: 2),
text("Grounded", 15, TextType.Medium, color: colorInk),
],
),
),
glassCircle(
CupertinoIcons.bell,
_onOpenOverdue,
dotted: _overdue.isNotEmpty,
dotColor: colorStandingGrounded,
),
const SizedBox(width: 8),
glassCircle(CupertinoIcons.chart_bar_alt_fill, _onOpenReportCard),
const SizedBox(width: 8),
glassCircle(
CupertinoIcons.person,
_onOpenSettings,
dotted: _user.sickMode,
dotColor: colorStandingWarned,
),
],
);
}
/// A round 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),
boxShadow: const [
BoxShadow(
color: Color(0x1A1A1F1C),
blurRadius: 12,
offset: Offset(0, 4),
),
],
),
child: ClipRect(
child: Stack(
fit: StackFit.expand,
children: [
SceneArt(palette: sceneWorkout, seed: sceneSeed(_user.name)),
Center(
child: text(initial, size * 0.4, TextType.Medium,
color: colorWhite),
),
],
),
),
);
}
/// "Hey ◯ Alex / Here's what to hold today." — two tones, oversized.
Widget _greeting(bool queueIsHome) {
final String name = _user.name.isEmpty ? "there" : _user.name;
String second;
if (_distressed) {
second = "Go easy on\nyourself today.";
} else if (queueIsHome) {
second = "Clear what you\nowe first.";
} else if (_plan.isNotEmpty && _doneCount == _plan.length) {
second = "Every promise\nkept today.";
} else {
second = "Here's what to\nhold today.";
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
text("Hey", 38, TextType.Medium,
color: colorInk, letterSpacing: -1.0),
const SizedBox(width: 10),
_avatar(36),
const SizedBox(width: 10),
Flexible(
child: text(name, 38, TextType.Medium,
color: colorInk,
letterSpacing: -1.0,
maxLines: 1,
overflow: TextOverflow.ellipsis),
),
],
),
const SizedBox(height: 4),
text(second, 34, TextType.Regular,
color: colorInkSoft, height: 1.12, letterSpacing: -0.8),
],
)
.animate()
.fadeIn(duration: 500.ms)
.slideY(begin: 0.15, end: 0, duration: 600.ms, curve: Curves.easeOut);
}
/// The day at a glance: a ring for what is done, the standing, and the
/// three numbers that matter.
Widget _standingCard() {
final Color tone = standingColor(_standing);
final double fraction = _plan.isEmpty ? 0 : _doneCount / _plan.length;
return GestureDetector(
onTap: _overdue.isEmpty ? _onOpenReportCard : _onOpenOverdue,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.zero,
boxShadow: const [
BoxShadow(
color: Color(0x142A3A30),
blurRadius: 30,
offset: Offset(0, 12),
),
],
),
child: GlassSurface(
radius: BorderRadius.zero,
tint: colorGlassStrong,
padding: const EdgeInsets.all(22),
child: Row(
children: [
ProgressRing(
fraction: fraction,
size: 104,
stroke: 9,
fill: colorPrimary,
track: colorPrimary.withValues(alpha: 0.12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
text("$_doneCount/${_plan.length}", 23, TextType.Medium,
color: colorInk),
text("done", 10, TextType.Regular, color: colorInkSoft),
],
),
),
const SizedBox(width: 18),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: tone,
shape: BoxShape.circle,
boxShadow: <BoxShadow>[
BoxShadow(
color: tone.withValues(alpha: 0.5),
blurRadius: 6),
],
),
),
const SizedBox(width: 7),
text(standingLabel(_standing).toUpperCase(), 9,
TextType.Bold,
color: tone, letterSpacing: 1.2),
],
),
const SizedBox(height: 6),
text(
ToneEngine.standingHeadline(_standing, _user.tone),
17,
TextType.Medium,
color: colorInk,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: _miniStat("Debt", formatDebt(_debt))),
Expanded(
child: _miniStat(
"Planned", formatMinutes(_plannedMinutes())),
),
Expanded(
child: _miniStat("Overdue", "${_overdue.length}",
alert: _overdue.isNotEmpty),
),
],
),
],
),
),
],
),
),
),
).animate().fadeIn(duration: 500.ms, delay: 120.ms).slideY(
begin: 0.15, end: 0, duration: 600.ms, delay: 120.ms,
curve: Curves.easeOut);
}
Widget _miniStat(String label, String value, {bool alert = false}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text(value, 23, TextType.Light,
color: alert ? colorStandingGrounded : colorInk),
const SizedBox(height: 2),
text(label, 10, TextType.Regular, color: colorInkSoft),
],
);
}
/// 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(
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),
),
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,
),
),
);
}
String _monthName(int month) {
const List<String> names = <String>[
"January", "February", "March", "April", "May", "June", "July",
"August", "September", "October", "November", "December",
];
return names[month - 1];
}
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 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,
);
}
}