Light sage-to-cream ambient backdrop, frosted glass controls and painted SceneArt landscapes in place of photography. Home becomes a dashboard with a two-tone greeting, a frosted summary ring, category portraits, an up-next rail of image-led cards and a floating glass tab bar. The routine library gets an image header and painted cards; the player gets a scene stage, steps on an arc, a dotted clock and a night pill with a lime spark. Goals become painted cards in their own colour. New logo: a lime sun resting on a ground line under two arcs. The splash raises the sun and lights the arcs; launcher icons and native splash are regenerated to match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
940 lines
33 KiB
Dart
940 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.circular(24),
|
|
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: 74,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
AnimatedContainer(
|
|
duration: const Duration(milliseconds: 200),
|
|
curve: Curves.easeOut,
|
|
width: 64,
|
|
height: 64,
|
|
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: 240,
|
|
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: 200,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(26),
|
|
boxShadow: const [
|
|
BoxShadow(
|
|
color: Color(0x262A3A30),
|
|
blurRadius: 24,
|
|
offset: Offset(0, 10),
|
|
),
|
|
],
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(26),
|
|
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(99),
|
|
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: 70,
|
|
height: 70,
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(18),
|
|
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(32),
|
|
topRight: Radius.circular(32),
|
|
),
|
|
),
|
|
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(999),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
left: 24,
|
|
right: 24,
|
|
bottom: 18,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
GlassSurface(
|
|
onDark: true,
|
|
radius: BorderRadius.circular(99),
|
|
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(99),
|
|
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(22),
|
|
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(18),
|
|
),
|
|
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;
|
|
});
|
|
}
|
|
}
|