Add routines module and full goal management
Routines: workout, meditation and must-do routines with timed, rep and tick steps, sets and rest. A library screen, a builder with templates and drag-to-reorder steps, and a full-screen player that auto-advances timed phases, counts backgrounding and logs broken chains as partial. Goals: a goal editor (deadline, colour, category, default class, archive), routines linked to goals, and a goal field on tasks and routines. Tasks added from inside a goal are now actually attached to it via parentId. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
import '../../about/external/data/Goal.dart';
|
||||
|
||||
abstract class ConnectRoutineBuilder {
|
||||
void onSaved();
|
||||
|
||||
/// Goals are loaded; open the picker.
|
||||
void onGoalsReady(List<Goal> goals);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
abstract class ConnectRoutineRunner {
|
||||
/// The run was recorded. [held] is false when the chain broke and the run
|
||||
/// went in as partial.
|
||||
void onLogged(bool held);
|
||||
}
|
||||
5
frontend/lib/Grounded/see/routine/ConnectRoutines.dart
Normal file
5
frontend/lib/Grounded/see/routine/ConnectRoutines.dart
Normal file
@@ -0,0 +1,5 @@
|
||||
import '../../about/external/data/RoutineChain.dart';
|
||||
|
||||
abstract class ConnectRoutines {
|
||||
void onRoutinesLoaded(List<RoutineChain> routines);
|
||||
}
|
||||
23
frontend/lib/Grounded/see/routine/RoutineBuilder.dart
Normal file
23
frontend/lib/Grounded/see/routine/RoutineBuilder.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../about/external/data/Goal.dart';
|
||||
import '../../about/external/data/RoutineChain.dart';
|
||||
import '../../about/internal/application/RoutineKind.dart';
|
||||
import 'RoutineBuilderState.dart';
|
||||
|
||||
class RoutineBuilder extends StatefulWidget {
|
||||
/// The routine being edited; null builds a new one.
|
||||
final RoutineChain? routine;
|
||||
|
||||
/// The kind a new routine starts as — the library's active filter.
|
||||
final RoutineKind? kind;
|
||||
|
||||
/// The goal a new routine is created inside, or the goal an edited one
|
||||
/// already belongs to (for its title). Changeable in the form.
|
||||
final Goal? goal;
|
||||
|
||||
const RoutineBuilder({super.key, this.routine, this.kind, this.goal});
|
||||
|
||||
@override
|
||||
State<RoutineBuilder> createState() => RoutineBuilderState();
|
||||
}
|
||||
686
frontend/lib/Grounded/see/routine/RoutineBuilderState.dart
Normal file
686
frontend/lib/Grounded/see/routine/RoutineBuilderState.dart
Normal file
@@ -0,0 +1,686 @@
|
||||
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(16),
|
||||
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(14),
|
||||
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(9),
|
||||
),
|
||||
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();
|
||||
}
|
||||
}
|
||||
14
frontend/lib/Grounded/see/routine/RoutineRunner.dart
Normal file
14
frontend/lib/Grounded/see/routine/RoutineRunner.dart
Normal file
@@ -0,0 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../about/external/data/RoutineChain.dart';
|
||||
import 'RoutineRunnerState.dart';
|
||||
|
||||
class RoutineRunner extends StatefulWidget {
|
||||
/// A copy of the routine — the runner marks steps complete as it goes.
|
||||
final RoutineChain routine;
|
||||
|
||||
const RoutineRunner({super.key, required this.routine});
|
||||
|
||||
@override
|
||||
State<RoutineRunner> createState() => RoutineRunnerState();
|
||||
}
|
||||
1100
frontend/lib/Grounded/see/routine/RoutineRunnerState.dart
Normal file
1100
frontend/lib/Grounded/see/routine/RoutineRunnerState.dart
Normal file
File diff suppressed because it is too large
Load Diff
10
frontend/lib/Grounded/see/routine/Routines.dart
Normal file
10
frontend/lib/Grounded/see/routine/Routines.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'RoutinesState.dart';
|
||||
|
||||
class Routines extends StatefulWidget {
|
||||
const Routines({super.key});
|
||||
|
||||
@override
|
||||
State<Routines> createState() => RoutinesState();
|
||||
}
|
||||
787
frontend/lib/Grounded/see/routine/RoutinesState.dart
Normal file
787
frontend/lib/Grounded/see/routine/RoutinesState.dart
Normal file
@@ -0,0 +1,787 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stacked/stacked.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 '../../configs/Navigator.dart';
|
||||
import '../../designs/Component.dart';
|
||||
import '../../designs/ProgressRing.dart';
|
||||
import '../../designs/Responsive.dart';
|
||||
import '../../designs/Shell.dart';
|
||||
import '../../designs/buttons/Buttons.dart';
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import '../../utils/CommonUtils.dart';
|
||||
import 'ConnectRoutines.dart';
|
||||
import 'RoutineBuilder.dart';
|
||||
import 'RoutineRunner.dart';
|
||||
import 'Routines.dart';
|
||||
import 'ViewRoutines.dart';
|
||||
|
||||
/// The routine library: what is due today up front, everything else below,
|
||||
/// filtered by kind.
|
||||
class RoutinesState extends State<Routines> implements ConnectRoutines {
|
||||
ViewRoutines? _model;
|
||||
|
||||
List<RoutineChain> _routines = <RoutineChain>[];
|
||||
|
||||
/// Null shows every kind.
|
||||
RoutineKind? _kind;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewRoutines>.reactive(
|
||||
viewModelBuilder: () => ViewRoutines(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() {
|
||||
_model?.loadRoutines();
|
||||
}
|
||||
|
||||
// ── Derived ───────────────────────────────────────────────────────────────
|
||||
|
||||
List<RoutineChain> get _filtered {
|
||||
if (_kind == null) {
|
||||
return _routines;
|
||||
}
|
||||
return _routines.where((item) => item.kind == _kind).toList();
|
||||
}
|
||||
|
||||
List<RoutineChain> get _today {
|
||||
final DateTime now = DateTime.now();
|
||||
return _filtered.where((item) => item.scheduledOn(now)).toList();
|
||||
}
|
||||
|
||||
List<RoutineChain> get _rest {
|
||||
final DateTime now = DateTime.now();
|
||||
return _filtered.where((item) => !item.scheduledOn(now)).toList();
|
||||
}
|
||||
|
||||
/// Today's routines that are not finished, soonest first — the featured
|
||||
/// rail. Falls back to the whole library when nothing is due.
|
||||
List<RoutineChain> get _upNext {
|
||||
final List<RoutineChain> open =
|
||||
_today.where((item) => !item.complete).toList();
|
||||
final List<RoutineChain> source = open.isEmpty ? _filtered : open;
|
||||
return source.take(5).toList();
|
||||
}
|
||||
|
||||
int _countFor(RoutineKind? kind) {
|
||||
if (kind == null) {
|
||||
return _routines.length;
|
||||
}
|
||||
return _routines.where((item) => item.kind == kind).length;
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
void _onBack() {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
void _onSelectKind(RoutineKind? kind) {
|
||||
setState(() {
|
||||
_kind = _kind == kind ? null : kind;
|
||||
});
|
||||
}
|
||||
|
||||
void _onBuild() async {
|
||||
final result = await GroundedNavigation()
|
||||
.navigateToPageWithData(RoutineBuilder(kind: _kind), context);
|
||||
|
||||
if (result == true) {
|
||||
_model?.loadRoutines();
|
||||
}
|
||||
}
|
||||
|
||||
void _onEdit(RoutineChain routine) async {
|
||||
final result = await GroundedNavigation()
|
||||
.navigateToPageWithData(RoutineBuilder(routine: routine), context);
|
||||
|
||||
if (result == true) {
|
||||
_model?.loadRoutines();
|
||||
}
|
||||
}
|
||||
|
||||
void _onStart(RoutineChain routine) async {
|
||||
if (routine.steps.isEmpty) {
|
||||
_onEdit(routine);
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await GroundedNavigation()
|
||||
.navigateToPageWithData(RoutineRunner(routine: routine.copy()), context);
|
||||
|
||||
if (result == true) {
|
||||
_model?.loadRoutines();
|
||||
}
|
||||
}
|
||||
|
||||
void _onPreview(RoutineChain routine) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
barrierColor: colorBlack.withValues(alpha: 0.55),
|
||||
builder: (BuildContext sheetContext) {
|
||||
return _previewSheet(sheetContext, routine);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── Views ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Widget _mobileView(BoxConstraints constraints) {
|
||||
return Sheet(
|
||||
eyebrow: "${_routines.length} routine${_routines.length == 1 ? "" : "s"}",
|
||||
title: "Routines",
|
||||
onBack: _onBack,
|
||||
action: chromeAction(CupertinoIcons.add, _onBuild),
|
||||
banner: _routines.isEmpty ? null : _todayBanner(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text(
|
||||
"${weekdayName(DateTime.now().weekday).toUpperCase()} · ${_today.length} DUE",
|
||||
11,
|
||||
TextType.Bold,
|
||||
color: colorGrey2,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
displayTitle(_headline()),
|
||||
const SizedBox(height: 24),
|
||||
_kindRail(),
|
||||
const SizedBox(height: 28),
|
||||
if (_filtered.isEmpty)
|
||||
emptyState(
|
||||
_kind == null ? CupertinoIcons.square_stack_3d_up : kindIcon(_kind!),
|
||||
_kind == null
|
||||
? "No routines yet"
|
||||
: "No ${kindLabel(_kind!).toLowerCase()} routines",
|
||||
"A routine is a chain: every step, in order. Break it and it is logged as partial, not quietly forgotten.",
|
||||
)
|
||||
else ...[
|
||||
sectionBreak(
|
||||
"Up next",
|
||||
caption: _today.isEmpty ? "from your library" : "today",
|
||||
),
|
||||
_featuredRail(),
|
||||
const SizedBox(height: 28),
|
||||
if (_today.isNotEmpty) ...[
|
||||
sectionBreak(
|
||||
"Scheduled today",
|
||||
caption: "${_today.where((item) => item.complete).length} of ${_today.length} done",
|
||||
),
|
||||
..._today.map(_routineRow),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
if (_rest.isNotEmpty) ...[
|
||||
sectionBreak("Library", caption: "${_rest.length} more"),
|
||||
..._rest.map(_routineRow),
|
||||
],
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
roundedCornerButton(
|
||||
"Build a routine",
|
||||
_onBuild,
|
||||
icon: CupertinoIcons.add,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _headline() {
|
||||
if (_routines.isEmpty) {
|
||||
return "Build the things you do every time.";
|
||||
}
|
||||
final int open = _today.where((item) => !item.complete).length;
|
||||
if (_today.isEmpty) {
|
||||
return "Nothing scheduled. Run one anyway.";
|
||||
}
|
||||
if (open == 0) {
|
||||
return "Every chain held today.";
|
||||
}
|
||||
return "Here's what to hold today.";
|
||||
}
|
||||
|
||||
/// The strip in the black chrome: today's load at a glance.
|
||||
Widget _todayBanner() {
|
||||
final DateTime now = DateTime.now();
|
||||
final List<RoutineChain> due =
|
||||
_routines.where((item) => item.scheduledOn(now)).toList();
|
||||
|
||||
int seconds = 0;
|
||||
for (RoutineChain item in due) {
|
||||
seconds = seconds + item.totalSeconds;
|
||||
}
|
||||
|
||||
final int nowMinute = now.hour * 60 + now.minute;
|
||||
final List<RoutineChain> ahead = due
|
||||
.where((item) => !item.complete && item.startMinute >= nowMinute)
|
||||
.toList()
|
||||
..sort((a, b) => a.startMinute.compareTo(b.startMinute));
|
||||
|
||||
final Color label = colorWhite.withValues(alpha: 0.45);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
|
||||
decoration: BoxDecoration(
|
||||
color: colorWhite.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: labelled("Due today", "${due.length}",
|
||||
valueSize: 22,
|
||||
valueType: TextType.Light,
|
||||
valueColor: colorWhite,
|
||||
labelColor: label),
|
||||
),
|
||||
Expanded(
|
||||
child: labelled("Planned", formatMinutes(seconds / 60),
|
||||
valueSize: 22,
|
||||
valueType: TextType.Light,
|
||||
valueColor: colorWhite,
|
||||
labelColor: label),
|
||||
),
|
||||
Expanded(
|
||||
child: labelled(
|
||||
"Next",
|
||||
ahead.isEmpty ? "—" : formatStartMinute(ahead.first.startMinute),
|
||||
valueSize: 22,
|
||||
valueType: TextType.Light,
|
||||
valueColor: colorWhite,
|
||||
labelColor: label),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Round category tiles — tap to filter, tap again to clear.
|
||||
Widget _kindRail() {
|
||||
final List<RoutineKind?> options = <RoutineKind?>[null, ...RoutineKind.values];
|
||||
|
||||
return SizedBox(
|
||||
height: 96,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: options.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(width: 14),
|
||||
itemBuilder: (context, index) => _kindTile(options[index]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _kindTile(RoutineKind? kind) {
|
||||
final bool selected = _kind == kind;
|
||||
final Color tone = kind == null ? colorPrimaryDark : kindColor(kind);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _onSelectKind(kind),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: SizedBox(
|
||||
width: 70,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOut,
|
||||
width: 60,
|
||||
height: 60,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: selected
|
||||
? LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: <Color>[
|
||||
tone,
|
||||
kind == null ? colorPrimaryDark2 : kindDeep(kind),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
color: selected ? null : colorWhite,
|
||||
border: Border.all(
|
||||
color: selected ? tone : colorBorder,
|
||||
width: selected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
kind == null ? CupertinoIcons.square_grid_2x2_fill : kindIcon(kind),
|
||||
size: 22,
|
||||
color: selected ? colorWhite : tone,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
text(
|
||||
kind == null ? "All" : kindLabel(kind),
|
||||
11,
|
||||
selected ? TextType.Bold : TextType.Regular,
|
||||
color: selected ? colorPrimaryDark : colorGrey2,
|
||||
maxLines: 1,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
text("${_countFor(kind)}", 9, TextType.Bold,
|
||||
color: colorGrey, letterSpacing: 0.6),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _featuredRail() {
|
||||
final List<RoutineChain> items = _upNext;
|
||||
|
||||
return SizedBox(
|
||||
height: 214,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
clipBehavior: Clip.none,
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(width: 12),
|
||||
itemBuilder: (context, index) => _featuredCard(items[index]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The large gradient card. The kind's icon sits oversized in the corner as
|
||||
/// the card's picture — routines have no stock photography.
|
||||
Widget _featuredCard(RoutineChain routine) {
|
||||
final Color tone = kindColor(routine.kind);
|
||||
final Color label = colorWhite.withValues(alpha: 0.55);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _onPreview(routine),
|
||||
child: Container(
|
||||
width: 248,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: <Color>[tone, kindDeep(routine.kind)],
|
||||
),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x08000000),
|
||||
blurRadius: 18,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
right: -26,
|
||||
bottom: 34,
|
||||
child: Icon(
|
||||
kindIcon(routine.kind),
|
||||
size: 150,
|
||||
color: colorWhite.withValues(alpha: 0.08),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
pill(kindLabel(routine.kind), colorWhite,
|
||||
colorWhite.withValues(alpha: 0.14),
|
||||
textSize: 9),
|
||||
if (routine.commitmentClass ==
|
||||
CommitmentClass.NonNegotiable) ...[
|
||||
const SizedBox(width: 6),
|
||||
pill("Must hold", colorWhite,
|
||||
colorWhite.withValues(alpha: 0.14),
|
||||
textSize: 9),
|
||||
],
|
||||
const Spacer(),
|
||||
Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: colorWhite,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(CupertinoIcons.play_fill,
|
||||
size: 14, color: kindDeep(routine.kind)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
if (routine.startMinute >= 0) ...[
|
||||
text(
|
||||
"${formatStartMinute(routine.startMinute)} · ${formatDays(routine.days)}"
|
||||
.toUpperCase(),
|
||||
9,
|
||||
TextType.Bold,
|
||||
color: label,
|
||||
letterSpacing: 1.0,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
text(routine.title, 21, TextType.Light,
|
||||
color: colorWhite,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
height: 1.15),
|
||||
const SizedBox(height: 14),
|
||||
Container(
|
||||
height: 1,
|
||||
color: colorWhite.withValues(alpha: 0.12),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: labelled(
|
||||
"Minutes",
|
||||
"${(routine.totalSeconds / 60).round()}",
|
||||
valueSize: 17,
|
||||
valueColor: colorWhite,
|
||||
labelColor: label,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: labelled(
|
||||
"Steps",
|
||||
"${routine.steps.length}",
|
||||
valueSize: 17,
|
||||
valueColor: colorWhite,
|
||||
labelColor: label,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: labelled(
|
||||
routine.kind == RoutineKind.Workout ? "Sets" : "Starts",
|
||||
routine.kind == RoutineKind.Workout
|
||||
? "${routine.totalSets}"
|
||||
: routine.startMinute < 0
|
||||
? "Any"
|
||||
: formatStartMinute(routine.startMinute),
|
||||
valueSize: 17,
|
||||
valueColor: colorWhite,
|
||||
labelColor: label,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _routineRow(RoutineChain routine) {
|
||||
final Color tone = kindColor(routine.kind);
|
||||
final bool weighted = routine.commitmentClass != CommitmentClass.Standard;
|
||||
|
||||
final List<String> eyebrow = <String>[
|
||||
if (weighted) classLabel(routine.commitmentClass),
|
||||
if (routine.startMinute >= 0) formatStartMinute(routine.startMinute),
|
||||
formatDays(routine.days),
|
||||
];
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
child: card(
|
||||
padding: const EdgeInsets.fromLTRB(10, 10, 14, 10),
|
||||
onTap: () => _onPreview(routine),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 58,
|
||||
height: 58,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: <Color>[kindBackground(routine.kind), colorWhite],
|
||||
),
|
||||
border: Border.all(color: tone.withValues(alpha: 0.15)),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Icon(kindIcon(routine.kind), size: 24, color: tone),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text(
|
||||
eyebrow.join(" · ").toUpperCase(),
|
||||
9,
|
||||
TextType.Bold,
|
||||
color: weighted
|
||||
? classColor(routine.commitmentClass)
|
||||
: colorGrey2,
|
||||
letterSpacing: 0.9,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
text(routine.title, 15, TextType.Medium,
|
||||
color: colorPrimaryDark,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
_meta(CupertinoIcons.clock,
|
||||
formatMinutes(routine.totalSeconds / 60)),
|
||||
const SizedBox(width: 12),
|
||||
_countMeta(routine),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
routine.complete
|
||||
? Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: tone,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Icons.check_rounded,
|
||||
size: 22, color: colorWhite),
|
||||
)
|
||||
: ProgressRing(
|
||||
fraction: routine.partialCompletion,
|
||||
fill: tone,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _countMeta(RoutineChain routine) {
|
||||
switch (routine.kind) {
|
||||
case RoutineKind.Workout:
|
||||
return _meta(CupertinoIcons.repeat, "${routine.totalSets} sets");
|
||||
case RoutineKind.Meditation:
|
||||
return _meta(CupertinoIcons.circle_grid_hex,
|
||||
"${routine.steps.length} steps");
|
||||
case RoutineKind.MustDo:
|
||||
return _meta(CupertinoIcons.checkmark_square,
|
||||
"${routine.steps.length} tasks");
|
||||
}
|
||||
}
|
||||
|
||||
Widget _meta(IconData icon, String value) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 12, color: colorGrey2),
|
||||
const SizedBox(width: 4),
|
||||
text(value, 11, TextType.Regular, color: colorGrey2),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// The preview: the whole chain laid out before you commit to starting it.
|
||||
Widget _previewSheet(BuildContext sheetContext, RoutineChain routine) {
|
||||
final Color tone = kindColor(routine.kind);
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(sheetContext).size.height * 0.86,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: colorSheetBackground,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(28),
|
||||
topRight: Radius.circular(28),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 38,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(top: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorGrey.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(24, 20, 24, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
pill(kindLabel(routine.kind), tone,
|
||||
kindBackground(routine.kind),
|
||||
icon: kindIcon(routine.kind), textSize: 9),
|
||||
const SizedBox(width: 6),
|
||||
pill(classLabel(routine.commitmentClass),
|
||||
classColor(routine.commitmentClass),
|
||||
classBackground(routine.commitmentClass),
|
||||
textSize: 9),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
displayTitle(routine.title, size: 30),
|
||||
if (routine.note.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
text(routine.note, 13, TextType.Regular,
|
||||
color: colorGrey2, height: 1.5),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: statTile("Minutes",
|
||||
"${(routine.totalSeconds / 60).round()}")),
|
||||
Expanded(
|
||||
child: statTile("Steps", "${routine.steps.length}")),
|
||||
Expanded(
|
||||
child: statTile(
|
||||
"When",
|
||||
routine.startMinute < 0
|
||||
? "Any"
|
||||
: formatStartMinute(routine.startMinute),
|
||||
caption: formatDays(routine.days),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
sectionBreak("The chain",
|
||||
caption: routine.kind == RoutineKind.Workout
|
||||
? "${routine.totalSets} sets, in order"
|
||||
: "${routine.steps.length} steps, in order"),
|
||||
...routine.steps
|
||||
.asMap()
|
||||
.entries
|
||||
.map((entry) => _previewStep(entry.key, entry.value, tone)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: outlinedActionButton("Edit", () {
|
||||
Navigator.pop(sheetContext);
|
||||
_onEdit(routine);
|
||||
}, icon: CupertinoIcons.pencil),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: roundedCornerButton("Start", () {
|
||||
Navigator.pop(sheetContext);
|
||||
_onStart(routine);
|
||||
},
|
||||
icon: CupertinoIcons.play_fill,
|
||||
enabled: routine.steps.isNotEmpty),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _previewStep(int index, RoutineStep step, Color tone) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: tone.withValues(alpha: 0.10),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: text("${index + 1}", 11, TextType.Bold, color: tone),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
text(step.title, 14, TextType.Medium, color: colorPrimaryDark),
|
||||
const SizedBox(height: 3),
|
||||
text(stepSummary(step), 11, TextType.Regular,
|
||||
color: colorGrey2),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 5),
|
||||
child: Icon(stepModeIcon(step.mode), size: 15, color: colorGrey),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── ConnectRoutines ───────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onRoutinesLoaded(List<RoutineChain> routines) {
|
||||
setState(() {
|
||||
_routines = routines;
|
||||
});
|
||||
}
|
||||
}
|
||||
20
frontend/lib/Grounded/see/routine/StepEditor.dart
Normal file
20
frontend/lib/Grounded/see/routine/StepEditor.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../about/external/data/RoutineStep.dart';
|
||||
import '../../about/internal/application/RoutineKind.dart';
|
||||
import 'StepEditorState.dart';
|
||||
|
||||
/// The bottom sheet for one step. Pops with the edited [RoutineStep], with
|
||||
/// `false` when the step is removed, or with nothing when dismissed.
|
||||
class StepEditor extends StatefulWidget {
|
||||
/// The step being edited; null adds a new one.
|
||||
final RoutineStep? step;
|
||||
|
||||
/// Decides the defaults for a new step.
|
||||
final RoutineKind kind;
|
||||
|
||||
const StepEditor({super.key, this.step, required this.kind});
|
||||
|
||||
@override
|
||||
State<StepEditor> createState() => StepEditorState();
|
||||
}
|
||||
362
frontend/lib/Grounded/see/routine/StepEditorState.dart
Normal file
362
frontend/lib/Grounded/see/routine/StepEditorState.dart
Normal file
@@ -0,0 +1,362 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
import '../../about/external/data/RoutineStep.dart';
|
||||
import '../../about/internal/application/RoutineKind.dart';
|
||||
import '../../about/internal/application/StepMode.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../designs/buttons/Buttons.dart';
|
||||
import '../../designs/input/CountStepper.dart';
|
||||
import '../../designs/input/InputFields.dart';
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import '../../utils/CommonUtils.dart';
|
||||
import '../../utils/Validators.dart';
|
||||
import 'StepEditor.dart';
|
||||
|
||||
class StepEditorState extends State<StepEditor> {
|
||||
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
||||
|
||||
final TextEditingController _title = TextEditingController();
|
||||
|
||||
final TextEditingController _cue = TextEditingController();
|
||||
|
||||
late RoutineStep _step;
|
||||
|
||||
bool get _editing {
|
||||
return widget.step != null;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_step = widget.step == null
|
||||
? _defaultsFor(widget.kind)
|
||||
: RoutineStep.fromJson(widget.step!.toJson());
|
||||
|
||||
_title.text = _step.title;
|
||||
_cue.text = _step.cue;
|
||||
}
|
||||
|
||||
/// A new step starts as the kind's most common shape, so most edits are
|
||||
/// just a title.
|
||||
RoutineStep _defaultsFor(RoutineKind kind) {
|
||||
switch (kind) {
|
||||
case RoutineKind.Workout:
|
||||
return RoutineStep(
|
||||
mode: StepMode.Reps,
|
||||
sets: 3,
|
||||
reps: 10,
|
||||
timerSeconds: 40,
|
||||
restSeconds: 60);
|
||||
case RoutineKind.Meditation:
|
||||
return RoutineStep(mode: StepMode.Timed, timerSeconds: 300);
|
||||
case RoutineKind.MustDo:
|
||||
return RoutineStep(mode: StepMode.Check, timerSeconds: 120);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.9,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: colorSheetBackground,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(28),
|
||||
topRight: Radius.circular(28),
|
||||
),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 28),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
text(_editing ? "EDIT STEP" : "NEW STEP", 9, TextType.Bold,
|
||||
color: kindColor(widget.kind), letterSpacing: 1.2),
|
||||
const SizedBox(height: 10),
|
||||
text(_title.text.isEmpty ? "What happens here?" : _title.text,
|
||||
24, TextType.Light,
|
||||
color: colorPrimaryDark, height: 1.2),
|
||||
const SizedBox(height: 20),
|
||||
inputField(
|
||||
"Title",
|
||||
_title,
|
||||
hint: _titleHint(),
|
||||
validator: Validators.title,
|
||||
onChanged: _onTitleChanged,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
text("MODE", 10, TextType.Bold,
|
||||
color: colorGrey2, letterSpacing: 0.8),
|
||||
const SizedBox(height: 8),
|
||||
segmentedSelector<StepMode>(
|
||||
options: StepMode.values,
|
||||
selected: _step.mode,
|
||||
label: stepModeLabel,
|
||||
onSelected: _onModeSelected,
|
||||
activeColor: kindColor(widget.kind),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
text(_modeDescription(_step.mode), 12, TextType.Regular,
|
||||
color: colorGrey2, height: 1.5),
|
||||
const SizedBox(height: 20),
|
||||
..._numbers(),
|
||||
const SizedBox(height: 20),
|
||||
inputField(
|
||||
"Cue",
|
||||
_cue,
|
||||
hint: widget.kind == RoutineKind.Meditation
|
||||
? "In 4 · hold 4 · out 4"
|
||||
: "One line to read mid-step (optional)",
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_summaryLine(),
|
||||
const SizedBox(height: 20),
|
||||
roundedCornerButton(
|
||||
_editing ? "Update step" : "Add step",
|
||||
_onSave,
|
||||
background: colorPrimaryDark,
|
||||
icon: _editing ? CupertinoIcons.checkmark : CupertinoIcons.add,
|
||||
),
|
||||
if (_editing) ...[
|
||||
const SizedBox(height: 6),
|
||||
Center(
|
||||
child: textButton("Remove step", _onRemove,
|
||||
color: colorDestructive, textSize: 13),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _titleHint() {
|
||||
switch (widget.kind) {
|
||||
case RoutineKind.Workout:
|
||||
return "Deadlifts, plank, warm up…";
|
||||
case RoutineKind.Meditation:
|
||||
return "Settle, breathe, body scan…";
|
||||
case RoutineKind.MustDo:
|
||||
return "Make the bed, plan tomorrow…";
|
||||
}
|
||||
}
|
||||
|
||||
String _modeDescription(StepMode mode) {
|
||||
switch (mode) {
|
||||
case StepMode.Timed:
|
||||
return "The clock runs down and the next beat starts on its own.";
|
||||
case StepMode.Reps:
|
||||
return "You end each set when the reps are done. Rest is timed.";
|
||||
case StepMode.Check:
|
||||
return "A task. You tick it; the clock records how long it took.";
|
||||
}
|
||||
}
|
||||
|
||||
/// The steppers that apply to the chosen mode.
|
||||
List<Widget> _numbers() {
|
||||
final List<Widget> rows = <Widget>[];
|
||||
|
||||
switch (_step.mode) {
|
||||
case StepMode.Timed:
|
||||
rows.add(_pair(
|
||||
countStepper("Duration", formatSeconds(_step.timerSeconds),
|
||||
onMinus: _onDurationMinus,
|
||||
onPlus: _onDurationPlus,
|
||||
canMinus: _step.timerSeconds > 5),
|
||||
countStepper("Sets", "${_step.sets}",
|
||||
onMinus: _onSetsMinus,
|
||||
onPlus: _onSetsPlus,
|
||||
canMinus: _step.sets > 1),
|
||||
));
|
||||
break;
|
||||
case StepMode.Reps:
|
||||
rows.add(_pair(
|
||||
countStepper("Reps", "${_step.reps}",
|
||||
onMinus: _onRepsMinus,
|
||||
onPlus: _onRepsPlus,
|
||||
canMinus: _step.reps > 1),
|
||||
countStepper("Sets", "${_step.sets}",
|
||||
onMinus: _onSetsMinus,
|
||||
onPlus: _onSetsPlus,
|
||||
canMinus: _step.sets > 1),
|
||||
));
|
||||
rows.add(const SizedBox(height: 10));
|
||||
rows.add(countStepper("Est. per set", formatSeconds(_step.timerSeconds),
|
||||
onMinus: _onDurationMinus,
|
||||
onPlus: _onDurationPlus,
|
||||
canMinus: _step.timerSeconds > 5));
|
||||
break;
|
||||
case StepMode.Check:
|
||||
rows.add(countStepper("Estimate", formatSeconds(_step.timerSeconds),
|
||||
onMinus: _onDurationMinus,
|
||||
onPlus: _onDurationPlus,
|
||||
canMinus: _step.timerSeconds > 5));
|
||||
break;
|
||||
}
|
||||
|
||||
if (_step.mode != StepMode.Check) {
|
||||
rows.add(const SizedBox(height: 10));
|
||||
rows.add(countStepper(
|
||||
"Rest after each set",
|
||||
_step.restSeconds == 0 ? "None" : formatSeconds(_step.restSeconds),
|
||||
onMinus: _onRestMinus,
|
||||
onPlus: _onRestPlus,
|
||||
canMinus: _step.restSeconds > 0,
|
||||
));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
Widget _pair(Widget left, Widget right) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: left),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: right),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _summaryLine() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: kindBackground(widget.kind),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(stepModeIcon(_step.mode),
|
||||
size: 15, color: kindColor(widget.kind)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: text(stepSummary(_step), 12, TextType.Medium,
|
||||
color: colorPrimaryDark),
|
||||
),
|
||||
text("~${formatSeconds(_step.plannedSeconds)}", 12, TextType.Bold,
|
||||
color: kindColor(widget.kind)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
void _onTitleChanged(String value) {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _onModeSelected(StepMode mode) {
|
||||
setState(() {
|
||||
_step.mode = mode;
|
||||
if (mode == StepMode.Reps && _step.reps == 0) {
|
||||
_step.reps = 10;
|
||||
}
|
||||
if (mode == StepMode.Check) {
|
||||
_step.sets = 1;
|
||||
_step.restSeconds = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Steps get coarser as the duration grows: 5s near the bottom, a minute
|
||||
/// once you are into meditation lengths.
|
||||
int _durationIncrement(int seconds) {
|
||||
if (seconds < 60) {
|
||||
return 5;
|
||||
}
|
||||
if (seconds < 180) {
|
||||
return 15;
|
||||
}
|
||||
if (seconds < 600) {
|
||||
return 30;
|
||||
}
|
||||
return 60;
|
||||
}
|
||||
|
||||
void _onDurationMinus() {
|
||||
setState(() {
|
||||
final int next =
|
||||
_step.timerSeconds - _durationIncrement(_step.timerSeconds - 1);
|
||||
_step.timerSeconds = next < 5 ? 5 : next;
|
||||
});
|
||||
}
|
||||
|
||||
void _onDurationPlus() {
|
||||
setState(() {
|
||||
_step.timerSeconds =
|
||||
_step.timerSeconds + _durationIncrement(_step.timerSeconds);
|
||||
});
|
||||
}
|
||||
|
||||
void _onSetsMinus() {
|
||||
setState(() {
|
||||
_step.sets = _step.sets > 1 ? _step.sets - 1 : 1;
|
||||
});
|
||||
}
|
||||
|
||||
void _onSetsPlus() {
|
||||
setState(() {
|
||||
_step.sets = _step.sets + 1;
|
||||
});
|
||||
}
|
||||
|
||||
void _onRepsMinus() {
|
||||
setState(() {
|
||||
_step.reps = _step.reps > 1 ? _step.reps - 1 : 1;
|
||||
});
|
||||
}
|
||||
|
||||
void _onRepsPlus() {
|
||||
setState(() {
|
||||
_step.reps = _step.reps + 1;
|
||||
});
|
||||
}
|
||||
|
||||
void _onRestMinus() {
|
||||
setState(() {
|
||||
final int next = _step.restSeconds - 15;
|
||||
_step.restSeconds = next < 0 ? 0 : next;
|
||||
});
|
||||
}
|
||||
|
||||
void _onRestPlus() {
|
||||
setState(() {
|
||||
_step.restSeconds = _step.restSeconds + 15;
|
||||
});
|
||||
}
|
||||
|
||||
void _onSave() {
|
||||
if (_formKey.currentState?.validate() != true) {
|
||||
return;
|
||||
}
|
||||
|
||||
_step.title = _title.text.trim();
|
||||
_step.cue = _cue.text.trim();
|
||||
|
||||
Navigator.pop(context, _step);
|
||||
}
|
||||
|
||||
void _onRemove() {
|
||||
Navigator.pop(context, false);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_title.dispose();
|
||||
_cue.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
57
frontend/lib/Grounded/see/routine/ViewRoutineBuilder.dart
Normal file
57
frontend/lib/Grounded/see/routine/ViewRoutineBuilder.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
import '../../about/external/data/RoutineChain.dart';
|
||||
import '../../about/external/data/pages/request/HistoryRequest.dart';
|
||||
import '../../about/external/data/pages/request/PageAndSort.dart';
|
||||
import '../../about/external/data/pages/request/Pageable.dart';
|
||||
import '../../about/external/data/pages/request/Sort.dart';
|
||||
import '../../utils/ObjectConvertors.dart';
|
||||
import '../parent/ParentViewModel.dart';
|
||||
import 'ConnectRoutineBuilder.dart';
|
||||
|
||||
class ViewRoutineBuilder extends ParentViewModel {
|
||||
ConnectRoutineBuilder connection;
|
||||
|
||||
ViewRoutineBuilder(super.context, this.connection);
|
||||
|
||||
/// Goals for the picker — loaded on tap, never up front.
|
||||
void loadGoals() async {
|
||||
if (!await hasNetwork(() => loadGoals())) return;
|
||||
|
||||
showLoading("Loading goals");
|
||||
|
||||
try {
|
||||
final response = await getDataManager().getMyGoals(HistoryRequest(
|
||||
query: PageAndSort(
|
||||
sort: Sort('desc', 'startDate'),
|
||||
page: Pageable(0, 0, 50, 0),
|
||||
),
|
||||
));
|
||||
|
||||
closeLoading();
|
||||
|
||||
connection.onGoalsReady(getGoalList(response.data));
|
||||
} catch (e) {
|
||||
handleError(e, () => loadGoals(), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
void save(RoutineChain routine) async {
|
||||
if (!await hasNetwork(() => save(routine))) return;
|
||||
|
||||
showLoading("Saving routine");
|
||||
|
||||
try {
|
||||
for (int index = 0; index < routine.steps.length; index++) {
|
||||
routine.steps[index].order = index;
|
||||
routine.steps[index].completed = false;
|
||||
}
|
||||
|
||||
await getDataManager().saveRoutineEntry(routine.toJson());
|
||||
|
||||
closeLoading();
|
||||
|
||||
connection.onSaved();
|
||||
} catch (e) {
|
||||
handleError(e, () => save(routine), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
60
frontend/lib/Grounded/see/routine/ViewRoutineRunner.dart
Normal file
60
frontend/lib/Grounded/see/routine/ViewRoutineRunner.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
import '../../about/external/data/LiveSession.dart';
|
||||
import '../../about/external/initial/RoutineLogRequest.dart';
|
||||
import '../../about/internal/application/RoutineRun.dart';
|
||||
import '../../configs/NotificationServiceConfig.dart';
|
||||
import '../parent/ParentViewModel.dart';
|
||||
import 'ConnectRoutineRunner.dart';
|
||||
|
||||
class ViewRoutineRunner extends ParentViewModel {
|
||||
ConnectRoutineRunner connection;
|
||||
|
||||
ViewRoutineRunner(super.context, this.connection);
|
||||
|
||||
/// Mirrors the current phase into the ongoing notification, through the
|
||||
/// same [LiveSession] shape the single-task runner uses.
|
||||
void publishRun(RoutineRun run) {
|
||||
LocalNotificationEngine.showSessionNotification(LiveSession(
|
||||
commitmentId: run.chain.id ?? "",
|
||||
title: run.phase.rest ? "Rest" : run.phase.step.title,
|
||||
goalTitle: run.chain.title,
|
||||
startedAt: run.startedAt,
|
||||
accumulatedSeconds: run.phaseElapsed(),
|
||||
resumedAt: run.running ? DateTime.now() : null,
|
||||
requiredSeconds: run.phase.seconds,
|
||||
backgroundedCount: run.backgroundedCount,
|
||||
));
|
||||
}
|
||||
|
||||
void clearRun() {
|
||||
LocalNotificationEngine.cancelSessionNotification();
|
||||
}
|
||||
|
||||
/// Held or broken, the run is always logged — a broken chain goes in as
|
||||
/// partial at the step it broke on.
|
||||
void log(RoutineRun run) async {
|
||||
if (!await hasNetwork(() => log(run))) return;
|
||||
|
||||
showLoading("Recording");
|
||||
|
||||
try {
|
||||
await getDataManager().logRoutineChainEntry(RoutineLogRequest(
|
||||
routineId: run.chain.id ?? "",
|
||||
startedAt: run.startedAt.toIso8601String(),
|
||||
elapsedSeconds: run.elapsedSeconds(),
|
||||
completedSteps: run.chain.completedSteps,
|
||||
totalSteps: run.chain.steps.length,
|
||||
breakIndex: run.breakIndex,
|
||||
backgroundedCount: run.backgroundedCount,
|
||||
skippedCount: run.skippedCount,
|
||||
).toJson());
|
||||
|
||||
clearRun();
|
||||
|
||||
closeLoading();
|
||||
|
||||
connection.onLogged(run.chain.complete);
|
||||
} catch (e) {
|
||||
handleError(e, () => log(run), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
37
frontend/lib/Grounded/see/routine/ViewRoutines.dart
Normal file
37
frontend/lib/Grounded/see/routine/ViewRoutines.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
import '../../about/external/data/RoutineChain.dart';
|
||||
import '../../about/external/data/pages/request/HistoryRequest.dart';
|
||||
import '../../about/external/data/pages/request/PageAndSort.dart';
|
||||
import '../../about/external/data/pages/request/Pageable.dart';
|
||||
import '../../about/external/data/pages/request/Sort.dart';
|
||||
import '../../utils/ObjectConvertors.dart';
|
||||
import '../parent/ParentViewModel.dart';
|
||||
import 'ConnectRoutines.dart';
|
||||
|
||||
class ViewRoutines extends ParentViewModel {
|
||||
ConnectRoutines connection;
|
||||
|
||||
ViewRoutines(super.context, this.connection);
|
||||
|
||||
void loadRoutines() async {
|
||||
if (!await hasNetwork(() => loadRoutines())) return;
|
||||
|
||||
showLoading("Loading your routines");
|
||||
|
||||
try {
|
||||
final response = await getDataManager().getMyRoutines(HistoryRequest(
|
||||
query: PageAndSort(
|
||||
sort: Sort('asc', 'startMinute'),
|
||||
page: Pageable(0, 0, 50, 0),
|
||||
),
|
||||
));
|
||||
|
||||
final List<RoutineChain> routines = getRoutineChainList(response.data);
|
||||
|
||||
closeLoading();
|
||||
|
||||
connection.onRoutinesLoaded(routines);
|
||||
} catch (e) {
|
||||
handleError(e, () => loadRoutines(), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user