Files
GroundedHelper/frontend/lib/Grounded/see/goal/GoalDetailState.dart
alvocool d3431ca5de Switch to Inter, round the corners softly and make the nav switch tabs
Inter replaces General Sans (Inter Display for titles and big numbers).
Corners come back from before the squaring commit, scaled to a soft set
(16 cards, 14 buttons, 12 rows, 8 chips) and applied to the screens built
since. The nav is now a cluster of round frosted buttons that switches
Today, Routines, Goals, Groups and Progress in place inside Home: no
pushed pages, no back arrows, and Android back returns to Today.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
2026-11-09 07:08:46 +03:00

528 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package: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.circular(12),
),
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.circular(12),
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,
shape: BoxShape.circle,
),
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);
}
}
}