Groups let people keep each other honest: create one or join with a six-letter invite code. Each group has shared goals split into measurable objectives (km, sessions, words) that every member logs toward, a leaderboard of kept rate and streak with nudges, and a group chat where check-ins and nudges appear as cards beside the talk. The open chat polls for new messages. Reached from a new Groups tab (Training moves into the day rail), a Home "Your groups" rail that opens the chat, and a Group tile in the add sheet. Mock mode seeds two groups, a joinable one and members who reply. The water tracker is removed; it did not fit the app. Bottom sheets are now square app-wide via the theme. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
2243 lines
71 KiB
Dart
2243 lines
71 KiB
Dart
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/Group.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/MemberAvatar.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 '../group/GroupChat.dart';
|
||
import '../group/GroupEditor.dart';
|
||
import '../group/Groups.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>[];
|
||
|
||
List<Group> _groups = <Group>[];
|
||
|
||
final TextEditingController _todoInput = TextEditingController();
|
||
|
||
final FocusNode _todoFocus = FocusNode();
|
||
|
||
final GlobalKey _todoKey = GlobalKey();
|
||
|
||
ReportCard? _week;
|
||
|
||
bool _weekly = false;
|
||
|
||
|
||
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 _onOpenGroups() async {
|
||
await GroundedNavigation().navigateToPageWithData(const Groups(), context);
|
||
_model?.loadGroups();
|
||
}
|
||
|
||
void _onOpenGroupChat(Group group) async {
|
||
await GroundedNavigation()
|
||
.navigateToPageWithData(GroupChat(group: group), context);
|
||
_model?.loadGroups();
|
||
}
|
||
|
||
void _onAddPickGroup() async {
|
||
_onCloseAddSheet();
|
||
await GroundedNavigation()
|
||
.navigateToPageWithData(const GroupEditor(), context);
|
||
_model?.loadGroups();
|
||
}
|
||
|
||
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 _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)
|
||
_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.person_3_fill,
|
||
label: "Groups",
|
||
onTap: _onOpenGroups
|
||
),
|
||
(
|
||
icon: CupertinoIcons.chart_bar_alt_fill,
|
||
label: "Insights",
|
||
onTap: _onOpenReportCard
|
||
),
|
||
],
|
||
).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),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── 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: colorGrabber),
|
||
),
|
||
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: _addTile(kindIcon(RoutineKind.MustDo),
|
||
"Must-do", _onAddPickMustDo)),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: _addTile(CupertinoIcons.flag,
|
||
"Goal", _onAddPickGoal)),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: _addTile(CupertinoIcons.person_3,
|
||
"Group", _onAddPickGroup)),
|
||
],
|
||
),
|
||
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 (_groups.isNotEmpty) ...[
|
||
sectionBreak("Your groups",
|
||
caption: _groupsCaption(),
|
||
trailing: _viewAll("All groups", _onOpenGroups)),
|
||
_groupRail(),
|
||
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.flame_fill,
|
||
"Training", 21, _onOpenTraining),
|
||
],
|
||
);
|
||
}
|
||
|
||
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),
|
||
],
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
String _groupsCaption() {
|
||
int unread = 0;
|
||
for (Group group in _groups) {
|
||
unread = unread + group.unread;
|
||
}
|
||
return unread == 0 ? "${_groups.length} groups" : "$unread new messages";
|
||
}
|
||
|
||
/// Each group as a tile: its banner, who is in it, and the latest line.
|
||
/// Tapping goes straight into the chat.
|
||
Widget _groupRail() {
|
||
return SizedBox(
|
||
height: 214,
|
||
child: ListView.separated(
|
||
scrollDirection: Axis.horizontal,
|
||
clipBehavior: Clip.none,
|
||
itemCount: _groups.length,
|
||
separatorBuilder: (context, index) => const SizedBox(width: 12),
|
||
itemBuilder: (context, index) => _groupTile(_groups[index]),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _groupTile(Group group) {
|
||
final Color accent = goalColor(group.colourHex);
|
||
|
||
return GestureDetector(
|
||
onTap: () => _onOpenGroupChat(group),
|
||
child: Container(
|
||
width: 250,
|
||
decoration: BoxDecoration(
|
||
color: colorWhite,
|
||
boxShadow: const [
|
||
BoxShadow(
|
||
color: Color(0x142A3A30),
|
||
blurRadius: 20,
|
||
offset: Offset(0, 8),
|
||
),
|
||
],
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
SizedBox(
|
||
height: 96,
|
||
child: Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
SceneArt(
|
||
palette: goalScene(accent),
|
||
seed: sceneSeed(group.name),
|
||
figure: CupertinoIcons.person_3_fill,
|
||
figureScale: 0.5,
|
||
figureAlignment: const Alignment(0.75, 0),
|
||
shade: true,
|
||
),
|
||
Padding(
|
||
padding: const EdgeInsets.all(12),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
if (group.unread > 0)
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 8, vertical: 4),
|
||
color: colorLime,
|
||
child: text("${group.unread} new", 10,
|
||
TextType.Medium,
|
||
color: colorNight),
|
||
),
|
||
const Spacer(),
|
||
text(group.name, 18, TextType.Medium,
|
||
color: colorWhite,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Expanded(
|
||
child: Padding(
|
||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 12),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
text(
|
||
group.lastMessage.isEmpty
|
||
? "No messages yet"
|
||
: "${group.lastAuthor}: ${group.lastMessage}",
|
||
13,
|
||
TextType.Regular,
|
||
color: colorInk,
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
height: 1.4,
|
||
),
|
||
const Spacer(),
|
||
Row(
|
||
children: [
|
||
avatarStack(
|
||
group.members.map((member) => member.name).toList(),
|
||
26),
|
||
const Spacer(),
|
||
Icon(CupertinoIcons.chat_bubble_2_fill,
|
||
size: 16, color: colorInkSoft),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 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 onGroupsLoaded(List<Group> groups) {
|
||
setState(() {
|
||
_groups = groups;
|
||
});
|
||
}
|
||
|
||
@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,
|
||
);
|
||
}
|
||
}
|