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:
alvocool
2026-11-02 08:45:41 +03:00
parent c88887b639
commit 1a5867a98f
49 changed files with 5384 additions and 220 deletions

View 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),
],
),
),
);
}

View 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;
}
}

View 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),
),
);
}