mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 18:07:05 +01:00
- Sick days screen (own name, sick icon, cat-2 fill): tap today or a later day to mark it sick, tap again to clear; past days inert. Writes the sickdays wire format (state sick/cleared + actor, one HLC per write) to sickdays-sync/devices via crdt_sync syncLog. - Mutual exclusion in both pickers: each syncs the other pool on open and refuses a date it holds, naming the pool; an unreadable pool refuses all. - Native SickDays cache (fail closed via DayCache); AlarmReceiver skips the ring with reason "sick_day" (no markFired, no ring_started); StickCheck verdict sick_day, streak skips sick days; stakes card says so. - Pre-alarm pool sync: allow-while-idle alarm at 21:00 and A-30 (request code 1006) runs a headless engine (poolSyncMain) that pulls both pools from RTDB and rewrites both native caches, so a PC mark arrives with the app closed. The ring decision stays a cache read. - JVM unit tests (JUnit 4.13.2) for DayCache, RingDecision, PoolSyncSchedule, StickCheck; Dart tests; Dart->Python interop check. pylint hook skipped: verify.py imports sickdays, not in the hook env until the sickdays-v0.1.0 tag exists; run by hand in a sickdays venv (10.00/10). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry
237 lines
8.6 KiB
Dart
237 lines
8.6 KiB
Dart
import 'dart:async';
|
|
import 'dart:developer' as developer;
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'package:wake_alarm_sync/screens/free_days_screen.dart';
|
|
import 'package:wake_alarm_sync/screens/home_body.dart';
|
|
import 'package:wake_alarm_sync/screens/settings_screen.dart';
|
|
import 'package:wake_alarm_sync/screens/sick_days_screen.dart';
|
|
import 'package:wake_alarm_sync/services/alarm_policy.dart';
|
|
import 'package:wake_alarm_sync/services/alarm_publisher.dart';
|
|
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
|
|
import 'package:wake_alarm_sync/services/alarm_sync_service.dart';
|
|
import 'package:wake_alarm_sync/services/day_pool_refresh.dart';
|
|
import 'package:wake_alarm_sync/services/firebase_backend.dart';
|
|
import 'package:wake_alarm_sync/services/sync_settings.dart';
|
|
|
|
/// Main screen: displays the current alarm time and syncs it to the PC via
|
|
/// the shared crdt_sync transport.
|
|
class HomeScreen extends StatefulWidget {
|
|
/// Creates a [HomeScreen].
|
|
const HomeScreen({super.key});
|
|
|
|
@override
|
|
State<HomeScreen> createState() => _HomeScreenState();
|
|
}
|
|
|
|
class _HomeScreenState extends State<HomeScreen> {
|
|
/// The next alarm: the day's ramp baseline, or a one-shot earlier start.
|
|
AlarmPolicy _policy = AlarmPolicy.resolve(DateTime.now(), null);
|
|
int get _hour => _policy.hour;
|
|
int get _minute => _policy.minute;
|
|
bool _busy = false;
|
|
String _status = '';
|
|
bool _configured = false;
|
|
int _syncEpoch = 0;
|
|
AlarmSyncService? _service;
|
|
|
|
/// Platform bridge that arms the app's own alarm (item 1).
|
|
final AlarmPublisher _publisher = AlarmPublisher();
|
|
|
|
/// Platform bridge used to arm the fixed schedule.
|
|
final AlarmScheduler _scheduler = AlarmScheduler();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// STRICTLY SEQUENTIAL. These three used to run concurrently, and the
|
|
// last one reads what the first two write: `publish` takes the service as
|
|
// a *parameter*, so it captured `_service` while it was still null and
|
|
// every startup publish returned "connect a backend" while a backend was
|
|
// connected. `_applyPolicy` has the same shape -- publishing before it
|
|
// resolves would send the baseline instead of an active override.
|
|
unawaited(_bootstrapSync());
|
|
}
|
|
|
|
/// Resolves the policy and the sync store, then publishes -- in that order.
|
|
///
|
|
/// Publishing to the PC is automatic: it used to require the same tap that
|
|
/// armed the alarm, so a forgotten tap left the PC on its fallback.
|
|
Future<void> _bootstrapSync() async {
|
|
await _applyPolicy();
|
|
await _loadSyncState();
|
|
await _syncToPc();
|
|
// Last, and silent: free and sick days marked on the PC reach the
|
|
// native ring path here; an unreadable pool means the alarm rings.
|
|
await refreshDayPools();
|
|
}
|
|
|
|
/// Opens the sync store and records whether a backend is connected.
|
|
///
|
|
/// No longer loads a *time*: the schedule is baked in and owned by
|
|
/// [AlarmPolicy], so a stored value could only disagree with it. This exists
|
|
/// to supply the store for publishing and to drive the "connect a backend"
|
|
/// hint.
|
|
Future<void> _loadSyncState() async {
|
|
final SyncSettings settings;
|
|
final AlarmSyncService service;
|
|
try {
|
|
settings = await SyncSettings.load();
|
|
service = await AlarmSyncService.open();
|
|
} on Exception catch (e, stack) {
|
|
// Storage/plugins unavailable (e.g. under a widget test, or a keystore
|
|
// hiccup): keep the on-screen defaults rather than crashing. Logged
|
|
// rather than swallowed -- silence here is what made a broken store
|
|
// look like an unconfigured backend for weeks.
|
|
developer.log(
|
|
'sync store unavailable: $e',
|
|
name: 'WakeAlarmSync',
|
|
error: e,
|
|
stackTrace: stack,
|
|
);
|
|
return;
|
|
}
|
|
// Either backend counts: a device connected only to Firebase is fully
|
|
// configured, and gating on the GitHub token alone would lock it out of
|
|
// syncing entirely once the mirror is retired.
|
|
final firebaseAccount = await loadAccount();
|
|
if (!mounted) return;
|
|
_service = service;
|
|
setState(
|
|
() => _configured = settings.isConfigured || firebaseAccount != null,
|
|
);
|
|
}
|
|
|
|
/// Lets the user move **tomorrow's** alarm earlier, never later.
|
|
///
|
|
/// This is the only schedule edit the app offers. Earlier-only is what makes
|
|
/// it tamper-proof: every change available costs the user sleep, so there is
|
|
/// no lever a half-asleep user can pull to stay in bed. A later time, a
|
|
/// skipped day and an off switch are simply not expressible.
|
|
Future<void> _pickEarlier() async {
|
|
final current = _fmt(_hour, _minute);
|
|
final picked = await showTimePicker(
|
|
context: context,
|
|
initialTime: TimeOfDay(hour: _hour, minute: _minute),
|
|
helpText: 'Earlier than $current only',
|
|
);
|
|
if (picked == null) return;
|
|
final accepted = await AlarmPolicy.setEarlier(picked.hour, picked.minute);
|
|
if (!mounted) return;
|
|
if (!accepted) {
|
|
setState(
|
|
() => _status =
|
|
'That is not earlier than $current. The alarm can only be '
|
|
'moved earlier.',
|
|
);
|
|
return;
|
|
}
|
|
await _applyPolicy();
|
|
if (!mounted) return;
|
|
setState(() => _status = 'Armed for ${_fmt(_hour, _minute)}.');
|
|
// The PC has to learn about the new time too. Without this the only
|
|
// publish attempt was the one at startup, so an alarm moved earlier here
|
|
// never reached the PC at all.
|
|
await _syncToPc();
|
|
}
|
|
|
|
/// Re-reads the policy and arms the alarm to match it.
|
|
Future<void> _applyPolicy() async {
|
|
final policy = await AlarmPolicy.load();
|
|
await _scheduler.armSchedule(policy.hour, policy.minute);
|
|
if (!mounted) return;
|
|
setState(() => _policy = policy);
|
|
}
|
|
|
|
Future<void> _syncToPc() async {
|
|
setState(() {
|
|
_busy = true;
|
|
_status = 'Syncing to PC…';
|
|
});
|
|
final message = await _publisher.publish(
|
|
service: _service,
|
|
hour: _hour,
|
|
minute: _minute,
|
|
);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_status = message;
|
|
_busy = false;
|
|
_syncEpoch++;
|
|
});
|
|
}
|
|
|
|
/// The baseline time, shown when a one-shot override is active.
|
|
String get _baseline => _fmt(_policy.baselineHour, _policy.baselineMinute);
|
|
String _fmt(int h, int m) =>
|
|
'${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}';
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Wake Alarm Sync'),
|
|
actions: [
|
|
IconButton(
|
|
icon: Icon(
|
|
Icons.event_available,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
tooltip: 'Free days',
|
|
onPressed: () => Navigator.push<void>(
|
|
context,
|
|
MaterialPageRoute<void>(builder: (_) => const FreeDaysScreen()),
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: Icon(
|
|
Icons.sick_outlined,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
tooltip: 'Sick days',
|
|
onPressed: () => Navigator.push<void>(
|
|
context,
|
|
MaterialPageRoute<void>(builder: (_) => const SickDaysScreen()),
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: Icon(
|
|
Icons.settings,
|
|
// Dimmed relative to the adjacent title text (rule 28).
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
onPressed: () async {
|
|
await Navigator.push<void>(
|
|
context,
|
|
MaterialPageRoute<void>(builder: (_) => const SettingsScreen()),
|
|
);
|
|
// Publish too, not just reload: a tag registered in Settings
|
|
// reaches the backend now, not on the next cold start.
|
|
await _loadSyncState();
|
|
await _syncToPc();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
// SafeArea + scroll. This was a bare Center/Padding/Column, which
|
|
// cannot scroll, so the content simply overflowed with the black-and-
|
|
// yellow stripe pattern painted over the primary control. Two ways that
|
|
// happened: phone landscape leaves only ~360px of body height for ~405px
|
|
// of content, and the status line renders an unbounded raw
|
|
// `'Error: \$e'` with no maxLines, which overflows even in portrait.
|
|
body: HomeBody(
|
|
time: _fmt(_hour, _minute),
|
|
baseline: _baseline,
|
|
isOverridden: _policy.isOverridden,
|
|
busy: _busy,
|
|
status: _status,
|
|
configured: _configured,
|
|
syncEpoch: _syncEpoch,
|
|
onPickEarlier: () => unawaited(_pickEarlier()),
|
|
onEscaped: () => unawaited(_syncToPc()),
|
|
),
|
|
);
|
|
}
|
|
}
|