Files
GroundedHelper/frontend/lib/Grounded/see/routine/RoutineBuilderState.dart
alvocool 9c858b8146 Square every corner, enlarge the main surfaces and add a to-do list
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
2026-11-02 14:50:31 +03:00

685 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.zero,
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),
),
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.zero,
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,
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.zero,
),
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();
}
}