Files
GroundedHelper/frontend/lib/Grounded/see/routine/RoutinesState.dart
alvocool 9c858b8146 Square every corner, enlarge the main surfaces and add a to-do list
Rounded rectangles and round controls are now square across the app; only
status dots, glows, the sun, rings and the breathing circle stay round.
Cards, rows, thumbnails, featured cards, category tiles, the standing card
and the nav are larger. Home gets a quick To-do list kept on the device
(TodoItem in InternalMemory), seeded with sample items in mock mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
2026-11-02 14:50:31 +03:00

937 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: const EdgeInsets.only(bottom: 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: [
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.zero,
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(
color: selected ? colorNight : colorWhite,
boxShadow: const [
BoxShadow(
color: Color(0x1F2A3A30),
blurRadius: 14,
offset: Offset(0, 6),
),
],
),
child: ClipRect(
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.zero,
boxShadow: const [
BoxShadow(
color: Color(0x262A3A30),
blurRadius: 24,
offset: Offset(0, 10),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.zero,
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.zero,
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.zero,
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,
),
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.zero,
topRight: Radius.zero,
),
),
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.zero,
),
),
),
),
Positioned(
left: 24,
right: 24,
bottom: 18,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
GlassSurface(
onDark: true,
radius: BorderRadius.zero,
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.zero,
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.zero,
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.zero,
),
child: Row(
children: [
Container(
width: 34,
height: 34,
alignment: Alignment.center,
decoration: BoxDecoration(
color: tone.withValues(alpha: 0.10),
),
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;
});
}
}