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
1187 lines
37 KiB
Dart
1187 lines
37 KiB
Dart
import 'dart:async';
|
||
|
||
import 'package:flutter/cupertino.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:stacked/stacked.dart';
|
||
|
||
import '../../about/external/data/RoutineStep.dart';
|
||
import '../../about/internal/application/NotificationType.dart';
|
||
import '../../about/internal/application/RoutineKind.dart';
|
||
import '../../about/internal/application/RoutinePhase.dart';
|
||
import '../../about/internal/application/RoutineRun.dart';
|
||
import '../../about/internal/application/StepMode.dart';
|
||
import '../../about/internal/application/TextType.dart';
|
||
import '../../designs/AmbientBackground.dart';
|
||
import '../../designs/Component.dart';
|
||
import '../../designs/DotDigits.dart';
|
||
import '../../designs/GlassSurface.dart';
|
||
import '../../designs/ProgressRing.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 '../../utils/Thresholds.dart';
|
||
import 'ConnectRoutineRunner.dart';
|
||
import 'RoutineRunner.dart';
|
||
import 'ViewRoutineRunner.dart';
|
||
|
||
/// The routine player. A painted scene carries the phase — a glass ring that
|
||
/// drains for timed work, a breathing circle for meditation — and the light
|
||
/// panel beneath it carries the chain on an arc, the step and the dotted clock.
|
||
class RoutineRunnerState extends State<RoutineRunner>
|
||
with WidgetsBindingObserver, SingleTickerProviderStateMixin
|
||
implements ConnectRoutineRunner {
|
||
ViewRoutineRunner? _model;
|
||
|
||
late RoutineRun _run;
|
||
|
||
Timer? _ticker;
|
||
|
||
/// Drives repaint only; every value shown is derived from wall-clock.
|
||
int _tick = 0;
|
||
|
||
/// The last whole second a countdown haptic fired on, so each fires once.
|
||
int _lastBuzz = -1;
|
||
|
||
bool _haptics = true;
|
||
|
||
bool _finished = false;
|
||
|
||
late AnimationController _breath;
|
||
|
||
final ScrollController _carousel = ScrollController();
|
||
|
||
static const double _carouselItem = 78;
|
||
|
||
/// Run time below which leaving logs nothing — opening the player by
|
||
/// mistake is not a broken chain.
|
||
static const int _graceSeconds = 30;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
WidgetsBinding.instance.addObserver(this);
|
||
|
||
_run = RoutineRun(widget.routine);
|
||
for (RoutineStep step in _run.chain.steps) {
|
||
step.completed = false;
|
||
}
|
||
|
||
// Box breathing: four seconds in, four out.
|
||
_breath = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(seconds: 4),
|
||
);
|
||
|
||
_run.resume();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ViewModelBuilder<ViewRoutineRunner>.reactive(
|
||
viewModelBuilder: () => ViewRoutineRunner(context, this),
|
||
onViewModelReady: (viewModel) {
|
||
_model = viewModel;
|
||
_initiate();
|
||
},
|
||
builder: (context, viewModel, child) => PopScope(
|
||
// Leaving mid-chain is a decision, not a back gesture.
|
||
canPop: false,
|
||
onPopInvokedWithResult: (didPop, result) {
|
||
if (!didPop) {
|
||
_onRequestExit();
|
||
}
|
||
},
|
||
child: AnnotatedRegion<SystemUiOverlayStyle>(
|
||
value: SystemUiOverlayStyle.light,
|
||
child: Scaffold(
|
||
backgroundColor: colorAmbientMid,
|
||
body: _finished ? _summaryView() : _runnerView(),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
void _initiate() {
|
||
_startTicker();
|
||
_syncBreath();
|
||
_model?.publishRun(_run);
|
||
}
|
||
|
||
void _startTicker() {
|
||
_ticker?.cancel();
|
||
_ticker = Timer.periodic(const Duration(milliseconds: 250), _onTick);
|
||
}
|
||
|
||
void _onTick(Timer timer) {
|
||
if (!mounted || _finished) {
|
||
return;
|
||
}
|
||
|
||
if (_run.running && _run.phase.countsDown) {
|
||
final int remaining = _run.phaseRemaining();
|
||
|
||
if (remaining <= Thresholds.routineCountdownSeconds &&
|
||
remaining > 0 &&
|
||
remaining != _lastBuzz) {
|
||
_lastBuzz = remaining;
|
||
_buzz(light: true);
|
||
}
|
||
|
||
if (_run.phaseFinished()) {
|
||
_advance(skipped: false);
|
||
return;
|
||
}
|
||
}
|
||
|
||
setState(() {
|
||
_tick = _tick + 1;
|
||
});
|
||
|
||
// Refresh the lock-screen notification every ~5s rather than every tick.
|
||
if (_tick % 20 == 0 && _run.running) {
|
||
_model?.publishRun(_run);
|
||
}
|
||
}
|
||
|
||
/// Backgrounding pauses the run and is counted, exactly as for a single
|
||
/// task — that is what makes a logged routine mean anything.
|
||
@override
|
||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||
if (_finished) {
|
||
return;
|
||
}
|
||
|
||
if (state == AppLifecycleState.resumed) {
|
||
return;
|
||
}
|
||
|
||
if (state == AppLifecycleState.paused ||
|
||
state == AppLifecycleState.hidden) {
|
||
if (_run.running) {
|
||
setState(() {
|
||
_run.pause();
|
||
_run.backgroundedCount = _run.backgroundedCount + 1;
|
||
});
|
||
_syncBreath();
|
||
_model?.publishRun(_run);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Flow ──────────────────────────────────────────────────────────────────
|
||
|
||
void _advance({required bool skipped}) {
|
||
final bool more = _run.advance(skipped: skipped);
|
||
_lastBuzz = -1;
|
||
|
||
if (!more) {
|
||
_finish();
|
||
return;
|
||
}
|
||
|
||
_buzz(light: false);
|
||
|
||
setState(() {});
|
||
_syncBreath();
|
||
_syncCarousel();
|
||
_model?.publishRun(_run);
|
||
}
|
||
|
||
void _finish() {
|
||
_run.pause();
|
||
_ticker?.cancel();
|
||
_breath.stop();
|
||
_buzz(light: false);
|
||
_model?.clearRun();
|
||
|
||
setState(() {
|
||
_finished = true;
|
||
});
|
||
}
|
||
|
||
void _buzz({required bool light}) {
|
||
if (!_haptics) {
|
||
return;
|
||
}
|
||
if (light) {
|
||
HapticFeedback.selectionClick();
|
||
} else {
|
||
HapticFeedback.heavyImpact();
|
||
}
|
||
}
|
||
|
||
/// The breathing circle only moves while a meditation phase is running.
|
||
void _syncBreath() {
|
||
final bool breathe = _run.chain.kind == RoutineKind.Meditation &&
|
||
_run.running &&
|
||
!_run.phase.rest &&
|
||
!_finished;
|
||
|
||
if (breathe && !_breath.isAnimating) {
|
||
_breath.repeat(reverse: true);
|
||
} else if (!breathe && _breath.isAnimating) {
|
||
_breath.stop();
|
||
}
|
||
}
|
||
|
||
/// Keep the current step centred in the carousel.
|
||
void _syncCarousel() {
|
||
if (!_carousel.hasClients) {
|
||
return;
|
||
}
|
||
// The list is padded by half a viewport either side, so step n is
|
||
// centred at exactly n item-widths of scroll.
|
||
final double target = _run.phase.stepIndex * _carouselItem;
|
||
|
||
_carousel.animateTo(
|
||
target.clamp(0, _carousel.position.maxScrollExtent),
|
||
duration: const Duration(milliseconds: 350),
|
||
curve: Curves.easeOutCubic,
|
||
);
|
||
}
|
||
|
||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||
|
||
void _onTogglePause() {
|
||
setState(() {
|
||
if (_run.running) {
|
||
_run.pause();
|
||
} else {
|
||
_run.resume();
|
||
}
|
||
});
|
||
_syncBreath();
|
||
_model?.publishRun(_run);
|
||
}
|
||
|
||
/// The primary pill: pause/play on a countdown, "done" on anything that
|
||
/// waits for you.
|
||
void _onPrimary() {
|
||
if (_run.phase.countsDown) {
|
||
_onTogglePause();
|
||
return;
|
||
}
|
||
if (!_run.running) {
|
||
_run.resume();
|
||
}
|
||
_advance(skipped: false);
|
||
}
|
||
|
||
void _onNext() {
|
||
// Skipping rest is not skipping work, so it is not counted as a skip.
|
||
_advance(skipped: !_run.phase.rest);
|
||
}
|
||
|
||
void _onPrevious() {
|
||
setState(() {
|
||
_run.back();
|
||
_lastBuzz = -1;
|
||
});
|
||
_syncBreath();
|
||
_syncCarousel();
|
||
_model?.publishRun(_run);
|
||
}
|
||
|
||
void _onToggleHaptics() {
|
||
setState(() {
|
||
_haptics = !_haptics;
|
||
});
|
||
}
|
||
|
||
void _onJumpTo(int stepIndex) {
|
||
setState(() {
|
||
_run.jumpTo(stepIndex);
|
||
_lastBuzz = -1;
|
||
});
|
||
_syncBreath();
|
||
_syncCarousel();
|
||
_model?.publishRun(_run);
|
||
}
|
||
|
||
void _onRequestExit() {
|
||
if (_finished) {
|
||
Navigator.pop(context, false);
|
||
return;
|
||
}
|
||
|
||
final bool worthLogging = _run.elapsedSeconds() >= _graceSeconds;
|
||
final int done = _run.chain.completedSteps;
|
||
final int total = _run.chain.steps.length;
|
||
|
||
_model?.showApplicationNotification(
|
||
NotificationType.warning,
|
||
"Break the chain here?",
|
||
worthLogging
|
||
? "$done of $total steps done. Stopping now logs this run as partial at step ${_run.phase.stepIndex + 1} — it does not disappear."
|
||
: "Nothing has been done yet, so leaving now logs nothing.",
|
||
true,
|
||
true,
|
||
null,
|
||
action: worthLogging ? "Stop and log" : "Leave",
|
||
positiveAction: _onConfirmExit,
|
||
);
|
||
}
|
||
|
||
void _onConfirmExit() {
|
||
Navigator.pop(context);
|
||
|
||
if (_run.elapsedSeconds() < _graceSeconds) {
|
||
_model?.clearRun();
|
||
Navigator.pop(context, false);
|
||
return;
|
||
}
|
||
|
||
_run.pause();
|
||
_model?.log(_run);
|
||
}
|
||
|
||
void _onLog() {
|
||
_model?.log(_run);
|
||
}
|
||
|
||
void _onOpenSteps() {
|
||
showModalBottomSheet(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
backgroundColor: Colors.transparent,
|
||
barrierColor: colorBlack.withValues(alpha: 0.55),
|
||
builder: (BuildContext sheetContext) => _stepsSheet(sheetContext),
|
||
);
|
||
}
|
||
|
||
// ── Views ─────────────────────────────────────────────────────────────────
|
||
|
||
Widget _runnerView() {
|
||
return AmbientBackground(
|
||
child: LayoutBuilder(
|
||
builder: (BuildContext context, BoxConstraints constraints) {
|
||
final double stageHeight = constraints.maxHeight * 0.48;
|
||
|
||
return Stack(
|
||
children: [
|
||
Positioned(
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
height: stageHeight + 70,
|
||
child: _scene(),
|
||
),
|
||
Positioned(
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
height: stageHeight,
|
||
child: _stage(),
|
||
),
|
||
Positioned(
|
||
left: 0,
|
||
right: 0,
|
||
top: stageHeight - 40,
|
||
bottom: 0,
|
||
child: _panel(),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
/// The painted world the run happens in. Rest dims it to dusk.
|
||
Widget _scene() {
|
||
return ShaderMask(
|
||
shaderCallback: (Rect bounds) => const LinearGradient(
|
||
begin: Alignment.topCenter,
|
||
end: Alignment.bottomCenter,
|
||
colors: <Color>[Color(0xFF000000), Color(0x00000000)],
|
||
stops: <double>[0.62, 1.0],
|
||
).createShader(bounds),
|
||
blendMode: BlendMode.dstIn,
|
||
child: Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
SceneArt(
|
||
palette: scenePalette(_run.chain.kind),
|
||
seed: sceneSeed(_run.chain.title),
|
||
),
|
||
AnimatedContainer(
|
||
duration: const Duration(milliseconds: 600),
|
||
color: colorNight.withValues(alpha: _run.phase.rest ? 0.45 : 0.08),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// The top half: frosted controls and the phase visual over the scene.
|
||
Widget _stage() {
|
||
final RoutineKind kind = _run.chain.kind;
|
||
|
||
return SafeArea(
|
||
bottom: false,
|
||
child: Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 6, 16, 50),
|
||
child: Column(
|
||
children: [
|
||
Row(
|
||
children: [
|
||
glassCircle(CupertinoIcons.arrow_left, _onRequestExit,
|
||
onDark: true),
|
||
const Spacer(),
|
||
_totalPill(),
|
||
const SizedBox(width: 8),
|
||
glassCircle(CupertinoIcons.ellipsis_vertical, _onOpenSteps,
|
||
onDark: true),
|
||
],
|
||
),
|
||
Expanded(
|
||
child: Center(
|
||
child: kind == RoutineKind.Meditation && !_run.phase.rest
|
||
? _breathingCircle()
|
||
: _phaseRing(),
|
||
),
|
||
),
|
||
_stageStatus(),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Total elapsed and planned remaining — the reference's "Time" pill.
|
||
Widget _totalPill() {
|
||
return GlassSurface(
|
||
onDark: true,
|
||
radius: BorderRadius.zero,
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(CupertinoIcons.time, size: 13, color: colorWhite),
|
||
const SizedBox(width: 6),
|
||
text(formatClock(_run.elapsedSeconds()), 13, TextType.Medium,
|
||
color: colorWhite),
|
||
Container(
|
||
width: 3,
|
||
height: 3,
|
||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||
decoration: BoxDecoration(
|
||
color: colorWhite.withValues(alpha: 0.6),
|
||
shape: BoxShape.circle,
|
||
),
|
||
),
|
||
text("~${formatClock(_run.plannedRemaining())} left", 12,
|
||
TextType.Regular,
|
||
color: colorWhite.withValues(alpha: 0.8)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// A glass disc standing in the scene with the figure in it; the ring
|
||
/// drains as the phase runs down.
|
||
Widget _phaseRing() {
|
||
final RoutinePhase phase = _run.phase;
|
||
|
||
return ProgressRing(
|
||
fraction: phase.countsDown ? _run.phaseProgress() : 0,
|
||
size: 180,
|
||
stroke: 5,
|
||
fill: colorWhite,
|
||
track: colorWhite.withValues(alpha: 0.22),
|
||
child: GlassSurface(
|
||
onDark: true,
|
||
radius: BorderRadius.zero,
|
||
child: SizedBox(
|
||
width: 150,
|
||
height: 150,
|
||
child: phase.rest
|
||
? Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(CupertinoIcons.wind, size: 38, color: colorWhite),
|
||
const SizedBox(height: 8),
|
||
text("Rest", 14, TextType.Medium, color: colorWhite),
|
||
],
|
||
)
|
||
: Icon(
|
||
phase.step.mode == StepMode.Check
|
||
? CupertinoIcons.checkmark_alt
|
||
: kindIcon(_run.chain.kind),
|
||
size: 70,
|
||
color: colorWhite,
|
||
shadows: const <Shadow>[
|
||
Shadow(
|
||
color: Color(0x40000000),
|
||
blurRadius: 18,
|
||
offset: Offset(0, 6)),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _breathingCircle() {
|
||
return AnimatedBuilder(
|
||
animation: _breath,
|
||
builder: (context, child) {
|
||
final double t = Curves.easeInOut.transform(_breath.value);
|
||
final bool inhaling = _breath.status == AnimationStatus.forward;
|
||
|
||
return SizedBox(
|
||
width: 240,
|
||
height: 240,
|
||
child: Stack(
|
||
alignment: Alignment.center,
|
||
children: [
|
||
Container(
|
||
width: 140 + 96 * t,
|
||
height: 140 + 96 * t,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
color: colorWhite.withValues(alpha: 0.10),
|
||
border: Border.all(
|
||
color: colorWhite.withValues(alpha: 0.35), width: 1),
|
||
),
|
||
),
|
||
GlassSurface(
|
||
onDark: true,
|
||
radius: BorderRadius.zero,
|
||
child: SizedBox(
|
||
width: 120 + 50 * t,
|
||
height: 120 + 50 * t,
|
||
child: Center(
|
||
child: text(
|
||
!_run.running
|
||
? "Paused"
|
||
: inhaling
|
||
? "Breathe in"
|
||
: "Breathe out",
|
||
16,
|
||
TextType.Medium,
|
||
color: colorWhite,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
Widget _stageStatus() {
|
||
final String status = !_run.running
|
||
? "Paused"
|
||
: _run.phase.rest
|
||
? "Resting"
|
||
: "In progress";
|
||
|
||
return Row(
|
||
children: [
|
||
GlassSurface(
|
||
onDark: true,
|
||
radius: BorderRadius.zero,
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Container(
|
||
width: 7,
|
||
height: 7,
|
||
decoration: BoxDecoration(
|
||
color: _run.running ? colorLime : colorWarning,
|
||
shape: BoxShape.circle,
|
||
),
|
||
),
|
||
const SizedBox(width: 7),
|
||
text(status, 11, TextType.Medium, color: colorWhite),
|
||
],
|
||
),
|
||
),
|
||
if (_run.backgroundedCount > 0) ...[
|
||
const SizedBox(width: 8),
|
||
GlassSurface(
|
||
onDark: true,
|
||
radius: BorderRadius.zero,
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
|
||
child: text("Left app ${_run.backgroundedCount}×", 11,
|
||
TextType.Medium,
|
||
color: colorWarmYellow),
|
||
),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
|
||
/// The lower half: the chain on an arc, the step, the dotted clock and the
|
||
/// transport.
|
||
Widget _panel() {
|
||
final RoutinePhase phase = _run.phase;
|
||
final Color tone = kindColor(_run.chain.kind);
|
||
|
||
return SafeArea(
|
||
top: false,
|
||
child: Column(
|
||
children: [
|
||
_stepCarousel(),
|
||
Expanded(
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||
children: [
|
||
Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
text(
|
||
"${phase.stepIndex + 1} OF ${_run.chain.steps.length}",
|
||
13,
|
||
TextType.Regular,
|
||
color: colorInkSoft,
|
||
letterSpacing: 0.6,
|
||
),
|
||
const SizedBox(height: 4),
|
||
text(
|
||
phase.rest ? "Rest" : phase.step.title,
|
||
24,
|
||
TextType.Medium,
|
||
color: colorInk,
|
||
align: TextAlign.center,
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
if (phase.step.sets > 1) ...[
|
||
const SizedBox(height: 10),
|
||
_setDots(phase, tone),
|
||
],
|
||
],
|
||
),
|
||
_clock(phase, tone),
|
||
_caption(phase),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
_transport(phase, tone),
|
||
const SizedBox(height: 14),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _stepCarousel() {
|
||
final List<RoutineStep> steps = _run.chain.steps;
|
||
|
||
return SizedBox(
|
||
height: 104,
|
||
child: LayoutBuilder(
|
||
builder: (BuildContext context, BoxConstraints constraints) {
|
||
final double side = (constraints.maxWidth - _carouselItem) / 2;
|
||
|
||
return ListView.builder(
|
||
controller: _carousel,
|
||
scrollDirection: Axis.horizontal,
|
||
padding: EdgeInsets.symmetric(horizontal: side),
|
||
itemCount: steps.length,
|
||
itemExtent: _carouselItem,
|
||
itemBuilder: (context, index) =>
|
||
_carouselStep(index, steps[index]),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Each step as a painted portrait. They sit on an arc that dips away from
|
||
/// the current one, like beads on a string.
|
||
Widget _carouselStep(int index, RoutineStep step) {
|
||
final int current = _run.phase.stepIndex;
|
||
final bool isCurrent = index == current;
|
||
final bool done = step.completed;
|
||
final int distance = (index - current).abs();
|
||
final double drop = (distance * distance * 5.0).clamp(0, 30).toDouble();
|
||
final double size = isCurrent ? 58 : 46;
|
||
|
||
return GestureDetector(
|
||
onTap: () => _onJumpTo(index),
|
||
behavior: HitTestBehavior.opaque,
|
||
child: AnimatedPadding(
|
||
duration: const Duration(milliseconds: 300),
|
||
curve: Curves.easeOutCubic,
|
||
padding: EdgeInsets.only(top: drop),
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.start,
|
||
children: [
|
||
AnimatedContainer(
|
||
duration: const Duration(milliseconds: 300),
|
||
curve: Curves.easeOutCubic,
|
||
width: size,
|
||
height: size,
|
||
padding: const EdgeInsets.all(2.5),
|
||
decoration: BoxDecoration(
|
||
color: isCurrent ? colorNight : colorWhite,
|
||
boxShadow: const [
|
||
BoxShadow(
|
||
color: Color(0x262A3A30),
|
||
blurRadius: 12,
|
||
offset: Offset(0, 5),
|
||
),
|
||
],
|
||
),
|
||
child: ClipRect(
|
||
child: Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
SceneArt(
|
||
palette: scenePalette(_run.chain.kind),
|
||
seed: sceneSeed(step.title),
|
||
figure: stepModeIcon(step.mode),
|
||
figureScale: 0.42,
|
||
figureAlignment: const Alignment(0, 0.2),
|
||
),
|
||
if (done && !isCurrent)
|
||
Container(
|
||
color: colorNight.withValues(alpha: 0.55),
|
||
child: Icon(Icons.check_rounded,
|
||
size: 20, color: colorLime),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 5),
|
||
text(
|
||
step.title,
|
||
10,
|
||
isCurrent ? TextType.Medium : TextType.Regular,
|
||
color: isCurrent ? colorInk : colorInkSoft,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
align: TextAlign.center,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _setDots(RoutinePhase phase, Color tone) {
|
||
return Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
text("Set ${phase.set} of ${phase.step.sets}", 11, TextType.Medium,
|
||
color: tone),
|
||
const SizedBox(width: 10),
|
||
...List<Widget>.generate(phase.step.sets, (index) {
|
||
final int set = index + 1;
|
||
final bool filled =
|
||
set < phase.set || (set == phase.set && phase.rest);
|
||
final bool active = set == phase.set && !phase.rest;
|
||
|
||
return AnimatedContainer(
|
||
duration: const Duration(milliseconds: 200),
|
||
width: active ? 20 : 7,
|
||
height: 7,
|
||
margin: const EdgeInsets.only(right: 4),
|
||
decoration: BoxDecoration(
|
||
color: filled || active ? tone : tone.withValues(alpha: 0.15),
|
||
borderRadius: BorderRadius.zero,
|
||
),
|
||
);
|
||
}),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// The dotted clock — counting down for timed work, up for everything else,
|
||
/// and the rep target for rep sets.
|
||
Widget _clock(RoutinePhase phase, Color tone) {
|
||
final Color ink = _run.running ? colorInk : colorInkSoft;
|
||
|
||
if (!phase.countsDown && phase.step.mode == StepMode.Reps) {
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
text("×", 44, TextType.Light, color: ink),
|
||
const SizedBox(width: 10),
|
||
DotDigits(value: "${phase.step.reps}", height: 62, color: ink),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
text("reps · ${formatClock(_run.phaseElapsed())} on this set", 12,
|
||
TextType.Regular,
|
||
color: colorInkSoft),
|
||
],
|
||
);
|
||
}
|
||
|
||
final int seconds =
|
||
phase.countsDown ? _run.phaseRemaining() : _run.phaseElapsed();
|
||
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
DotDigits(value: formatClock(seconds), height: 62, color: ink),
|
||
if (phase.countsDown) ...[
|
||
const SizedBox(height: 12),
|
||
SizedBox(
|
||
width: 180,
|
||
child: meter(_run.phaseProgress(),
|
||
fill: phase.rest ? colorInkSoft : tone, height: 4),
|
||
),
|
||
] else ...[
|
||
const SizedBox(height: 8),
|
||
text("on this task", 12, TextType.Regular, color: colorInkSoft),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
|
||
/// The cue while working; what is coming while resting.
|
||
Widget _caption(RoutinePhase phase) {
|
||
final RoutinePhase? next = _run.nextWork;
|
||
|
||
String line = "";
|
||
if (phase.rest && next != null) {
|
||
line = next.step.sets > 1
|
||
? "Up next: ${next.step.title} · set ${next.set} of ${next.step.sets}"
|
||
: "Up next: ${next.step.title}";
|
||
} else if (phase.step.cue.isNotEmpty) {
|
||
line = phase.step.cue;
|
||
} else if (next != null) {
|
||
line = "Then: ${next.step.title}";
|
||
}
|
||
|
||
return text(line, 13, TextType.Regular,
|
||
color: colorInkSoft, align: TextAlign.center, maxLines: 2, height: 1.4);
|
||
}
|
||
|
||
Widget _transport(RoutinePhase phase, Color tone) {
|
||
final bool countdown = phase.countsDown;
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
_roundControl(
|
||
_haptics
|
||
? CupertinoIcons.speaker_2
|
||
: CupertinoIcons.speaker_slash,
|
||
_onToggleHaptics,
|
||
),
|
||
_roundControl(CupertinoIcons.backward_end_fill, _onPrevious,
|
||
enabled: _run.phaseIndex > 0),
|
||
GestureDetector(
|
||
onTap: _onPrimary,
|
||
child: AnimatedContainer(
|
||
duration: const Duration(milliseconds: 200),
|
||
width: 116,
|
||
height: 58,
|
||
alignment: Alignment.center,
|
||
decoration: BoxDecoration(
|
||
color: colorNight,
|
||
borderRadius: BorderRadius.zero,
|
||
boxShadow: const [
|
||
BoxShadow(
|
||
color: Color(0x331A1F1C),
|
||
blurRadius: 18,
|
||
offset: Offset(0, 8),
|
||
),
|
||
],
|
||
),
|
||
child: countdown
|
||
? Icon(
|
||
_run.running
|
||
? CupertinoIcons.pause_fill
|
||
: CupertinoIcons.play_fill,
|
||
size: 24,
|
||
color: colorLime,
|
||
)
|
||
: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(Icons.check_rounded, size: 18, color: colorLime),
|
||
const SizedBox(width: 6),
|
||
text(
|
||
phase.step.mode == StepMode.Reps
|
||
? "Set done"
|
||
: "Done",
|
||
13,
|
||
TextType.Medium,
|
||
color: colorWhite,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
_roundControl(CupertinoIcons.forward_end_fill, _onNext),
|
||
_roundControl(CupertinoIcons.list_number, _onOpenSteps),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _roundControl(IconData icon, VoidCallback onTap,
|
||
{bool enabled = true}) {
|
||
return Opacity(
|
||
opacity: enabled ? 1 : 0.4,
|
||
child: glassCircle(icon, enabled ? onTap : _onDisabled, size: 48),
|
||
);
|
||
}
|
||
|
||
void _onDisabled() {}
|
||
|
||
/// Every step with its state. Tap one to jump there.
|
||
Widget _stepsSheet(BuildContext sheetContext) {
|
||
final Color tone = kindColor(_run.chain.kind);
|
||
|
||
return Container(
|
||
constraints: BoxConstraints(
|
||
maxHeight: MediaQuery.of(sheetContext).size.height * 0.7,
|
||
),
|
||
decoration: const BoxDecoration(
|
||
color: colorSheetBackground,
|
||
borderRadius: BorderRadius.only(
|
||
topLeft: Radius.zero,
|
||
topRight: Radius.zero,
|
||
),
|
||
),
|
||
child: ListView(
|
||
shrinkWrap: true,
|
||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 32),
|
||
children: [
|
||
text("THE CHAIN", 9, TextType.Bold,
|
||
color: tone, letterSpacing: 1.2),
|
||
const SizedBox(height: 8),
|
||
text(_run.chain.title, 24, TextType.Light, color: colorPrimaryDark),
|
||
const SizedBox(height: 18),
|
||
..._run.chain.steps.asMap().entries.map((entry) {
|
||
final int index = entry.key;
|
||
final RoutineStep step = entry.value;
|
||
final bool current = index == _run.phase.stepIndex;
|
||
|
||
return GestureDetector(
|
||
onTap: () {
|
||
Navigator.pop(sheetContext);
|
||
_onJumpTo(index);
|
||
},
|
||
behavior: HitTestBehavior.opaque,
|
||
child: Container(
|
||
margin: const EdgeInsets.only(bottom: 8),
|
||
padding: const EdgeInsets.fromLTRB(12, 12, 14, 12),
|
||
decoration: BoxDecoration(
|
||
color: current ? kindBackground(_run.chain.kind) : colorWhite,
|
||
borderRadius: BorderRadius.zero,
|
||
border: Border.all(
|
||
color: current ? tone.withValues(alpha: 0.35) : colorBorder,
|
||
),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 28,
|
||
height: 28,
|
||
alignment: Alignment.center,
|
||
decoration: BoxDecoration(
|
||
color: step.completed ? tone : colorSecondary,
|
||
),
|
||
child: step.completed
|
||
? Icon(Icons.check_rounded,
|
||
size: 16, color: colorWhite)
|
||
: text("${index + 1}", 11, TextType.Bold,
|
||
color: colorGrey2),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
text(step.title, 14, TextType.Medium,
|
||
color: colorPrimaryDark),
|
||
const SizedBox(height: 3),
|
||
text(stepSummary(step), 11, TextType.Regular,
|
||
color: colorGrey2),
|
||
],
|
||
),
|
||
),
|
||
if (current)
|
||
pill("Now", tone, colorWhite, textSize: 9),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// End of run. Held or not, it is stated plainly and then logged.
|
||
Widget _summaryView() {
|
||
final bool held = _run.chain.complete;
|
||
final int done = _run.chain.completedSteps;
|
||
final int total = _run.chain.steps.length;
|
||
final bool broken =
|
||
!held && _run.chain.partialCompletion < Thresholds.routinePartialFloor;
|
||
|
||
return AmbientBackground(
|
||
child: Stack(
|
||
children: [
|
||
Positioned(
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
height: 420,
|
||
child: ShaderMask(
|
||
shaderCallback: (Rect bounds) => const LinearGradient(
|
||
begin: Alignment.topCenter,
|
||
end: Alignment.bottomCenter,
|
||
colors: <Color>[Color(0xFF000000), Color(0x00000000)],
|
||
stops: <double>[0.5, 1.0],
|
||
).createShader(bounds),
|
||
blendMode: BlendMode.dstIn,
|
||
child: SceneArt(
|
||
palette: scenePalette(_run.chain.kind),
|
||
seed: sceneSeed(_run.chain.title),
|
||
),
|
||
),
|
||
),
|
||
SafeArea(
|
||
child: Padding(
|
||
padding: const EdgeInsets.fromLTRB(24, 20, 24, 24),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
GlassSurface(
|
||
onDark: true,
|
||
radius: BorderRadius.zero,
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 12, vertical: 7),
|
||
child: text(
|
||
held
|
||
? "Chain held"
|
||
: (broken ? "Chain broken" : "Partial"),
|
||
12,
|
||
TextType.Medium,
|
||
color: colorWhite,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const Spacer(),
|
||
Center(
|
||
child: ProgressRing(
|
||
fraction: _run.chain.partialCompletion,
|
||
size: 190,
|
||
stroke: 6,
|
||
fill: held ? colorLime : colorWhite,
|
||
track: colorWhite.withValues(alpha: 0.3),
|
||
child: GlassSurface(
|
||
onDark: true,
|
||
radius: BorderRadius.zero,
|
||
child: SizedBox(
|
||
width: 158,
|
||
height: 158,
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
text("$done/$total", 42, TextType.Light,
|
||
color: colorWhite),
|
||
text("steps", 12, TextType.Regular,
|
||
color: colorWhite.withValues(alpha: 0.8)),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 36),
|
||
text(_run.chain.title, 32, TextType.Medium,
|
||
color: colorInk, height: 1.1, letterSpacing: -0.6),
|
||
const SizedBox(height: 8),
|
||
text(
|
||
held
|
||
? "Every step, in order. That is the whole point."
|
||
: "$done of $total steps. This goes in as partial — honest, not hidden.",
|
||
14,
|
||
TextType.Regular,
|
||
color: colorInkSoft,
|
||
height: 1.5,
|
||
),
|
||
const SizedBox(height: 22),
|
||
GlassSurface(
|
||
radius: BorderRadius.zero,
|
||
tint: colorGlassStrong,
|
||
padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: labelled(
|
||
"Time", formatClock(_run.elapsedSeconds()),
|
||
valueSize: 22, valueType: TextType.Light),
|
||
),
|
||
Expanded(
|
||
child: labelled("Skipped", "${_run.skippedCount}",
|
||
valueSize: 22, valueType: TextType.Light),
|
||
),
|
||
Expanded(
|
||
child: labelled(
|
||
"Left app", "${_run.backgroundedCount}×",
|
||
valueSize: 22,
|
||
valueType: TextType.Light,
|
||
valueColor: _run.backgroundedCount > 0
|
||
? colorWarning
|
||
: colorInk),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const Spacer(),
|
||
roundedCornerButton(
|
||
"Log it",
|
||
_onLog,
|
||
icon: CupertinoIcons.checkmark,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── ConnectRoutineRunner ──────────────────────────────────────────────────
|
||
|
||
@override
|
||
void onLogged(bool held) {
|
||
Navigator.pop(context, true);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_ticker?.cancel();
|
||
_breath.dispose();
|
||
_carousel.dispose();
|
||
WidgetsBinding.instance.removeObserver(this);
|
||
_model?.clearRun();
|
||
super.dispose();
|
||
}
|
||
}
|