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
1502 lines
48 KiB
Dart
1502 lines
48 KiB
Dart
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,
|
||
);
|
||
}
|
||
}
|