Files
GroundedHelper/frontend/lib/Grounded/see/routine/RoutinesState.dart
alvocool d3431ca5de Switch to Inter, round the corners softly and make the nav switch tabs
Inter replaces General Sans (Inter Display for titles and big numbers).
Corners come back from before the squaring commit, scaled to a soft set
(16 cards, 14 buttons, 12 rows, 8 chips) and applied to the screens built
since. The nav is now a cluster of round frosted buttons that switches
Today, Routines, Goals, Groups and Progress in place inside Home: no
pushed pages, no back arrows, and Android back returns to Today.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
2026-11-09 07:08:46 +03:00

941 lines
33 KiB
Dart

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/AmbientBackground.dart';
import '../../designs/Component.dart';
import '../../designs/GlassSurface.dart';
import '../../designs/ProgressRing.dart';
import '../../designs/Responsive.dart';
import '../../designs/Shell.dart';
import '../../designs/buttons/Buttons.dart';
import '../../designs/scene/SceneArt.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() {
_kind = widget.kind;
_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 Scaffold(
backgroundColor: colorAmbientMid,
body: AmbientBackground(
child: SingleChildScrollView(
padding: EdgeInsets.only(bottom: widget.tab ? 130 : 40),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
_hero(),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (_routines.isNotEmpty) ...[
_todayStrip(),
const SizedBox(height: 26),
],
sectionBreak("Category",
caption: _kind == null ? "all kinds" : "tap again to clear"),
_kindRail(),
const SizedBox(height: 22),
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(
"Featured",
caption: _today.isEmpty ? "from your library" : "up next today",
),
_featuredRail(),
const SizedBox(height: 28),
if (_today.isNotEmpty) ...[
sectionBreak(
"Active today",
caption:
"${_today.where((item) => item.complete).length} of ${_today.length} done",
),
..._today.map(_routineRow),
const SizedBox(height: 18),
],
if (_rest.isNotEmpty) ...[
sectionBreak("Library", caption: "${_rest.length} more"),
..._rest.map(_routineRow),
],
],
const SizedBox(height: 24),
roundedCornerButton(
"Build a routine",
_onBuild,
icon: CupertinoIcons.add,
),
],
),
),
],
),
),
),
);
}
/// The image header: a painted scene for the selected kind, fading into
/// the backdrop, with frosted controls and the day's headline on it.
Widget _hero() {
final RoutineKind shown = _kind ?? RoutineKind.Workout;
return SizedBox(
height: 330,
child: Stack(
fit: StackFit.expand,
children: [
ShaderMask(
shaderCallback: (Rect bounds) => const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[Color(0xFF000000), Color(0x00000000)],
stops: <double>[0.55, 1.0],
).createShader(bounds),
blendMode: BlendMode.dstIn,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 450),
child: SceneArt(
key: ValueKey<RoutineKind>(shown),
palette: scenePalette(shown),
seed: 40 + shown.index,
figure: kindIcon(shown),
figureScale: 0.36,
figureAlignment: const Alignment(0.45, -0.1),
),
),
),
Positioned(
top: 0,
left: 0,
right: 0,
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 0),
child: Row(
children: [
if (!widget.tab)
glassCircle(CupertinoIcons.arrow_left, _onBack),
const Spacer(),
glassCircle(CupertinoIcons.add, _onBuild),
],
),
),
),
),
Positioned(
left: 20,
right: 20,
bottom: 18,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text(
"${weekdayName(DateTime.now().weekday)} · ${_today.length} due",
13,
TextType.Regular,
color: colorInkSoft,
),
const SizedBox(height: 6),
text(_headline(), 32, TextType.Medium,
color: colorInk, height: 1.1, letterSpacing: -0.8),
],
),
),
],
),
);
}
String _headline() {
if (_routines.isEmpty) {
return "Build the things\nyou do every time.";
}
final int open = _today.where((item) => !item.complete).length;
if (_today.isEmpty) {
return "Nothing scheduled.\nRun one anyway.";
}
if (open == 0) {
return "Every chain\nheld today.";
}
return "Here's what to\nhold today.";
}
/// Today's load at a glance, on glass.
Widget _todayStrip() {
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));
return GlassSurface(
radius: BorderRadius.circular(16),
tint: colorGlassStrong,
padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
child: Row(
children: [
Expanded(
child: _stripStat(CupertinoIcons.calendar_today, "${due.length}",
"Due today"),
),
Expanded(
child: _stripStat(CupertinoIcons.clock,
formatMinutes(seconds / 60), "Planned"),
),
Expanded(
child: _stripStat(
CupertinoIcons.arrow_right_circle,
ahead.isEmpty
? "—"
: formatStartMinute(ahead.first.startMinute),
"Next"),
),
],
),
);
}
Widget _stripStat(IconData icon, String value, String label) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text(value, 22, TextType.Light, color: colorInk),
const SizedBox(height: 3),
Row(
children: [
Icon(icon, size: 11, color: colorInkSoft),
const SizedBox(width: 4),
text(label, 10, TextType.Regular, color: colorInkSoft),
],
),
],
);
}
/// Round painted portraits — tap to filter, tap again to clear.
Widget _kindRail() {
final List<RoutineKind?> options = <RoutineKind?>[null, ...RoutineKind.values];
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: options.map(_kindTile).toList(),
);
}
Widget _kindTile(RoutineKind? kind) {
final bool selected = _kind == kind;
return GestureDetector(
onTap: () => _onSelectKind(kind),
behavior: HitTestBehavior.opaque,
child: SizedBox(
width: 88,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
width: 78,
height: 78,
padding: const EdgeInsets.all(2.5),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: selected ? colorNight : colorWhite,
boxShadow: const [
BoxShadow(
color: Color(0x1F2A3A30),
blurRadius: 14,
offset: Offset(0, 6),
),
],
),
child: ClipOval(
child: kind == null
? Container(
color: colorWhite,
alignment: Alignment.center,
child: Icon(CupertinoIcons.square_grid_2x2_fill,
size: 22, color: colorInk),
)
: SceneArt(
palette: scenePalette(kind),
seed: 3 + kind.index * 7,
figure: kindIcon(kind),
figureScale: 0.48,
figureAlignment: const Alignment(0, 0.25),
),
),
),
const SizedBox(height: 8),
text(
kind == null ? "All" : kindLabel(kind),
11,
selected ? TextType.Medium : TextType.Regular,
color: selected ? colorInk : colorInkSoft,
maxLines: 1,
),
const SizedBox(height: 1),
text("${_countFor(kind)}", 10, TextType.Regular,
color: colorInkSoft),
],
),
),
);
}
Widget _featuredRail() {
final List<RoutineChain> items = _upNext;
return SizedBox(
height: 290,
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 image-led card: the painted scene, a glass chip, the title and three
/// light numbers along the bottom.
Widget _featuredCard(RoutineChain routine) {
final Color label = colorWhite.withValues(alpha: 0.75);
final bool workout = routine.kind == RoutineKind.Workout;
return GestureDetector(
onTap: () => _onPreview(routine),
child: Container(
width: 244,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: const [
BoxShadow(
color: Color(0x262A3A30),
blurRadius: 24,
offset: Offset(0, 10),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Stack(
fit: StackFit.expand,
children: [
SceneArt(
palette: scenePalette(routine.kind),
seed: sceneSeed(routine.title),
figure: kindIcon(routine.kind),
figureScale: 0.44,
figureAlignment: const Alignment(0.1, -0.25),
shade: true,
),
Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
GlassSurface(
onDark: true,
radius: BorderRadius.circular(14),
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 5),
child: text(
routine.commitmentClass ==
CommitmentClass.NonNegotiable
? "Must hold"
: kindLabel(routine.kind),
10,
TextType.Medium,
color: colorWhite,
),
),
const Spacer(),
glassCircle(CupertinoIcons.play_fill,
() => _onStart(routine),
size: 32, onDark: true),
],
),
const Spacer(),
if (routine.startMinute >= 0)
text(
"${formatStartMinute(routine.startMinute)} · ${formatDays(routine.days)}",
10,
TextType.Regular,
color: label,
maxLines: 1,
),
const SizedBox(height: 3),
text(routine.title, 17, TextType.Medium,
color: colorWhite,
maxLines: 2,
overflow: TextOverflow.ellipsis),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: _cardStat(
"${(routine.totalSeconds / 60).round()}",
CupertinoIcons.clock,
"Min",
label),
),
Expanded(
child: _cardStat("${routine.steps.length}",
CupertinoIcons.list_bullet, "Steps", label),
),
Expanded(
child: _cardStat(
workout
? "${routine.totalSets}"
: routine.startMinute < 0
? "Any"
: formatStartMinute(routine.startMinute),
workout
? CupertinoIcons.repeat
: CupertinoIcons.alarm,
workout ? "Sets" : "Starts",
label),
),
],
),
],
),
),
],
),
),
),
);
}
Widget _cardStat(String value, IconData icon, String caption, Color tone) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text(value, 20, TextType.Light, color: colorWhite, maxLines: 1),
const SizedBox(height: 2),
Row(
children: [
Icon(icon, size: 10, color: tone),
const SizedBox(width: 3),
text(caption, 9, TextType.Regular, color: tone),
],
),
],
);
}
Widget _routineRow(RoutineChain routine) {
final Color tone = kindColor(routine.kind);
final bool weighted = routine.commitmentClass != CommitmentClass.Standard;
final List<String> kicker = <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(8, 8, 12, 8),
onTap: () => _onPreview(routine),
child: Row(
children: [
SizedBox(
width: 90,
height: 90,
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: SceneArt(
palette: scenePalette(routine.kind),
seed: sceneSeed(routine.title),
figure: kindIcon(routine.kind),
figureScale: 0.46,
figureAlignment: const Alignment(0, 0.1),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text(
kicker.join(" · "),
10,
TextType.Medium,
color: weighted
? classColor(routine.commitmentClass)
: tone,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
text(routine.title, 15, TextType.Medium,
color: colorInk,
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,
track: tone.withValues(alpha: 0.12),
),
],
),
),
);
}
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: colorInkSoft),
const SizedBox(width: 4),
text(value, 11, TextType.Regular, color: colorInkSoft),
],
);
}
/// The preview: the scene up top, then 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.9,
),
decoration: const BoxDecoration(
color: colorAmbientMid,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
clipBehavior: Clip.antiAlias,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Flexible(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 220,
child: Stack(
fit: StackFit.expand,
children: [
SceneArt(
palette: scenePalette(routine.kind),
seed: sceneSeed(routine.title),
figure: kindIcon(routine.kind),
figureScale: 0.5,
figureAlignment: const Alignment(0.4, -0.1),
shade: true,
),
Positioned(
top: 10,
left: 0,
right: 0,
child: Center(
child: Container(
width: 38,
height: 4,
decoration: BoxDecoration(
color: colorWhite.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(14),
),
),
),
),
Positioned(
left: 24,
right: 24,
bottom: 18,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
GlassSurface(
onDark: true,
radius: BorderRadius.circular(14),
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 5),
child: text(kindLabel(routine.kind), 10,
TextType.Medium,
color: colorWhite),
),
const SizedBox(width: 6),
GlassSurface(
onDark: true,
radius: BorderRadius.circular(14),
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 5),
child: text(
classLabel(routine.commitmentClass),
10,
TextType.Medium,
color: colorWhite),
),
],
),
const SizedBox(height: 10),
text(routine.title, 28, TextType.Medium,
color: colorWhite,
height: 1.1,
letterSpacing: -0.6),
],
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 18, 24, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (routine.note.isNotEmpty) ...[
text(routine.note, 13, TextType.Regular,
color: colorInkSoft, height: 1.5),
const SizedBox(height: 16),
],
GlassSurface(
radius: BorderRadius.circular(14),
tint: colorGlassStrong,
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _stripStat(
CupertinoIcons.clock,
"${(routine.totalSeconds / 60).round()}",
"Minutes"),
),
Expanded(
child: _stripStat(CupertinoIcons.list_bullet,
"${routine.steps.length}", "Steps"),
),
Expanded(
child: _stripStat(
CupertinoIcons.alarm,
routine.startMinute < 0
? "Any"
: formatStartMinute(
routine.startMinute),
formatDays(routine.days)),
),
],
),
),
const SizedBox(height: 22),
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 Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.fromLTRB(10, 10, 14, 10),
decoration: BoxDecoration(
color: colorWhite,
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
Container(
width: 34,
height: 34,
alignment: Alignment.center,
decoration: BoxDecoration(
color: tone.withValues(alpha: 0.10),
shape: BoxShape.circle,
),
child: text("${index + 1}", 12, TextType.Medium, color: tone),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text(step.title, 14, TextType.Medium, color: colorInk),
const SizedBox(height: 3),
text(stepSummary(step), 11, TextType.Regular,
color: colorInkSoft),
],
),
),
Icon(stepModeIcon(step.mode), size: 15, color: colorInkSoft),
],
),
);
}
// ── ConnectRoutines ───────────────────────────────────────────────────────
@override
void onRoutinesLoaded(List<RoutineChain> routines) {
setState(() {
_routines = routines;
});
}
}