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:
127
frontend/lib/Grounded/designs/GoalPicker.dart
Normal file
127
frontend/lib/Grounded/designs/GoalPicker.dart
Normal file
@@ -0,0 +1,127 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../about/external/data/Goal.dart';
|
||||
import '../about/internal/application/TextType.dart';
|
||||
import '../utils/Colors.dart';
|
||||
import '../utils/CommonUtils.dart';
|
||||
import 'text/Text.dart';
|
||||
|
||||
/// The goal chooser, shared by the task form and the routine builder. Opened
|
||||
/// only once the goals have been loaded — `selectField` loads on tap.
|
||||
///
|
||||
/// Resolves to the chosen goal, to a bare `Goal()` (no id) for "no goal", or
|
||||
/// to null when dismissed without a choice.
|
||||
Future<Goal?> showGoalPicker(
|
||||
BuildContext context,
|
||||
List<Goal> goals, {
|
||||
String? selectedId,
|
||||
}) {
|
||||
return showModalBottomSheet<Goal>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
barrierColor: colorBlack.withValues(alpha: 0.55),
|
||||
builder: (BuildContext sheetContext) {
|
||||
final List<Goal> active = goals.where((goal) => !goal.archived).toList();
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(sheetContext).size.height * 0.75,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: colorSheetBackground,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(28),
|
||||
topRight: Radius.circular(28),
|
||||
),
|
||||
),
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 32),
|
||||
children: [
|
||||
text("BELONGS TO", 9, TextType.Bold,
|
||||
color: colorGrey2, letterSpacing: 1.2),
|
||||
const SizedBox(height: 8),
|
||||
text("Which goal is this for?", 24, TextType.Light,
|
||||
color: colorPrimaryDark),
|
||||
const SizedBox(height: 18),
|
||||
_goalOption(
|
||||
sheetContext,
|
||||
Goal(),
|
||||
"No goal",
|
||||
"Stands on its own",
|
||||
colorGrey,
|
||||
selectedId == null,
|
||||
),
|
||||
...active.map((goal) => _goalOption(
|
||||
sheetContext,
|
||||
goal,
|
||||
goal.title,
|
||||
goal.targetDate == null
|
||||
? "${goal.remainingTasks} open"
|
||||
: "${goal.remainingTasks} open · ${targetLabel(goal.targetDate)}",
|
||||
goalColor(goal.colourHex),
|
||||
goal.id == selectedId,
|
||||
)),
|
||||
if (active.isEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
text("No goals yet. Create one from the Goals screen.", 12,
|
||||
TextType.Regular,
|
||||
color: colorGrey2),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _goalOption(
|
||||
BuildContext sheetContext,
|
||||
Goal goal,
|
||||
String title,
|
||||
String caption,
|
||||
Color accent,
|
||||
bool selected,
|
||||
) {
|
||||
return GestureDetector(
|
||||
onTap: () => Navigator.pop(sheetContext, goal),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.fromLTRB(14, 13, 14, 13),
|
||||
decoration: BoxDecoration(
|
||||
color: colorWhite,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: selected ? accent.withValues(alpha: 0.5) : colorBorder,
|
||||
width: selected ? 1.5 : 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(color: accent, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text(title, 14, TextType.Medium, color: colorPrimaryDark),
|
||||
const SizedBox(height: 3),
|
||||
text(caption, 11, TextType.Regular, color: colorGrey2),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (selected)
|
||||
Icon(CupertinoIcons.checkmark_alt, size: 17, color: accent),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
116
frontend/lib/Grounded/designs/ProgressRing.dart
Normal file
116
frontend/lib/Grounded/designs/ProgressRing.dart
Normal file
@@ -0,0 +1,116 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../about/internal/application/TextType.dart';
|
||||
import '../utils/Colors.dart';
|
||||
import 'text/Text.dart';
|
||||
|
||||
/// A thin circular meter with its value in the middle — the routine row's
|
||||
/// "how far did I get" dial, and the runner's phase clock ring.
|
||||
class ProgressRing extends StatelessWidget {
|
||||
final double fraction;
|
||||
|
||||
final double size;
|
||||
|
||||
final double stroke;
|
||||
|
||||
final Color? fill;
|
||||
|
||||
final Color? track;
|
||||
|
||||
/// Drawn in the centre. Defaults to the percentage.
|
||||
final Widget? child;
|
||||
|
||||
const ProgressRing({
|
||||
super.key,
|
||||
required this.fraction,
|
||||
this.size = 44,
|
||||
this.stroke = 3,
|
||||
this.fill,
|
||||
this.track,
|
||||
this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final double clamped = fraction.isNaN ? 0 : fraction.clamp(0, 1).toDouble();
|
||||
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween<double>(end: clamped),
|
||||
duration: const Duration(milliseconds: 450),
|
||||
curve: Curves.easeOutCubic,
|
||||
builder: (context, value, _) {
|
||||
return CustomPaint(
|
||||
painter: _RingPainter(
|
||||
value,
|
||||
stroke,
|
||||
fill ?? colorPrimaryDark,
|
||||
track ?? colorMuted,
|
||||
),
|
||||
child: Center(
|
||||
child: child ??
|
||||
text("${(clamped * 100).round()}%", size * 0.22,
|
||||
TextType.Bold,
|
||||
color: colorPrimaryDark),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RingPainter extends CustomPainter {
|
||||
final double fraction;
|
||||
|
||||
final double stroke;
|
||||
|
||||
final Color fill;
|
||||
|
||||
final Color track;
|
||||
|
||||
_RingPainter(this.fraction, this.stroke, this.fill, this.track);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final Offset centre = Offset(size.width / 2, size.height / 2);
|
||||
final double radius = (math.min(size.width, size.height) - stroke) / 2;
|
||||
|
||||
final Paint base = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = stroke
|
||||
..color = track;
|
||||
|
||||
canvas.drawCircle(centre, radius, base);
|
||||
|
||||
if (fraction <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
final Paint arc = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = stroke
|
||||
..strokeCap = StrokeCap.round
|
||||
..color = fill;
|
||||
|
||||
canvas.drawArc(
|
||||
Rect.fromCircle(center: centre, radius: radius),
|
||||
-math.pi / 2,
|
||||
2 * math.pi * fraction,
|
||||
false,
|
||||
arc,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_RingPainter old) {
|
||||
return old.fraction != fraction ||
|
||||
old.fill != fill ||
|
||||
old.track != track ||
|
||||
old.stroke != stroke;
|
||||
}
|
||||
}
|
||||
63
frontend/lib/Grounded/designs/input/CountStepper.dart
Normal file
63
frontend/lib/Grounded/designs/input/CountStepper.dart
Normal file
@@ -0,0 +1,63 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import '../text/Text.dart';
|
||||
|
||||
/// A labelled minus / value / plus control for sets, reps and seconds.
|
||||
/// Numbers are stepped, not typed — nobody wants a keyboard mid-routine-edit.
|
||||
Widget countStepper(
|
||||
String label,
|
||||
String value, {
|
||||
required VoidCallback onMinus,
|
||||
required VoidCallback onPlus,
|
||||
bool canMinus = true,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(14, 10, 8, 10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorWhite,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: colorBorder, width: 1),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text(label.toUpperCase(), 9, TextType.Bold,
|
||||
color: colorGrey2, letterSpacing: 1.0),
|
||||
const SizedBox(height: 4),
|
||||
text(value, 17, TextType.Medium, color: colorPrimaryDark),
|
||||
],
|
||||
),
|
||||
),
|
||||
_stepButton(CupertinoIcons.minus, canMinus ? onMinus : null),
|
||||
const SizedBox(width: 6),
|
||||
_stepButton(CupertinoIcons.plus, onPlus),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stepButton(IconData icon, VoidCallback? onTap) {
|
||||
final bool enabled = onTap != null;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: enabled ? colorSecondary : colorInset,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(icon,
|
||||
size: 14, color: enabled ? colorPrimaryDark : colorGrey),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user