wake-alarm/phone_app/lib/screens/session_body.dart
Krzysztof kuhy Rudnicki c3c129b068 Make the morning session the carrot: earn 11:00 by finishing it in time
On 2026-09-19 the phone was powered off after the bathroom tag and nothing
on the PC noticed: every finished session was published and nothing read
it. Now wake-alarm distils the phone's morning into an HMAC-signed
~/.local/state/wake_alarm/morning_session.json (wake-alarm-session.timer,
07:00-11:00 every 2 min) that screen-locker and leetcode-guard read to
defer their locks -- provisionally until min(start + 90 min, 09:30) while
the session is live, until 11:00 once it completed inside that cutoff,
and not at all for a session finished later (the phone switched on at
noon completes the legs then; that must un-lock nothing).

The phone publishes the session at start as well as at finish
(current_session, cleared with the finish stamp), so the PC can tell
"started, not finished" from "never started". A required 1-5 energy tap
joins the weigh-in and ships as latest_energy with a PC reader mirroring
_weight (the shared half is _field_reader).

Also fixes the vmbox harness, red since the 2026-09-11 home-tidy: the
guest tree lives at ~/wake-alarm, and the INSTALL-OK grep matched vm's
echo of the command line. Takes Flutter 3.47.5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tp54ewdXRLaimUuT8LgYyn
2026-09-19 16:40:51 +02:00

138 lines
4.5 KiB
Dart

import 'package:flutter/material.dart';
import 'package:wake_alarm_sync/screens/energy_picker.dart';
import 'package:wake_alarm_sync/screens/session_checklist.dart';
import 'package:wake_alarm_sync/services/morning_session.dart';
/// The session screen's body: the checklist, the check-in, the clocks.
///
/// Stateless, taking plain values — the screen keeps the state and the
/// ordering, which is where the ring's bugs lived.
class SessionBody extends StatelessWidget {
/// Creates the body from already-resolved state.
const SessionBody({
required this.session,
required this.now,
required this.controller,
required this.status,
required this.energy,
required this.onSelectEnergy,
required this.onSubmitWeight,
super.key,
});
/// The session being displayed.
final MorningSession session;
/// The instant the clocks are drawn at.
final DateTime now;
/// The weigh-in field's controller.
final TextEditingController controller;
/// The status line.
final String status;
/// The tapped energy level, or null.
final int? energy;
/// Called when an energy chip is tapped.
final ValueChanged<int> onSelectEnergy;
/// Called when the user submits a weight.
final VoidCallback onSubmitWeight;
/// Whether the weigh-in field is live: first leg, nothing typed yet.
bool get _awaitingWeight =>
session.currentLeg == SessionLeg.bathroomIn &&
session.weightKg == null &&
session.pendingWeightKg == null;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final leg = session.currentLeg;
return Scaffold(
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text('Stay up', style: theme.textTheme.headlineMedium),
const SizedBox(height: 4),
Text(
'The alarm is off. It comes back if a step below is missed. '
'Leaving the app is fine — the notification brings you back.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 24),
SessionChecklist(session: session, now: now),
const SizedBox(height: 24),
if (_awaitingWeight) ...<Widget>[
EnergyPicker(selected: energy, onSelect: onSelectEnergy),
const SizedBox(height: 12),
TextField(
controller: controller,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: const InputDecoration(
labelText: 'Weight (kg)',
border: OutlineInputBorder(),
),
onSubmitted: (_) => onSubmitWeight(),
),
const SizedBox(height: 12),
FilledButton(
onPressed: onSubmitWeight,
child: const Text('Log weight'),
),
const SizedBox(height: 16),
],
if (leg != null)
Text(
_deadlineLine(leg),
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Re-rings: ${session.reRings}/${MorningSession.maxReRings}',
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 16),
if (status.isNotEmpty)
Text(
status,
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyLarge,
),
],
),
),
),
);
}
String _deadlineLine(SessionLeg leg) {
final soonest = session.earliest(leg);
if (soonest != null && now.isBefore(soonest)) {
return 'Shower — tag counts in ${_mmss(soonest.difference(now))}';
}
final deadline = session.deadline(leg);
if (now.isBefore(deadline)) {
return 'Alarm returns in ${_mmss(deadline.difference(now))}';
}
return 'Deadline passed';
}
static String _mmss(Duration d) {
final m = d.inMinutes.toString().padLeft(2, '0');
final s = (d.inSeconds % 60).toString().padLeft(2, '0');
return '$m:$s';
}
}