Files
GroundedHelper/frontend/lib/Grounded/see/live/LiveTaskState.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

457 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<LiveTask>
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<ViewLiveTask>.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<SystemUiOverlayStyle>(
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<FormState> formKey = GlobalKey<FormState>();
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(16),
topRight: Radius.circular(16),
),
),
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();
}
}