mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 19:27:02 +01:00
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
138 lines
4.5 KiB
Dart
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';
|
|
}
|
|
}
|