Add Progress over time and a targets and reminders setup

Progress gives a Day/Week/Month view of routines held against a weekly
target, compared with the same point of the previous period, with active
time and the daily streak. Targets & reminders sets routines and minutes
per week and schedules a heads-up before each routine. The runner's summary
now shows today's held count and streak. Finished runs are mirrored to a
device run log that feeds both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
This commit is contained in:
alvocool
2026-11-08 20:17:58 +03:00
parent 4b71257429
commit add94f621c
26 changed files with 1590 additions and 4 deletions

View File

@@ -2,9 +2,12 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/data/latest.dart' as timezones;
import 'package:timezone/timezone.dart' as tz;
import '../about/external/data/Commitment.dart';
import '../about/external/data/LiveSession.dart';
import '../about/external/data/RoutineChain.dart';
import '../about/internal/application/CommitmentClass.dart';
import '../about/internal/application/EscalationTier.dart';
import '../about/internal/application/ToneLevel.dart';
@@ -209,6 +212,72 @@ class LocalNotificationEngine {
);
}
/// Routine reminders occupy ids [reminderBase, reminderBase + reminderSlots).
static const int reminderBase = 7000;
static const int reminderSlots = 200;
/// One-shot reminders for every scheduled routine over the next seven days,
/// [leadMinutes] before its start. One-shots rather than repeating rules so
/// no device time-zone lookup is needed: each fire time is an absolute
/// instant. Home re-places them on every open, so the week rolls forward.
static Future<void> scheduleRoutineReminders(
List<RoutineChain> routines, int leadMinutes) async {
if (kIsWeb) {
return;
}
await init();
await cancelRoutineReminders();
timezones.initializeTimeZones();
final DateTime now = DateTime.now();
final DateTime today = DateTime(now.year, now.month, now.day);
int slot = 0;
for (int offset = 0; offset < 7; offset++) {
final DateTime day = today.add(Duration(days: offset));
for (RoutineChain routine in routines) {
if (!routine.days.contains(day.weekday) || routine.startMinute < 0) {
continue;
}
final DateTime fireAt = day.add(
Duration(minutes: routine.startMinute - leadMinutes));
if (!fireAt.isAfter(now) || slot >= reminderSlots) {
continue;
}
await plugin.zonedSchedule(
id: reminderBase + slot,
title: routine.title,
body: leadMinutes == 0
? "Starting now."
: "Starts in $leadMinutes minutes.",
scheduledDate: tz.TZDateTime.from(fireAt, tz.UTC),
notificationDetails: const NotificationDetails(
android: AndroidNotificationDetails(reminderChannel, 'Reminders',
category: AndroidNotificationCategory.reminder),
iOS: DarwinNotificationDetails(),
),
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
payload: routine.id,
);
slot = slot + 1;
}
}
}
static Future<void> cancelRoutineReminders() async {
if (kIsWeb) {
return;
}
await init();
for (int index = 0; index < reminderSlots; index++) {
await plugin.cancel(id: reminderBase + index);
}
}
static Future<void> cancelAll() async {
// The browser has no notification channels; the runner works without.
if (kIsWeb) {