Files
GroundedHelper/frontend/lib/Grounded/see/routine/RoutineBuilderState.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

687 lines
21 KiB
Dart

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:stacked/stacked.dart';
import '../../about/external/data/Goal.dart';
import '../../about/external/data/RoutineChain.dart';
import '../../about/external/data/RoutineStep.dart';
import '../../about/internal/application/CommitmentClass.dart';
import '../../about/internal/application/RoutineKind.dart';
import '../../about/internal/application/TextType.dart';
import '../../designs/Checkbox.dart';
import '../../designs/Component.dart';
import '../../designs/GoalPicker.dart';
import '../../designs/Responsive.dart';
import '../../designs/Shell.dart';
import '../../designs/buttons/Buttons.dart';
import '../../designs/input/InputFields.dart';
import '../../designs/text/Text.dart';
import '../../utils/Colors.dart';
import '../../utils/CommonUtils.dart';
import '../../utils/RoutineTemplates.dart';
import '../../utils/Validators.dart';
import 'ConnectRoutineBuilder.dart';
import 'RoutineBuilder.dart';
import 'StepEditor.dart';
import 'ViewRoutineBuilder.dart';
class RoutineBuilderState extends State<RoutineBuilder>
implements ConnectRoutineBuilder {
ViewRoutineBuilder? _model;
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
final TextEditingController _title = TextEditingController();
final TextEditingController _note = TextEditingController();
late RoutineChain _routine;
String _stepsError = "";
/// The goal this routine serves, when its title is known.
Goal? _goal;
bool get _editing {
return widget.routine != null;
}
@override
void initState() {
super.initState();
// Edit a copy, so backing out leaves the library exactly as it was.
_routine = widget.routine?.copy() ??
RoutineChain(kind: widget.kind ?? RoutineKind.Workout);
_title.text = _routine.title;
_note.text = _routine.note;
final Goal? goal = widget.goal;
if (goal != null && (widget.routine == null || goal.id == _routine.goalId)) {
_goal = goal;
_routine.goalId = goal.id;
if (widget.routine == null) {
_routine.commitmentClass = goal.defaultClass;
}
}
}
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewRoutineBuilder>.reactive(
viewModelBuilder: () => ViewRoutineBuilder(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() {}
// ── Handlers ──────────────────────────────────────────────────────────────
void _onBack() {
Navigator.pop(context, false);
}
void _onKindSelected(RoutineKind kind) {
if (kind == _routine.kind) {
return;
}
setState(() {
_routine.kind = kind;
});
}
void _onTemplateSelected(RoutineChain template) {
setState(() {
_routine.title = template.title;
_routine.commitmentClass = template.commitmentClass;
_routine.days = List<int>.from(template.days);
_routine.startMinute = template.startMinute;
_routine.steps = template.copy().steps;
_title.text = template.title;
_stepsError = "";
});
}
void _onPickGoal() {
_model?.loadGoals();
}
void _onClassSelected(CommitmentClass value) {
setState(() {
_routine.commitmentClass = value;
});
}
void _onToggleDay(int day) {
setState(() {
if (_routine.days.contains(day)) {
_routine.days.remove(day);
} else {
_routine.days.add(day);
_routine.days.sort();
}
});
}
void _onWeekdays() {
setState(() {
_routine.days = <int>[1, 2, 3, 4, 5];
});
}
void _onEveryDay() {
setState(() {
_routine.days = <int>[1, 2, 3, 4, 5, 6, 7];
});
}
void _onClearDays() {
setState(() {
_routine.days = <int>[];
});
}
void _onPickStart() async {
final int current = _routine.startMinute < 0 ? 7 * 60 : _routine.startMinute;
final TimeOfDay? time = await showTimePicker(
context: context,
initialTime: TimeOfDay(hour: current ~/ 60, minute: current % 60),
);
if (time == null) {
return;
}
setState(() {
_routine.startMinute = time.hour * 60 + time.minute;
});
}
void _onAddStep() {
_openStepEditor(null);
}
void _openStepEditor(int? index) async {
final dynamic result = await showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
barrierColor: colorBlack.withValues(alpha: 0.55),
builder: (BuildContext sheetContext) {
return StepEditor(
step: index == null ? null : _routine.steps[index],
kind: _routine.kind,
);
},
);
if (result == null || !mounted) {
return;
}
setState(() {
if (result == false && index != null) {
_routine.steps.removeAt(index);
} else if (result is RoutineStep) {
if (index == null) {
_routine.steps.add(result);
} else {
_routine.steps[index] = result;
}
_stepsError = "";
}
});
}
void _onDuplicateStep(int index) {
setState(() {
_routine.steps.insert(
index + 1, RoutineStep.fromJson(_routine.steps[index].toJson())..id = null);
});
}
void _onReorder(int oldIndex, int newIndex) {
setState(() {
final RoutineStep moved = _routine.steps.removeAt(oldIndex);
_routine.steps.insert(newIndex, moved);
});
}
void _onSave() {
final bool fieldsValid = _formKey.currentState?.validate() == true;
if (_routine.steps.isEmpty) {
setState(() {
_stepsError = "A routine needs at least one step.";
});
}
if (!fieldsValid || _routine.steps.isEmpty) {
return;
}
_routine.title = _title.text.trim();
_routine.note = _note.text.trim();
_model?.save(_routine);
}
// ── Views ─────────────────────────────────────────────────────────────────
Widget _mobileView(BoxConstraints constraints) {
return Sheet(
eyebrow: kindLabel(_routine.kind),
title: _editing ? "Edit routine" : "New routine",
onBack: _onBack,
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
displayTitle(_editing ? "Adjust the chain." : "What are you\nbuilding?"),
const SizedBox(height: 24),
_kindPicker(),
if (!_editing) ...[
const SizedBox(height: 24),
text("START FROM", 10, TextType.Bold,
color: colorGrey2, letterSpacing: 0.8),
const SizedBox(height: 10),
_templateRail(),
],
const SizedBox(height: 28),
sectionBreak("Details"),
inputField(
"Title",
_title,
hint: "Morning strength, box breathing…",
validator: Validators.title,
),
const SizedBox(height: 16),
selectField(
"Goal",
_goalLabel(),
_onPickGoal,
hint: "No goal — stands on its own",
icon: CupertinoIcons.flag,
),
const SizedBox(height: 16),
inputField(
"Note",
_note,
hint: "Why this routine exists (optional)",
maxLines: 2,
),
const SizedBox(height: 28),
sectionBreak("Weight", caption: "decides the debt when it breaks"),
segmentedSelector<CommitmentClass>(
options: CommitmentClass.values,
selected: _routine.commitmentClass,
label: classLabel,
onSelected: _onClassSelected,
activeColor: classColor(_routine.commitmentClass),
),
const SizedBox(height: 10),
text(_classDescription(_routine.commitmentClass), 12,
TextType.Regular,
color: colorGrey2, height: 1.5),
const SizedBox(height: 28),
sectionBreak("Schedule", caption: formatDays(_routine.days)),
_dayPicker(),
const SizedBox(height: 8),
Row(
children: [
textButton("Weekdays", _onWeekdays, textSize: 12),
textButton("Every day", _onEveryDay, textSize: 12),
textButton("On demand", _onClearDays, textSize: 12),
],
),
const SizedBox(height: 8),
selectField(
"Starts at",
formatStartMinute(_routine.startMinute),
_onPickStart,
hint: "Pick a time",
icon: CupertinoIcons.clock,
),
const SizedBox(height: 28),
sectionBreak(
"Steps",
caption:
"${_routine.steps.length} step${_routine.steps.length == 1 ? "" : "s"}",
trailing: _routine.steps.isEmpty
? null
: text("~${formatMinutes(_routine.totalSeconds / 60)}", 12,
TextType.Bold,
color: colorGrey2),
),
if (_stepsError.isNotEmpty) ...[
text(_stepsError, 11, TextType.Regular, color: colorNegative),
const SizedBox(height: 10),
],
if (_routine.steps.isNotEmpty)
text("Hold and drag the handle to reorder.", 11,
TextType.Regular,
color: colorGrey),
if (_routine.steps.isNotEmpty) const SizedBox(height: 10),
_stepList(),
addTaskAffordance(_onAddStep, label: "Add step"),
if (_routine.steps.isNotEmpty) ...[
const SizedBox(height: 8),
_chainSummary(),
],
const SizedBox(height: 24),
roundedCornerButton(
_editing ? "Save changes" : "Save routine",
_onSave,
icon: CupertinoIcons.checkmark,
),
],
),
),
);
}
/// A routine linked from the library may not have its goal's title to
/// hand until the picker loads the goals.
String _goalLabel() {
if (_goal != null) {
return _goal!.title;
}
return _routine.goalId == null ? "" : "Linked to a goal";
}
String _classDescription(CommitmentClass value) {
switch (value) {
case CommitmentClass.NonNegotiable:
return "Breaking this chain carries the heaviest debt and cannot be deferred. Use it for the few routines your day is built on.";
case CommitmentClass.Standard:
return "A broken chain is logged as partial and costs normal debt.";
case CommitmentClass.Elective:
return "Tracked, never punished. Good for routines you are still trying out.";
}
}
Widget _kindPicker() {
return Row(
children: RoutineKind.values.map((kind) {
final bool last = kind == RoutineKind.values.last;
return Expanded(
child: Padding(
padding: EdgeInsets.only(right: last ? 0 : 8),
child: _kindOption(kind),
),
);
}).toList(),
);
}
Widget _kindOption(RoutineKind kind) {
final bool selected = _routine.kind == kind;
final Color tone = kindColor(kind);
return GestureDetector(
onTap: () => _onKindSelected(kind),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
height: 124,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
gradient: selected
? LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[tone, kindDeep(kind)],
)
: null,
color: selected ? null : colorWhite,
border: Border.all(color: selected ? tone : colorBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 34,
height: 34,
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected
? colorWhite.withValues(alpha: 0.16)
: kindBackground(kind),
shape: BoxShape.circle,
),
child: Icon(kindIcon(kind),
size: 17, color: selected ? colorWhite : tone),
),
const Spacer(),
text(kindLabel(kind), 13, TextType.Bold,
color: selected ? colorWhite : colorPrimaryDark),
const SizedBox(height: 3),
text(kindDescription(kind), 10, TextType.Regular,
color: selected
? colorWhite.withValues(alpha: 0.7)
: colorGrey2,
maxLines: 2,
overflow: TextOverflow.ellipsis,
height: 1.3),
],
),
),
);
}
Widget _templateRail() {
final List<RoutineChain> templates = RoutineTemplates.forKind(_routine.kind);
return SizedBox(
height: 76,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: templates.length,
separatorBuilder: (context, index) => const SizedBox(width: 10),
itemBuilder: (context, index) => _templateChip(templates[index]),
),
);
}
Widget _templateChip(RoutineChain template) {
final bool applied = _routine.title == template.title;
return GestureDetector(
onTap: () => _onTemplateSelected(template),
child: Container(
width: 176,
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
decoration: BoxDecoration(
color: applied ? kindBackground(_routine.kind) : colorWhite,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: applied
? kindColor(_routine.kind).withValues(alpha: 0.4)
: colorBorder,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
text(template.title, 13, TextType.Medium,
color: colorPrimaryDark,
maxLines: 1,
overflow: TextOverflow.ellipsis),
const SizedBox(height: 5),
text(
"${template.steps.length} steps · ${formatMinutes(template.totalSeconds / 60)}",
11,
TextType.Regular,
color: colorGrey2,
),
],
),
),
);
}
Widget _dayPicker() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List<Widget>.generate(7, (index) {
final int day = index + 1;
final bool on = _routine.days.contains(day);
final Color tone = kindColor(_routine.kind);
return GestureDetector(
onTap: () => _onToggleDay(day),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: 40,
height: 40,
alignment: Alignment.center,
decoration: BoxDecoration(
color: on ? tone : colorWhite,
shape: BoxShape.circle,
border: Border.all(color: on ? tone : colorBorder),
),
child: text(weekdayInitial(day), 13,
on ? TextType.Bold : TextType.Regular,
color: on ? colorWhite : colorGrey2),
),
);
}),
);
}
Widget _stepList() {
return ReorderableListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
itemCount: _routine.steps.length,
onReorderItem: _onReorder,
proxyDecorator: _dragDecorator,
itemBuilder: (context, index) => _stepCard(index),
);
}
Widget _dragDecorator(Widget child, int index, Animation<double> animation) {
return Material(
color: Colors.transparent,
elevation: 0,
child: ScaleTransition(
scale: Tween<double>(begin: 1, end: 1.03).animate(animation),
child: child,
),
);
}
Widget _stepCard(int index) {
final RoutineStep step = _routine.steps[index];
final Color tone = kindColor(_routine.kind);
return Container(
key: ObjectKey(step),
margin: const EdgeInsets.only(bottom: 8),
child: card(
padding: const EdgeInsets.fromLTRB(8, 10, 6, 10),
onTap: () => _openStepEditor(index),
child: Row(
children: [
ReorderableDragStartListener(
index: index,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
child: Icon(Icons.drag_indicator_rounded,
size: 18, color: colorGrey),
),
),
Container(
width: 30,
height: 30,
alignment: Alignment.center,
decoration: BoxDecoration(
color: kindBackground(_routine.kind),
borderRadius: BorderRadius.circular(10),
),
child: text("${index + 1}", 12, TextType.Bold, color: tone),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text(step.title, 14, TextType.Medium,
color: colorPrimaryDark,
maxLines: 1,
overflow: TextOverflow.ellipsis),
const SizedBox(height: 4),
Row(
children: [
Icon(stepModeIcon(step.mode), size: 11, color: colorGrey2),
const SizedBox(width: 5),
Expanded(
child: text(stepSummary(step), 11, TextType.Regular,
color: colorGrey2,
maxLines: 1,
overflow: TextOverflow.ellipsis),
),
],
),
],
),
),
iconButton(
Icon(CupertinoIcons.plus_square_on_square,
size: 16, color: colorGrey2),
() => _onDuplicateStep(index),
size: 34,
),
],
),
),
);
}
/// The dark totals card under the steps — the whole chain in three numbers.
Widget _chainSummary() {
final Color label = colorWhite.withValues(alpha: 0.45);
return card(
background: kindDeep(_routine.kind),
borderColor: kindDeep(_routine.kind),
child: Row(
children: [
Expanded(
child: labelled("Planned", formatMinutes(_routine.totalSeconds / 60),
valueSize: 20,
valueType: TextType.Light,
valueColor: colorWhite,
labelColor: label),
),
Expanded(
child: labelled("Steps", "${_routine.steps.length}",
valueSize: 20,
valueType: TextType.Light,
valueColor: colorWhite,
labelColor: label),
),
Expanded(
child: labelled(
_routine.kind == RoutineKind.MustDo ? "Tasks" : "Sets",
"${_routine.kind == RoutineKind.MustDo ? _routine.steps.length : _routine.totalSets}",
valueSize: 20,
valueType: TextType.Light,
valueColor: colorWhite,
labelColor: label),
),
],
),
);
}
// ── ConnectRoutineBuilder ─────────────────────────────────────────────────
@override
void onSaved() {
Navigator.pop(context, true);
}
@override
void onGoalsReady(List<Goal> goals) async {
final Goal? picked =
await showGoalPicker(context, goals, selectedId: _routine.goalId);
if (picked == null || !mounted) {
return;
}
setState(() {
_goal = picked.id == null ? null : picked;
_routine.goalId = picked.id;
});
}
@override
void dispose() {
_title.dispose();
_note.dispose();
super.dispose();
}
}