Files
GroundedHelper/frontend/lib/Grounded/see/goal/GoalDetailState.dart
alvocool fe268cb8aa Redesign the UI after the fitness reference and replace the logo
Light sage-to-cream ambient backdrop, frosted glass controls and painted
SceneArt landscapes in place of photography. Home becomes a dashboard with a
two-tone greeting, a frosted summary ring, category portraits, an up-next rail
of image-led cards and a floating glass tab bar. The routine library gets an
image header and painted cards; the player gets a scene stage, steps on an
arc, a dotted clock and a night pill with a lime spark. Goals become painted
cards in their own colour.

New logo: a lime sun resting on a ground line under two arcs. The splash
raises the sun and lights the arcs; launcher icons and native splash are
regenerated to match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
2026-11-02 12:33:13 +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(16),
),
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(16),
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);
}
}
}