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 implements ConnectRoutineBuilder { ViewRoutineBuilder? _model; final GlobalKey _formKey = GlobalKey(); 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.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.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 = [1, 2, 3, 4, 5]; }); } void _onEveryDay() { setState(() { _routine.days = [1, 2, 3, 4, 5, 6, 7]; }); } void _onClearDays() { setState(() { _routine.days = []; }); } 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( 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: [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 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.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 animation) { return Material( color: Colors.transparent, elevation: 0, child: ScaleTransition( scale: Tween(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 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(); } }