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/LiveSession.dart'; import '../../about/internal/application/CommitmentClass.dart'; import '../../about/internal/application/NotificationType.dart'; import '../../about/internal/application/ProofType.dart'; import '../../about/internal/application/TextType.dart'; import '../../designs/Component.dart'; import '../../designs/buttons/Buttons.dart'; import '../../designs/input/InputFields.dart'; import '../../designs/text/Text.dart'; import '../../utils/Colors.dart'; import '../../utils/CommonUtils.dart'; import '../../utils/Validators.dart'; import 'ConnectLiveTask.dart'; import 'LiveTask.dart'; import 'ViewLiveTask.dart'; /// The full-screen runner. Deliberately the only thing on screen: a task you /// are running is not a row in a list, it is the thing you are doing. class LiveTaskState extends State with WidgetsBindingObserver implements ConnectLiveTask { ViewLiveTask? _model; late LiveSession _session; Timer? _ticker; /// Drives the display only. The elapsed value itself is derived from /// wall-clock, so a throttled ticker during screen-off cannot lose time. int _tick = 0; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _session = LiveSession( commitmentId: widget.commitment.id ?? "", title: widget.commitment.title, goalTitle: widget.goalTitle, startedAt: DateTime.now(), requiredSeconds: widget.commitment.proofType == ProofType.Timer ? widget.commitment.proofTimerMinutes * 60 : 0, ); _session.resume(); } @override Widget build(BuildContext context) { return ViewModelBuilder.reactive( viewModelBuilder: () => ViewLiveTask(context, this), onViewModelReady: (viewModel) { _model = viewModel; _initiate(); }, builder: (context, viewModel, child) => PopScope( // Leaving mid-run is a decision, not a back gesture. canPop: false, onPopInvokedWithResult: (didPop, result) { if (!didPop) { _onRequestExit(); } }, child: AnnotatedRegion( value: SystemUiOverlayStyle.light, child: Scaffold( backgroundColor: colorPrimaryDark, body: SafeArea(child: _runnerView()), ), ), ), ); } void _initiate() { _startTicker(); _model?.publishSession(_session); } void _startTicker() { _ticker?.cancel(); _ticker = Timer.periodic(const Duration(seconds: 1), (timer) { if (!mounted) { return; } setState(() { _tick = _tick + 1; }); // Refresh the lock-screen notification every 5s rather than every tick, // so the ongoing notification stays current without thrashing. if (_tick % 5 == 0 && _session.running) { _model?.publishSession(_session); } }); } /// Backgrounding pauses the clock — that is what makes Timer proof mean /// something. The count is kept and shown rather than hidden. @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { if (!_session.running) { setState(() { _session.resume(); }); _model?.publishSession(_session); } return; } if (state == AppLifecycleState.paused || state == AppLifecycleState.hidden) { if (_session.running) { setState(() { _session.pause(); _session.backgroundedCount = _session.backgroundedCount + 1; }); _model?.publishSession(_session); } } } // ── Handlers ────────────────────────────────────────────────────────────── void _onTogglePause() { setState(() { if (_session.running) { _session.pause(); } else { _session.resume(); } }); _model?.publishSession(_session); } void _onFinish() { _model?.complete(widget.commitment, _session); } void _onRequestExit() { _model?.showApplicationNotification( NotificationType.warning, "Leave this running?", _session.satisfied() ? "You have met the requirement. You can finish it properly instead of walking away." : "You are ${formatClock(_session.remainingSeconds())} short. Leaving now logs nothing.", true, true, null, action: "Leave anyway", positiveAction: () { Navigator.pop(context); _model?.clearSession(); Navigator.pop(context, false); }, ); } void _onAbandon() { final TextEditingController reason = TextEditingController(); final GlobalKey formKey = GlobalKey(); showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, barrierColor: colorBlack.withValues(alpha: 0.7), builder: (BuildContext sheetContext) { return Padding( padding: EdgeInsets.only( bottom: MediaQuery.of(sheetContext).viewInsets.bottom, ), child: Container( decoration: BoxDecoration( color: colorSheetBackground, borderRadius: const BorderRadius.only( topLeft: Radius.circular(28), topRight: Radius.circular(28), ), ), child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(24, 24, 24, 32), child: Form( key: formKey, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ text("ABANDONING MID-RUN", 9, TextType.Bold, color: colorStandingLockdown, letterSpacing: 1.2), const SizedBox(height: 10), text(widget.commitment.title, 24, TextType.Light, color: colorPrimaryDark, height: 1.2), const SizedBox(height: 16), inputField( "Reason", reason, hint: "Why is this stopping here?", validator: Validators.excuse, maxLines: 3, ), const SizedBox(height: 20), destructiveButton("Abandon", () { if (formKey.currentState?.validate() != true) { return; } Navigator.pop(sheetContext); _model?.abandon(widget.commitment, reason.text.trim()); }), const SizedBox(height: 8), Center( child: textButton("Keep going", () => Navigator.pop(sheetContext), textSize: 13), ), ], ), ), ), ), ); }, ); } // ── Views ───────────────────────────────────────────────────────────────── Widget _runnerView() { final bool timed = _session.requiredSeconds > 0; final bool satisfied = _session.satisfied(); return Padding( padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 20), child: Column( mainAxisSize: MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ _runnerHeader(), _runnerClock(timed, satisfied), _runnerControls(timed, satisfied), ], ), ); } Widget _runnerHeader() { return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ Container( width: 7, height: 7, decoration: BoxDecoration( color: _session.running ? colorPositive : colorWarning, shape: BoxShape.circle, ), ), const SizedBox(width: 8), text( _session.running ? "IN PROGRESS" : "PAUSED", 9, TextType.Bold, color: colorWhite.withValues(alpha: 0.55), letterSpacing: 1.4, ), ], ), iconButton( Icon(CupertinoIcons.xmark, size: 15, color: colorWhite.withValues(alpha: 0.7)), _onRequestExit, ), ], ), const SizedBox(height: 28), if (widget.goalTitle.isNotEmpty) ...[ text(widget.goalTitle.toUpperCase(), 10, TextType.Bold, color: colorWhite.withValues(alpha: 0.40), letterSpacing: 1.4), const SizedBox(height: 10), ], text(widget.commitment.title, 34, TextType.Light, color: colorWhite, height: 1.15), const SizedBox(height: 16), Row( children: [ pill( classLabel(widget.commitment.commitmentClass), colorWhite, colorWhite.withValues(alpha: 0.12), textSize: 9, ), const SizedBox(width: 8), pill( proofLabel(widget.commitment.proofType), colorWhite.withValues(alpha: 0.75), colorWhite.withValues(alpha: 0.08), textSize: 9, ), ], ), ], ); } Widget _runnerClock(bool timed, bool satisfied) { final int elapsed = _session.elapsedSeconds(); return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ text( timed ? (satisfied ? "REQUIREMENT MET" : "REMAINING") : "ELAPSED", 9, TextType.Bold, color: satisfied ? colorPositive : colorWhite.withValues(alpha: 0.40), letterSpacing: 1.6, ), const SizedBox(height: 18), text( timed && !satisfied ? formatClock(_session.remainingSeconds()) : formatClock(elapsed), 78, TextType.Light, color: colorWhite, height: 1.0, ), if (timed) ...[ const SizedBox(height: 28), meter( _session.progress(), fill: satisfied ? colorPositive : colorWhite, track: colorWhite.withValues(alpha: 0.12), height: 5, ), const SizedBox(height: 14), text( satisfied ? "You can finish this now." : "Leaving the app pauses the clock.", 12, TextType.Regular, color: colorWhite.withValues(alpha: 0.45), align: TextAlign.center, ), ], if (_session.backgroundedCount > 0) ...[ const SizedBox(height: 18), pill( "Left ${_session.backgroundedCount}×", colorWarning, colorWarning.withValues(alpha: 0.12), textSize: 9, ), ], ], ); } Widget _runnerControls(bool timed, bool satisfied) { return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ roundedCornerButton( satisfied || !timed ? "Finish" : "Finish early", _onFinish, background: satisfied || !timed ? colorWhite : colorWhite.withValues(alpha: 0.14), foreground: satisfied || !timed ? colorPrimaryDark : colorWhite, icon: CupertinoIcons.checkmark, ), const SizedBox(height: 10), outlinedActionButton( _session.running ? "Pause" : "Resume", _onTogglePause, foreground: colorWhite, icon: _session.running ? CupertinoIcons.pause_fill : CupertinoIcons.play_fill, ), const SizedBox(height: 6), Center( child: textButton( "Abandon this", _onAbandon, textSize: 12, color: colorWhite.withValues(alpha: 0.45), ), ), ], ); } // ── ConnectLiveTask ─────────────────────────────────────────────────────── @override void onCompleted() { _model?.showApplicationNotification( NotificationType.success, widget.commitment.wouldBeLate ? "Late complete" : "Done", widget.commitment.wouldBeLate ? "Recorded as a late complete — the window had already closed." : "${formatClock(_session.elapsedSeconds())} of focused work, recorded.", true, true, () { Navigator.pop(context, true); }, ); } @override void onTooEarly(int secondsRemaining) { _model?.showApplicationNotification( NotificationType.warning, "Not yet", "${formatClock(secondsRemaining)} still to go. The timer is the proof — finishing early would just be the checkbox again.", true, true, null, ); } @override void onAbandoned() { Navigator.pop(context, true); } @override void dispose() { _ticker?.cancel(); WidgetsBinding.instance.removeObserver(this); _model?.clearSession(); super.dispose(); } }