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

@@ -1,4 +1,10 @@
import '../../about/internal/application/RoutineRunRecord.dart';
abstract class ConnectRoutineRunner {
/// Earlier runs from the device log, for the summary's today and
/// streak line.
void onHistoryLoaded(List<RoutineRunRecord> records);
/// The run was recorded. [held] is false when the chain broke and the run
/// went in as partial.
void onLogged(bool held);

View File

@@ -5,6 +5,8 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:stacked/stacked.dart';
import '../../about/internal/application/RoutineRunRecord.dart';
import '../../utils/ProgressEngine.dart';
import '../../about/external/data/RoutineStep.dart';
import '../../about/internal/application/NotificationType.dart';
import '../../about/internal/application/RoutineKind.dart';
@@ -36,6 +38,8 @@ class RoutineRunnerState extends State<RoutineRunner>
implements ConnectRoutineRunner {
ViewRoutineRunner? _model;
List<RoutineRunRecord> _history = <RoutineRunRecord>[];
late RoutineRun _run;
Timer? _ticker;
@@ -107,6 +111,7 @@ class RoutineRunnerState extends State<RoutineRunner>
}
void _initiate() {
_model?.loadHistory();
_startTicker();
_syncBreath();
_model?.publishRun(_run);
@@ -1152,6 +1157,8 @@ class RoutineRunnerState extends State<RoutineRunner>
],
),
),
const SizedBox(height: 10),
_todayLine(held),
const Spacer(),
roundedCornerButton(
"Log it",
@@ -1167,8 +1174,70 @@ class RoutineRunnerState extends State<RoutineRunner>
);
}
/// Where this run leaves the day: routines held today and the daily
/// streak, counting this run as if logged.
Widget _todayLine(bool held) {
final DateTime now = DateTime.now();
final List<RoutineRunRecord> today = ProgressEngine.between(
_history,
ProgressEngine.dayOf(now),
ProgressEngine.dayOf(now).add(const Duration(days: 1)));
final List<RoutineRunRecord> withThis =
List<RoutineRunRecord>.from(_history);
if (held) {
withThis.add(RoutineRunRecord(
routineId: _run.chain.id ?? "",
title: _run.chain.title,
kind: _run.chain.kind.name,
startedAt: now,
completedSteps: 1,
totalSteps: 1,
));
}
final int heldToday = ProgressEngine.held(today) + (held ? 1 : 0);
final int streak = ProgressEngine.streak(withThis, now);
return GlassSurface(
radius: BorderRadius.zero,
tint: colorGlassStrong,
padding: const EdgeInsets.fromLTRB(18, 14, 18, 14),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text("Today", 14, TextType.Medium, color: colorInk),
const SizedBox(height: 3),
text(
"$heldToday held · streak $streak ${streak == 1 ? 'day' : 'days'}",
12,
TextType.Regular,
color: colorInkSoft,
),
],
),
),
Icon(CupertinoIcons.flame_fill,
size: 20,
color: streak > 0 ? colorOverdueWarm : colorInkSoft),
],
),
);
}
// ── ConnectRoutineRunner ──────────────────────────────────────────────────
@override
void onHistoryLoaded(List<RoutineRunRecord> records) {
setState(() {
_history = records;
});
}
@override
void onLogged(bool held) {
Navigator.pop(context, true);

View File

@@ -1,6 +1,7 @@
import '../../about/external/data/LiveSession.dart';
import '../../about/external/initial/RoutineLogRequest.dart';
import '../../about/internal/application/RoutineRun.dart';
import '../../about/internal/application/RoutineRunRecord.dart';
import '../../configs/NotificationServiceConfig.dart';
import '../parent/ParentViewModel.dart';
import 'ConnectRoutineRunner.dart';
@@ -25,6 +26,30 @@ class ViewRoutineRunner extends ParentViewModel {
));
}
void loadHistory() async {
connection.onHistoryLoaded(await getDataManager().getRunLog());
}
/// The local mirror Progress reads. Capped so the device log stays small.
Future<void> _remember(RoutineRun run) async {
final List<RoutineRunRecord> records = await getDataManager().getRunLog();
records.add(RoutineRunRecord(
routineId: run.chain.id ?? "",
title: run.chain.title,
kind: run.chain.kind.name,
startedAt: run.startedAt,
seconds: run.elapsedSeconds(),
completedSteps: run.chain.completedSteps,
totalSteps: run.chain.steps.length,
));
const int cap = 500;
await getDataManager().setRunLog(records.length > cap
? records.sublist(records.length - cap)
: records);
}
void clearRun() {
LocalNotificationEngine.cancelSessionNotification();
}
@@ -48,6 +73,8 @@ class ViewRoutineRunner extends ParentViewModel {
skippedCount: run.skippedCount,
).toJson());
await _remember(run);
clearRun();
closeLoading();