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
527 lines
16 KiB
Dart
527 lines
16 KiB
Dart
import 'package:flutter/cupertino.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:stacked/stacked.dart';
|
||
|
||
import '../../about/external/data/Commitment.dart';
|
||
import '../../about/external/data/Goal.dart';
|
||
import '../../about/external/data/RoutineChain.dart';
|
||
import '../../about/internal/application/CommitmentStatus.dart';
|
||
import '../../about/internal/application/NotificationType.dart';
|
||
import '../../about/internal/application/ProofType.dart';
|
||
import '../../about/internal/application/RoutineKind.dart';
|
||
import '../../about/internal/application/TextType.dart';
|
||
import '../../configs/Navigator.dart';
|
||
import '../../designs/Checkbox.dart';
|
||
import '../../designs/Component.dart';
|
||
import '../../designs/Responsive.dart';
|
||
import '../../designs/Shell.dart';
|
||
import '../../designs/buttons/Buttons.dart';
|
||
import '../../designs/scene/SceneArt.dart';
|
||
import '../../designs/text/Text.dart';
|
||
import '../../utils/Colors.dart';
|
||
import '../../utils/CommonUtils.dart';
|
||
import '../../utils/DebtEngine.dart';
|
||
import '../commitment/NewCommitment.dart';
|
||
import '../live/LiveTask.dart';
|
||
import '../routine/RoutineBuilder.dart';
|
||
import '../routine/RoutineRunner.dart';
|
||
import 'ConnectGoalDetail.dart';
|
||
import 'GoalDetail.dart';
|
||
import 'GoalEditor.dart';
|
||
import 'ViewGoalDetail.dart';
|
||
|
||
class GoalDetailState extends State<GoalDetail>
|
||
implements ConnectGoalDetail {
|
||
ViewGoalDetail? _model;
|
||
|
||
Goal _goal = Goal();
|
||
|
||
List<Commitment> _tasks = <Commitment>[];
|
||
|
||
List<RoutineChain> _routines = <RoutineChain>[];
|
||
|
||
bool _changed = false;
|
||
|
||
/// Ticked locally, awaiting the server.
|
||
final Set<String> _ticked = <String>{};
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ViewModelBuilder<ViewGoalDetail>.reactive(
|
||
viewModelBuilder: () => ViewGoalDetail(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() {
|
||
setState(() {
|
||
_goal = widget.goal;
|
||
});
|
||
_model?.loadTasks(widget.goal);
|
||
}
|
||
|
||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||
|
||
void _onBack() {
|
||
Navigator.pop(context, _changed);
|
||
}
|
||
|
||
void _onStartTask(Commitment task) {
|
||
_model?.startTask(task);
|
||
}
|
||
|
||
/// Ticking completes the task without opening the runner — the runner is for
|
||
/// work you actually want timed.
|
||
void _onTick(Commitment task) {
|
||
if (task.id == null || _ticked.contains(task.id)) {
|
||
return;
|
||
}
|
||
|
||
setState(() {
|
||
_ticked.add(task.id!);
|
||
});
|
||
|
||
_model?.tick(task);
|
||
}
|
||
|
||
void _onAddTask() async {
|
||
final result = await GroundedNavigation()
|
||
.navigateToPageWithData(NewCommitment(goal: _goal), context);
|
||
|
||
if (result == true) {
|
||
_changed = true;
|
||
_model?.loadTasks(_goal);
|
||
}
|
||
}
|
||
|
||
void _onAddRoutine() async {
|
||
final result = await GroundedNavigation()
|
||
.navigateToPageWithData(RoutineBuilder(goal: _goal), context);
|
||
|
||
if (result == true) {
|
||
_model?.loadRoutines(_goal);
|
||
}
|
||
}
|
||
|
||
void _onEditRoutine(RoutineChain routine) async {
|
||
final result = await GroundedNavigation()
|
||
.navigateToPageWithData(
|
||
RoutineBuilder(routine: routine, goal: _goal), context);
|
||
|
||
if (result == true) {
|
||
_model?.loadRoutines(_goal);
|
||
}
|
||
}
|
||
|
||
void _onStartRoutine(RoutineChain routine) async {
|
||
if (routine.steps.isEmpty) {
|
||
_onEditRoutine(routine);
|
||
return;
|
||
}
|
||
|
||
final result = await GroundedNavigation().navigateToPageWithData(
|
||
RoutineRunner(routine: routine.copy()), context);
|
||
|
||
if (result == true) {
|
||
_changed = true;
|
||
_model?.loadRoutines(_goal);
|
||
}
|
||
}
|
||
|
||
void _onEditGoal() async {
|
||
final result = await GroundedNavigation()
|
||
.navigateToPageWithData(GoalEditor(goal: _goal), context);
|
||
|
||
if (!mounted) {
|
||
return;
|
||
}
|
||
|
||
if (result == "archived") {
|
||
Navigator.pop(context, true);
|
||
return;
|
||
}
|
||
|
||
if (result is Goal) {
|
||
_changed = true;
|
||
setState(() {
|
||
_goal = result;
|
||
});
|
||
}
|
||
}
|
||
|
||
// ── Views ─────────────────────────────────────────────────────────────────
|
||
|
||
Widget _mobileView(BoxConstraints constraints) {
|
||
final List<Commitment> open = _tasks
|
||
.where((task) =>
|
||
task.status != CommitmentStatus.Completed &&
|
||
task.status != CommitmentStatus.LateCompleted &&
|
||
task.status != CommitmentStatus.Abandoned)
|
||
.toList();
|
||
|
||
final List<Commitment> done = _tasks
|
||
.where((task) =>
|
||
task.status == CommitmentStatus.Completed ||
|
||
task.status == CommitmentStatus.LateCompleted)
|
||
.toList();
|
||
|
||
return Sheet(
|
||
eyebrow: _goal.category.isEmpty ? "Goal" : _goal.category,
|
||
title: _goal.title,
|
||
onBack: _onBack,
|
||
chrome: _goal.colourHex.isEmpty ? null : goalColor(_goal.colourHex),
|
||
scene: goalScene(goalColor(_goal.colourHex)),
|
||
sceneSeed: sceneSeed(_goal.title),
|
||
action: chromeAction(CupertinoIcons.pencil, _onEditGoal),
|
||
banner: _progressBanner(),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (_goal.description.isNotEmpty) ...[
|
||
text(_goal.description, 15, TextType.Regular,
|
||
color: colorGrey2, height: 1.6),
|
||
const SizedBox(height: 28),
|
||
],
|
||
sectionBreak("To do", caption: "${open.length} open"),
|
||
if (open.isEmpty)
|
||
emptyState(
|
||
CupertinoIcons.square_list,
|
||
"Nothing scheduled",
|
||
"Add the actual sessions — Monday shoulders, Wednesday legs — and they start counting.",
|
||
)
|
||
else
|
||
...open.map(_taskRow),
|
||
const SizedBox(height: 28),
|
||
sectionBreak(
|
||
"Routines",
|
||
caption: _routines.isEmpty
|
||
? "none yet"
|
||
: "${_routines.length} serving this goal",
|
||
),
|
||
if (_routines.isEmpty)
|
||
text(
|
||
"Routines are the repeatable part of a goal — the Monday session, the morning sit. Add one and it runs from here.",
|
||
13,
|
||
TextType.Regular,
|
||
color: colorGrey2,
|
||
height: 1.5,
|
||
)
|
||
else
|
||
..._routines.map(_routineRow),
|
||
const SizedBox(height: 12),
|
||
outlinedActionButton("Add a routine", _onAddRoutine,
|
||
icon: CupertinoIcons.square_stack_3d_up),
|
||
if (done.isNotEmpty) ...[
|
||
const SizedBox(height: 28),
|
||
sectionBreak("Done", caption: "${done.length}"),
|
||
...done.map(_doneRow),
|
||
],
|
||
const SizedBox(height: 24),
|
||
roundedCornerButton("Add a task", _onAddTask,
|
||
icon: CupertinoIcons.add),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _progressBanner() {
|
||
return Container(
|
||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
|
||
decoration: BoxDecoration(
|
||
color: colorWhite.withValues(alpha: 0.08),
|
||
borderRadius: BorderRadius.zero,
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
text("PROGRESS", 9, TextType.Bold,
|
||
color: colorWhite.withValues(alpha: 0.45),
|
||
letterSpacing: 1.2),
|
||
text("${(_goal.progress * 100).round()}%", 13, TextType.Bold,
|
||
color: colorWhite),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
meter(
|
||
_goal.progress,
|
||
fill: colorWhite,
|
||
track: colorWhite.withValues(alpha: 0.14),
|
||
height: 5,
|
||
),
|
||
const SizedBox(height: 14),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: labelled(
|
||
"Tasks",
|
||
"${_goal.completedTasks}/${_goal.totalTasks}",
|
||
valueSize: 15,
|
||
valueColor: colorWhite,
|
||
labelColor: colorWhite.withValues(alpha: 0.45),
|
||
),
|
||
),
|
||
Expanded(
|
||
child: labelled(
|
||
"Routines",
|
||
"${_routines.length}",
|
||
valueSize: 15,
|
||
valueColor: colorWhite,
|
||
labelColor: colorWhite.withValues(alpha: 0.45),
|
||
),
|
||
),
|
||
Expanded(
|
||
child: labelled(
|
||
"Deadline",
|
||
_goal.targetDate == null
|
||
? "None"
|
||
: targetLabel(_goal.targetDate),
|
||
valueSize: 15,
|
||
valueColor: colorWhite,
|
||
labelColor: colorWhite.withValues(alpha: 0.45),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// A task row leads with the action: the point of opening a goal is to start
|
||
/// something, not to admire the list.
|
||
Widget _taskRow(Commitment task) {
|
||
final Color accent = classColor(task.commitmentClass);
|
||
|
||
final bool late = task.windowClosed;
|
||
|
||
return Container(
|
||
margin: const EdgeInsets.only(bottom: 10),
|
||
child: card(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
TickRow(
|
||
checked: _ticked.contains(task.id),
|
||
title: task.title,
|
||
accent: accent,
|
||
onTick: () => _onTick(task),
|
||
detail: Row(
|
||
children: [
|
||
text(formatWindow(task), 11, TextType.Regular,
|
||
color: colorGrey2),
|
||
const SizedBox(width: 9),
|
||
Container(
|
||
width: 3,
|
||
height: 3,
|
||
decoration: BoxDecoration(
|
||
color: colorGrey, shape: BoxShape.circle),
|
||
),
|
||
const SizedBox(width: 9),
|
||
text(formatMinutes(task.estMinutes), 11, TextType.Regular,
|
||
color: colorGrey2),
|
||
],
|
||
),
|
||
trailing: pill(proofLabel(task.proofType), colorGrey2, colorMuted,
|
||
textSize: 9),
|
||
),
|
||
if (late) ...[
|
||
const SizedBox(height: 12),
|
||
Row(
|
||
children: [
|
||
pill(overdueLabel(task), colorStandingGrounded,
|
||
colorStandingGroundedBg, textSize: 9),
|
||
const SizedBox(width: 6),
|
||
pill("−${formatDebt(DebtEngine.commitmentDebt(task))}",
|
||
colorGrey2, colorMuted, textSize: 9),
|
||
],
|
||
),
|
||
],
|
||
const SizedBox(height: 14),
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: roundedCornerButton(
|
||
"Start",
|
||
() => _onStartTask(task),
|
||
icon: CupertinoIcons.play_fill,
|
||
verticalPadding: 13,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _doneRow(Commitment task) {
|
||
final bool late = task.status == CommitmentStatus.LateCompleted;
|
||
|
||
return Container(
|
||
margin: const EdgeInsets.only(bottom: 8),
|
||
child: card(
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
|
||
child: TickRow(
|
||
checked: true,
|
||
locked: true,
|
||
title: task.title,
|
||
accent: late ? colorStandingWarned : colorPositive,
|
||
trailing: late
|
||
? pill("Late", colorStandingWarned, colorStandingWarnedBg,
|
||
textSize: 9)
|
||
: null,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _routineRow(RoutineChain routine) {
|
||
final Color tone = kindColor(routine.kind);
|
||
|
||
return Container(
|
||
margin: const EdgeInsets.only(bottom: 10),
|
||
child: card(
|
||
padding: const EdgeInsets.fromLTRB(10, 10, 10, 10),
|
||
onTap: () => _onEditRoutine(routine),
|
||
child: Row(
|
||
children: [
|
||
SizedBox(
|
||
width: 58,
|
||
height: 58,
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.zero,
|
||
child: SceneArt(
|
||
palette: scenePalette(routine.kind),
|
||
seed: sceneSeed(routine.title),
|
||
figure: kindIcon(routine.kind),
|
||
figureScale: 0.46,
|
||
figureAlignment: const Alignment(0, 0.1),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
text(routine.title, 14, TextType.Medium,
|
||
color: colorPrimaryDark,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis),
|
||
const SizedBox(height: 4),
|
||
text(
|
||
[
|
||
kindLabel(routine.kind),
|
||
formatMinutes(routine.totalSeconds / 60),
|
||
if (routine.startMinute >= 0)
|
||
formatStartMinute(routine.startMinute),
|
||
formatDays(routine.days),
|
||
].join(" · "),
|
||
11,
|
||
TextType.Regular,
|
||
color: colorGrey2,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
GestureDetector(
|
||
onTap: () => _onStartRoutine(routine),
|
||
child: Container(
|
||
width: 40,
|
||
height: 40,
|
||
alignment: Alignment.center,
|
||
decoration: BoxDecoration(
|
||
color: routine.kind == RoutineKind.MustDo
|
||
? colorPrimaryDark
|
||
: tone,
|
||
),
|
||
child: Icon(CupertinoIcons.play_fill,
|
||
size: 15, color: colorWhite),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── ConnectGoalDetail ─────────────────────────────────────────────────────
|
||
|
||
@override
|
||
void onRoutinesLoaded(List<RoutineChain> routines) {
|
||
setState(() {
|
||
_routines = routines;
|
||
});
|
||
}
|
||
|
||
@override
|
||
void onGoalLoaded(Goal goal, List<Commitment> tasks) {
|
||
setState(() {
|
||
_ticked.removeWhere((id) => tasks.every((task) => task.id != id));
|
||
_goal = goal;
|
||
_tasks = tasks;
|
||
});
|
||
}
|
||
|
||
@override
|
||
void onTaskTicked(Commitment task, bool late) {
|
||
_changed = true;
|
||
_model?.loadTasks(_goal);
|
||
|
||
if (!late) {
|
||
return;
|
||
}
|
||
|
||
_model?.showApplicationNotification(
|
||
NotificationType.warning,
|
||
"Late complete",
|
||
"The window had already closed. This is recorded as a late complete — it reduces the debt but does not clear it.",
|
||
true,
|
||
true,
|
||
null,
|
||
);
|
||
}
|
||
|
||
@override
|
||
void onTickFailed(Commitment task) {
|
||
setState(() {
|
||
_ticked.remove(task.id);
|
||
});
|
||
}
|
||
|
||
@override
|
||
void onTaskReady(Commitment task) async {
|
||
// The runner takes over the whole screen — a task you are running is the
|
||
// thing you are doing, not a row in a list.
|
||
final result = await GroundedNavigation().navigateToPageWithData(
|
||
LiveTask(commitment: task, goalTitle: _goal.title),
|
||
context,
|
||
);
|
||
|
||
if (result == true) {
|
||
_changed = true;
|
||
_model?.loadTasks(_goal);
|
||
}
|
||
}
|
||
}
|