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:
@@ -109,3 +109,19 @@ goals and a chat.
|
|||||||
(`designs/MemberAvatar.dart`): square painted portraits with the initial.
|
(`designs/MemberAvatar.dart`): square painted portraits with the initial.
|
||||||
- Groups are reached from the nav (Groups tab), the Home "Your groups" rail
|
- Groups are reached from the nav (Groups tab), the Home "Your groups" rail
|
||||||
(tap opens the chat) and the add sheet.
|
(tap opens the chat) and the add sheet.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
`see/progress/`: progress over time, reached from the nav (Progress tab).
|
||||||
|
|
||||||
|
- A Day / Week / Month switch over one white card: routines held against
|
||||||
|
the target, a change chip measured against the *same point* of the
|
||||||
|
previous period, and bars (days of the week, weeks of the month) with the
|
||||||
|
current one in night and future ones as stubs. Day shows a meter instead.
|
||||||
|
- Two tiles below: active time against its target, and the daily streak.
|
||||||
|
- Runs come from the device's run log (`RoutineRunRecord`), written when the
|
||||||
|
routine runner logs. Targets are local (`ProgressTargets`).
|
||||||
|
- **Targets** (`TargetSetup`) is the only setup screen: routines per week,
|
||||||
|
active minutes per week and reminders before scheduled routines. Until it
|
||||||
|
is saved once, Progress measures against the schedule and shows a night
|
||||||
|
"Set your weekly target" prompt. Targets never touch debt.
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/// The span the Progress screen measures.
|
||||||
|
enum ProgressPeriod { Day, Week, Month }
|
||||||
|
|
||||||
|
String periodLabel(ProgressPeriod value) {
|
||||||
|
switch (value) {
|
||||||
|
case ProgressPeriod.Day:
|
||||||
|
return "Day";
|
||||||
|
case ProgressPeriod.Week:
|
||||||
|
return "Week";
|
||||||
|
case ProgressPeriod.Month:
|
||||||
|
return "Month";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/// What a good week looks like, set by the user on the Targets screen.
|
||||||
|
/// Progress measures against these; reminders fire before scheduled routines.
|
||||||
|
class ProgressTargets {
|
||||||
|
/// Routines held (every step done) per week.
|
||||||
|
int routinesPerWeek;
|
||||||
|
|
||||||
|
/// Minutes spent inside routines per week, held or not.
|
||||||
|
int minutesPerWeek;
|
||||||
|
|
||||||
|
bool reminders;
|
||||||
|
|
||||||
|
/// How long before a routine's start the reminder fires.
|
||||||
|
int reminderLead;
|
||||||
|
|
||||||
|
/// False until the user has saved the Targets screen once, so Progress can
|
||||||
|
/// ask for a target instead of silently using the defaults.
|
||||||
|
bool set;
|
||||||
|
|
||||||
|
ProgressTargets({
|
||||||
|
this.routinesPerWeek = 5,
|
||||||
|
this.minutesPerWeek = 150,
|
||||||
|
this.reminders = false,
|
||||||
|
this.reminderLead = 10,
|
||||||
|
this.set = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ProgressTargets.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ProgressTargets(
|
||||||
|
routinesPerWeek: json['routinesPerWeek'] ?? 5,
|
||||||
|
minutesPerWeek: json['minutesPerWeek'] ?? 150,
|
||||||
|
reminders: json['reminders'] ?? false,
|
||||||
|
reminderLead: json['reminderLead'] ?? 10,
|
||||||
|
set: json['set'] ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final Map<String, dynamic> data = <String, dynamic>{};
|
||||||
|
data['routinesPerWeek'] = routinesPerWeek;
|
||||||
|
data['minutesPerWeek'] = minutesPerWeek;
|
||||||
|
data['reminders'] = reminders;
|
||||||
|
data['reminderLead'] = reminderLead;
|
||||||
|
data['set'] = set;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/// One finished run of a routine, kept on the device so Progress can draw the
|
||||||
|
/// days and weeks without waiting on the network. The server keeps its own
|
||||||
|
/// log through `Routine/Log`; this is the local mirror.
|
||||||
|
class RoutineRunRecord {
|
||||||
|
String routineId;
|
||||||
|
|
||||||
|
String title;
|
||||||
|
|
||||||
|
/// [RoutineKind] name.
|
||||||
|
String kind;
|
||||||
|
|
||||||
|
DateTime startedAt;
|
||||||
|
|
||||||
|
int seconds;
|
||||||
|
|
||||||
|
int completedSteps;
|
||||||
|
|
||||||
|
int totalSteps;
|
||||||
|
|
||||||
|
RoutineRunRecord({
|
||||||
|
required this.routineId,
|
||||||
|
required this.title,
|
||||||
|
required this.kind,
|
||||||
|
required this.startedAt,
|
||||||
|
this.seconds = 0,
|
||||||
|
this.completedSteps = 0,
|
||||||
|
this.totalSteps = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Every step, in order. Partial runs still count toward active time.
|
||||||
|
bool get held => totalSteps > 0 && completedSteps >= totalSteps;
|
||||||
|
|
||||||
|
factory RoutineRunRecord.fromJson(Map<String, dynamic> json) {
|
||||||
|
return RoutineRunRecord(
|
||||||
|
routineId: json['routineId'] ?? '',
|
||||||
|
title: json['title'] ?? '',
|
||||||
|
kind: json['kind'] ?? '',
|
||||||
|
startedAt: DateTime.tryParse(json['startedAt'] ?? '') ?? DateTime.now(),
|
||||||
|
seconds: json['seconds'] ?? 0,
|
||||||
|
completedSteps: json['completedSteps'] ?? 0,
|
||||||
|
totalSteps: json['totalSteps'] ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final Map<String, dynamic> data = <String, dynamic>{};
|
||||||
|
data['routineId'] = routineId;
|
||||||
|
data['title'] = title;
|
||||||
|
data['kind'] = kind;
|
||||||
|
data['startedAt'] = startedAt.toIso8601String();
|
||||||
|
data['seconds'] = seconds;
|
||||||
|
data['completedSteps'] = completedSteps;
|
||||||
|
data['totalSteps'] = totalSteps;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,12 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.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/Commitment.dart';
|
||||||
import '../about/external/data/LiveSession.dart';
|
import '../about/external/data/LiveSession.dart';
|
||||||
|
import '../about/external/data/RoutineChain.dart';
|
||||||
import '../about/internal/application/CommitmentClass.dart';
|
import '../about/internal/application/CommitmentClass.dart';
|
||||||
import '../about/internal/application/EscalationTier.dart';
|
import '../about/internal/application/EscalationTier.dart';
|
||||||
import '../about/internal/application/ToneLevel.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 {
|
static Future<void> cancelAll() async {
|
||||||
// The browser has no notification channels; the runner works without.
|
// The browser has no notification channels; the runner works without.
|
||||||
if (kIsWeb) {
|
if (kIsWeb) {
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import '../about/external/initial/SessionLogRequest.dart';
|
|||||||
import '../about/external/initial/SickModeRequest.dart';
|
import '../about/external/initial/SickModeRequest.dart';
|
||||||
import '../about/external/initial/ToneRequest.dart';
|
import '../about/external/initial/ToneRequest.dart';
|
||||||
import '../about/internal/application/CapacityProfile.dart';
|
import '../about/internal/application/CapacityProfile.dart';
|
||||||
|
import '../about/internal/application/ProgressTargets.dart';
|
||||||
|
import '../about/internal/application/RoutineRunRecord.dart';
|
||||||
import '../about/internal/application/TodoItem.dart';
|
import '../about/internal/application/TodoItem.dart';
|
||||||
import '../about/internal/application/MeDescription.dart';
|
import '../about/internal/application/MeDescription.dart';
|
||||||
import '../about/internal/application/Token.dart';
|
import '../about/internal/application/Token.dart';
|
||||||
@@ -132,6 +134,21 @@ class AppDataManager implements DataManager {
|
|||||||
@override
|
@override
|
||||||
Future setTodos(List<TodoItem> todos) => memory.setTodos(todos);
|
Future setTodos(List<TodoItem> todos) => memory.setTodos(todos);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<RoutineRunRecord>> getRunLog() => memory.getRunLog();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future setRunLog(List<RoutineRunRecord> records) =>
|
||||||
|
memory.setRunLog(records);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ProgressTargets> getProgressTargets() =>
|
||||||
|
memory.getProgressTargets();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future setProgressTargets(ProgressTargets targets) =>
|
||||||
|
memory.setProgressTargets(targets);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> showOnboarding() => memory.showOnboarding();
|
Future<bool> showOnboarding() => memory.showOnboarding();
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import '../about/external/data/Program.dart';
|
|||||||
import '../about/external/data/SystemResponse.dart';
|
import '../about/external/data/SystemResponse.dart';
|
||||||
import '../about/internal/application/CapacityProfile.dart';
|
import '../about/internal/application/CapacityProfile.dart';
|
||||||
import '../about/internal/application/MeDescription.dart';
|
import '../about/internal/application/MeDescription.dart';
|
||||||
|
import '../about/internal/application/ProgressTargets.dart';
|
||||||
|
import '../about/internal/application/RoutineRunRecord.dart';
|
||||||
import '../about/internal/application/TodoItem.dart';
|
import '../about/internal/application/TodoItem.dart';
|
||||||
import '../about/internal/application/Token.dart';
|
import '../about/internal/application/Token.dart';
|
||||||
import '../about/internal/application/UserDetails.dart';
|
import '../about/internal/application/UserDetails.dart';
|
||||||
@@ -65,6 +67,16 @@ abstract class ConnectInternalMemory {
|
|||||||
|
|
||||||
Future setTodos(List<TodoItem> todos);
|
Future setTodos(List<TodoItem> todos);
|
||||||
|
|
||||||
|
/// Finished routine runs, newest last. Feeds Progress.
|
||||||
|
Future<List<RoutineRunRecord>> getRunLog();
|
||||||
|
|
||||||
|
Future setRunLog(List<RoutineRunRecord> records);
|
||||||
|
|
||||||
|
/// Weekly targets and reminder preferences.
|
||||||
|
Future<ProgressTargets> getProgressTargets();
|
||||||
|
|
||||||
|
Future setProgressTargets(ProgressTargets targets);
|
||||||
|
|
||||||
Future<bool> showOnboarding();
|
Future<bool> showOnboarding();
|
||||||
|
|
||||||
Future setOnboardingOption(bool value);
|
Future setOnboardingOption(bool value);
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import '../about/external/data/ResponseState.dart';
|
|||||||
import '../about/external/data/SystemResponse.dart';
|
import '../about/external/data/SystemResponse.dart';
|
||||||
import '../about/internal/application/CapacityProfile.dart';
|
import '../about/internal/application/CapacityProfile.dart';
|
||||||
import '../about/internal/application/MeDescription.dart';
|
import '../about/internal/application/MeDescription.dart';
|
||||||
|
import '../about/internal/application/ProgressTargets.dart';
|
||||||
|
import '../about/internal/application/RoutineRunRecord.dart';
|
||||||
import '../about/internal/application/TodoItem.dart';
|
import '../about/internal/application/TodoItem.dart';
|
||||||
import '../about/internal/application/Token.dart';
|
import '../about/internal/application/Token.dart';
|
||||||
import '../about/internal/application/UserDetails.dart';
|
import '../about/internal/application/UserDetails.dart';
|
||||||
@@ -42,6 +44,8 @@ class InternalMemory implements ConnectInternalMemory {
|
|||||||
static const String AMNESTY_SPENT = "AMNESTY_SPENT_Jr4xVh9KmT3gLc6B";
|
static const String AMNESTY_SPENT = "AMNESTY_SPENT_Jr4xVh9KmT3gLc6B";
|
||||||
|
|
||||||
static const String TODOS = "TODOS_Wm3kRb8TqZ5nHc2F";
|
static const String TODOS = "TODOS_Wm3kRb8TqZ5nHc2F";
|
||||||
|
static const String RUN_LOG = "RUN_LOG_p8Xq2LbV7nZr4KcT";
|
||||||
|
static const String PROGRESS_TARGETS = "PROGRESS_TARGETS_Hd5sWm9JqR3vYx6B";
|
||||||
|
|
||||||
static const String SHOW_ONBOARDING = "SHOW_ONBOARDING_Nz6qGf2SdX7wPb4M";
|
static const String SHOW_ONBOARDING = "SHOW_ONBOARDING_Nz6qGf2SdX7wPb4M";
|
||||||
|
|
||||||
@@ -275,6 +279,44 @@ class InternalMemory implements ConnectInternalMemory {
|
|||||||
value: jsonEncode(todos.map((item) => item.toJson()).toList()));
|
value: jsonEncode(todos.map((item) => item.toJson()).toList()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<RoutineRunRecord>> getRunLog() async {
|
||||||
|
String? value = await groundedStorage.read(key: RUN_LOG);
|
||||||
|
|
||||||
|
if (value != null) {
|
||||||
|
return (jsonDecode(value) as List<dynamic>)
|
||||||
|
.map((item) =>
|
||||||
|
RoutineRunRecord.fromJson(item as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return <RoutineRunRecord>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future setRunLog(List<RoutineRunRecord> records) async {
|
||||||
|
await groundedStorage.write(
|
||||||
|
key: RUN_LOG,
|
||||||
|
value: jsonEncode(records.map((item) => item.toJson()).toList()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ProgressTargets> getProgressTargets() async {
|
||||||
|
String? value = await groundedStorage.read(key: PROGRESS_TARGETS);
|
||||||
|
|
||||||
|
if (value != null) {
|
||||||
|
return ProgressTargets.fromJson(jsonDecode(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProgressTargets();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future setProgressTargets(ProgressTargets targets) async {
|
||||||
|
await groundedStorage.write(
|
||||||
|
key: PROGRESS_TARGETS, value: jsonEncode(targets.toJson()));
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> showOnboarding() async {
|
Future<bool> showOnboarding() async {
|
||||||
String? value = await groundedStorage.read(key: SHOW_ONBOARDING);
|
String? value = await groundedStorage.read(key: SHOW_ONBOARDING);
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
// MOCK DATA — TEMPORARY. See MockSwitch.dart.
|
// MOCK DATA — TEMPORARY. See MockSwitch.dart.
|
||||||
|
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
import '../about/internal/application/MeDescription.dart';
|
import '../about/internal/application/MeDescription.dart';
|
||||||
|
import '../about/internal/application/RoutineRunRecord.dart';
|
||||||
import '../about/internal/application/TodoItem.dart';
|
import '../about/internal/application/TodoItem.dart';
|
||||||
import '../about/internal/application/UserDetails.dart';
|
import '../about/internal/application/UserDetails.dart';
|
||||||
import '../memory/InternalMemory.dart';
|
import '../memory/InternalMemory.dart';
|
||||||
@@ -24,9 +27,79 @@ class MockMemory extends InternalMemory {
|
|||||||
TodoItem(id: "t2", title: "Book physio for Thursday"),
|
TodoItem(id: "t2", title: "Book physio for Thursday"),
|
||||||
TodoItem(id: "t3", title: "Reply to Sam about Saturday", done: true),
|
TodoItem(id: "t3", title: "Reply to Sam about Saturday", done: true),
|
||||||
]);
|
]);
|
||||||
|
setRunLog(_history());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Six weeks of runs against the seeded routines, mostly held, with the
|
||||||
|
/// odd broken chain and skipped day, so Progress has a shape to draw.
|
||||||
|
static List<RoutineRunRecord> _history() {
|
||||||
|
final Random random = Random(11);
|
||||||
|
final DateTime now = DateTime.now();
|
||||||
|
final DateTime today = DateTime(now.year, now.month, now.day);
|
||||||
|
final List<RoutineRunRecord> records = <RoutineRunRecord>[];
|
||||||
|
|
||||||
|
void add(DateTime day, String id, String title, String kind, int minute,
|
||||||
|
int seconds, int steps, double holdRate) {
|
||||||
|
final DateTime at = day.add(Duration(minutes: minute));
|
||||||
|
if (at.isAfter(now)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final bool held = random.nextDouble() < holdRate;
|
||||||
|
records.add(RoutineRunRecord(
|
||||||
|
routineId: id,
|
||||||
|
title: title,
|
||||||
|
kind: kind,
|
||||||
|
startedAt: at,
|
||||||
|
seconds: (seconds * (held ? 1 : 0.5) * (0.85 + random.nextDouble() * 0.3))
|
||||||
|
.round(),
|
||||||
|
completedSteps: held ? steps : 1 + random.nextInt(steps - 1),
|
||||||
|
totalSteps: steps,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int ago = 42; ago >= 1; ago--) {
|
||||||
|
final DateTime day = today.subtract(Duration(days: ago));
|
||||||
|
final int weekday = day.weekday;
|
||||||
|
|
||||||
|
// A quiet day now and then: nothing run at all.
|
||||||
|
if (random.nextDouble() < 0.12) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (weekday <= 5) {
|
||||||
|
add(day, "routine-morning", "Morning non-negotiables", "MustDo",
|
||||||
|
7 * 60 + 15, 14 * 60, 4, 0.85);
|
||||||
|
add(day, "routine-shutdown", "Evening shutdown", "MustDo", 18 * 60,
|
||||||
|
9 * 60, 4, 0.6);
|
||||||
|
}
|
||||||
|
if (weekday == 1 || weekday == 3 || weekday == 5) {
|
||||||
|
add(day, "routine-strength", "Full body strength", "Workout",
|
||||||
|
17 * 60 + 30, 44 * 60, 6, 0.75);
|
||||||
|
}
|
||||||
|
if (weekday >= 6) {
|
||||||
|
add(day, "routine-mobility", "Mobility flow", "Workout", 10 * 60,
|
||||||
|
22 * 60, 5, 0.8);
|
||||||
|
}
|
||||||
|
if (random.nextDouble() < 0.35) {
|
||||||
|
add(day, "routine-breathing", "Box breathing", "Meditation", 21 * 60,
|
||||||
|
8 * 60, 3, 0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Today: the morning chain is already held in the seeded routines.
|
||||||
|
records.add(RoutineRunRecord(
|
||||||
|
routineId: "routine-morning",
|
||||||
|
title: "Morning non-negotiables",
|
||||||
|
kind: "MustDo",
|
||||||
|
startedAt: today.add(const Duration(minutes: 7 * 60 + 10)),
|
||||||
|
seconds: 13 * 60 + 40,
|
||||||
|
completedSteps: 4,
|
||||||
|
totalSteps: 4,
|
||||||
|
));
|
||||||
|
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
FlutterSecureStorage get groundedStorage => const MockStorage();
|
FlutterSecureStorage get groundedStorage => const MockStorage();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import '../../utils/ToneEngine.dart';
|
|||||||
import '../commitment/NewCommitment.dart';
|
import '../commitment/NewCommitment.dart';
|
||||||
import '../excuse/ExcuseReport.dart';
|
import '../excuse/ExcuseReport.dart';
|
||||||
import '../goal/Goals.dart';
|
import '../goal/Goals.dart';
|
||||||
|
import '../progress/Progress.dart';
|
||||||
import '../group/GroupChat.dart';
|
import '../group/GroupChat.dart';
|
||||||
import '../group/GroupEditor.dart';
|
import '../group/GroupEditor.dart';
|
||||||
import '../group/Groups.dart';
|
import '../group/Groups.dart';
|
||||||
@@ -182,6 +183,10 @@ class HomeState extends State<Home> implements ConnectHome {
|
|||||||
_model?.tick(item);
|
_model?.tick(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onOpenProgress() {
|
||||||
|
GroundedNavigation().navigateToPageWithData(const Progress(), context);
|
||||||
|
}
|
||||||
|
|
||||||
void _onOpenReportCard() {
|
void _onOpenReportCard() {
|
||||||
GroundedNavigation().navigateToPage(
|
GroundedNavigation().navigateToPage(
|
||||||
NavigatorType.justOpen, const ReportCardScreen(), context);
|
NavigatorType.justOpen, const ReportCardScreen(), context);
|
||||||
@@ -445,8 +450,8 @@ class HomeState extends State<Home> implements ConnectHome {
|
|||||||
),
|
),
|
||||||
(
|
(
|
||||||
icon: CupertinoIcons.chart_bar_alt_fill,
|
icon: CupertinoIcons.chart_bar_alt_fill,
|
||||||
label: "Insights",
|
label: "Progress",
|
||||||
onTap: _onOpenReportCard
|
onTap: _onOpenProgress
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
).animate().fadeIn(duration: 400.ms, delay: 250.ms).slideY(
|
).animate().fadeIn(duration: 400.ms, delay: 250.ms).slideY(
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ import '../../about/external/data/pages/response/CommitmentPage.dart';
|
|||||||
import '../../about/external/initial/CompletionRequest.dart';
|
import '../../about/external/initial/CompletionRequest.dart';
|
||||||
import '../../about/external/initial/ReportCardRequest.dart';
|
import '../../about/external/initial/ReportCardRequest.dart';
|
||||||
import '../../about/internal/application/CommitmentClass.dart';
|
import '../../about/internal/application/CommitmentClass.dart';
|
||||||
|
import '../../about/internal/application/ProgressTargets.dart';
|
||||||
import '../../about/internal/application/Standing.dart';
|
import '../../about/internal/application/Standing.dart';
|
||||||
import '../../about/internal/application/TodoItem.dart';
|
import '../../about/internal/application/TodoItem.dart';
|
||||||
import '../../about/internal/application/UserDetails.dart';
|
import '../../about/internal/application/UserDetails.dart';
|
||||||
|
import '../../configs/NotificationServiceConfig.dart';
|
||||||
import '../../utils/DebtEngine.dart';
|
import '../../utils/DebtEngine.dart';
|
||||||
import '../../utils/GuardrailEngine.dart';
|
import '../../utils/GuardrailEngine.dart';
|
||||||
import '../../utils/ObjectConvertors.dart';
|
import '../../utils/ObjectConvertors.dart';
|
||||||
@@ -125,12 +127,27 @@ class ViewHome extends ParentViewModel {
|
|||||||
),
|
),
|
||||||
));
|
));
|
||||||
|
|
||||||
connection.onRoutinesLoaded(getRoutineChainList(response.data));
|
final List<RoutineChain> routines = getRoutineChainList(response.data);
|
||||||
|
|
||||||
|
connection.onRoutinesLoaded(routines);
|
||||||
|
|
||||||
|
_placeReminders(routines);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
connection.onRoutinesLoaded(<RoutineChain>[]);
|
connection.onRoutinesLoaded(<RoutineChain>[]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reminders are one-shots for the coming week, so they are re-placed
|
||||||
|
/// whenever the schedule is read.
|
||||||
|
void _placeReminders(List<RoutineChain> routines) async {
|
||||||
|
final ProgressTargets targets =
|
||||||
|
await getDataManager().getProgressTargets();
|
||||||
|
if (targets.reminders) {
|
||||||
|
await LocalNotificationEngine.scheduleRoutineReminders(
|
||||||
|
routines, targets.reminderLead);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void loadOverdue() async {
|
void loadOverdue() async {
|
||||||
try {
|
try {
|
||||||
final response = await getDataManager().getOverdueQueue(HistoryRequest(
|
final response = await getDataManager().getOverdueQueue(HistoryRequest(
|
||||||
|
|||||||
7
frontend/lib/Grounded/see/progress/ConnectProgress.dart
Normal file
7
frontend/lib/Grounded/see/progress/ConnectProgress.dart
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import '../../about/internal/application/ProgressTargets.dart';
|
||||||
|
import '../../about/internal/application/RoutineRunRecord.dart';
|
||||||
|
|
||||||
|
abstract class ConnectProgress {
|
||||||
|
void onProgressLoaded(
|
||||||
|
List<RoutineRunRecord> records, ProgressTargets targets);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import '../../about/internal/application/ProgressTargets.dart';
|
||||||
|
|
||||||
|
abstract class ConnectTargetSetup {
|
||||||
|
/// [scheduledPerWeek] is how many routine slots the current schedule
|
||||||
|
/// already asks for, so the target can be matched to it.
|
||||||
|
void onTargetsLoaded(ProgressTargets targets, int scheduledPerWeek);
|
||||||
|
|
||||||
|
void onTargetsSaved();
|
||||||
|
}
|
||||||
10
frontend/lib/Grounded/see/progress/Progress.dart
Normal file
10
frontend/lib/Grounded/see/progress/Progress.dart
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'ProgressState.dart';
|
||||||
|
|
||||||
|
class Progress extends StatefulWidget {
|
||||||
|
const Progress({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<Progress> createState() => ProgressState();
|
||||||
|
}
|
||||||
474
frontend/lib/Grounded/see/progress/ProgressState.dart
Normal file
474
frontend/lib/Grounded/see/progress/ProgressState.dart
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:stacked/stacked.dart';
|
||||||
|
|
||||||
|
import '../../about/internal/application/ProgressPeriod.dart';
|
||||||
|
import '../../about/internal/application/ProgressTargets.dart';
|
||||||
|
import '../../about/internal/application/RoutineKind.dart';
|
||||||
|
import '../../about/internal/application/RoutineRunRecord.dart';
|
||||||
|
import '../../about/internal/application/TextType.dart';
|
||||||
|
import '../../configs/Navigator.dart';
|
||||||
|
import '../../designs/Component.dart';
|
||||||
|
import '../../designs/Responsive.dart';
|
||||||
|
import '../../designs/Shell.dart';
|
||||||
|
import '../../designs/buttons/Buttons.dart';
|
||||||
|
import '../../designs/text/Text.dart';
|
||||||
|
import '../../utils/Colors.dart';
|
||||||
|
import '../../utils/CommonUtils.dart';
|
||||||
|
import '../../utils/ProgressEngine.dart';
|
||||||
|
import '../reportcard/ReportCardScreen.dart';
|
||||||
|
import 'ConnectProgress.dart';
|
||||||
|
import 'Progress.dart';
|
||||||
|
import 'TargetSetup.dart';
|
||||||
|
import 'ViewProgress.dart';
|
||||||
|
|
||||||
|
class ProgressState extends State<Progress> implements ConnectProgress {
|
||||||
|
ViewProgress? _model;
|
||||||
|
|
||||||
|
List<RoutineRunRecord> _records = <RoutineRunRecord>[];
|
||||||
|
|
||||||
|
ProgressTargets _targets = ProgressTargets();
|
||||||
|
|
||||||
|
ProgressPeriod _period = ProgressPeriod.Week;
|
||||||
|
|
||||||
|
bool _loaded = false;
|
||||||
|
|
||||||
|
static const List<String> _weekLetters = <String>[
|
||||||
|
"M", "T", "W", "T", "F", "S", "S"
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ViewModelBuilder<ViewProgress>.reactive(
|
||||||
|
viewModelBuilder: () => ViewProgress(context, this),
|
||||||
|
onViewModelReady: (viewModel) {
|
||||||
|
_model = viewModel;
|
||||||
|
_initiate();
|
||||||
|
},
|
||||||
|
builder: (context, viewModel, child) => LayoutBuilder(
|
||||||
|
builder: (BuildContext context, BoxConstraints constraints) {
|
||||||
|
return Responsive(
|
||||||
|
mobile: _mobileView(constraints),
|
||||||
|
tablet: _mobileView(constraints),
|
||||||
|
desktop: _mobileView(constraints),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initiate() {
|
||||||
|
_model?.initialise();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void _onBack() {
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPeriodSelected(ProgressPeriod period) {
|
||||||
|
setState(() {
|
||||||
|
_period = period;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onOpenTargets() async {
|
||||||
|
final dynamic saved = await GroundedNavigation()
|
||||||
|
.navigateToPageWithData(const TargetSetup(), context);
|
||||||
|
if (saved == true) {
|
||||||
|
_model?.initialise();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onOpenReportCard() {
|
||||||
|
GroundedNavigation()
|
||||||
|
.navigateToPageWithData(const ReportCardScreen(), context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Derived ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
DateTime get _start => ProgressEngine.periodStart(_period, DateTime.now());
|
||||||
|
|
||||||
|
List<RoutineRunRecord> get _current => ProgressEngine.between(
|
||||||
|
_records, _start, ProgressEngine.periodEnd(_period, _start));
|
||||||
|
|
||||||
|
/// The previous period only up to the same point, so eight days into a
|
||||||
|
/// month is not measured against all thirty of the last one.
|
||||||
|
List<RoutineRunRecord> get _previous {
|
||||||
|
final DateTime start = ProgressEngine.previousStart(_period, _start);
|
||||||
|
final DateTime full = ProgressEngine.periodEnd(_period, start);
|
||||||
|
final DateTime samepoint = start.add(DateTime.now().difference(_start));
|
||||||
|
return ProgressEngine.between(
|
||||||
|
_records, start, samepoint.isBefore(full) ? samepoint : full);
|
||||||
|
}
|
||||||
|
|
||||||
|
String get _periodNoun {
|
||||||
|
switch (_period) {
|
||||||
|
case ProgressPeriod.Day:
|
||||||
|
return "today";
|
||||||
|
case ProgressPeriod.Week:
|
||||||
|
return "this week";
|
||||||
|
case ProgressPeriod.Month:
|
||||||
|
return "this month";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String get _previousNoun {
|
||||||
|
switch (_period) {
|
||||||
|
case ProgressPeriod.Day:
|
||||||
|
return "this time yesterday";
|
||||||
|
case ProgressPeriod.Week:
|
||||||
|
return "this point last week";
|
||||||
|
case ProgressPeriod.Month:
|
||||||
|
return "this point last month";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Views ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Widget _mobileView(BoxConstraints constraints) {
|
||||||
|
return Sheet(
|
||||||
|
eyebrow: "Over time",
|
||||||
|
title: "Progress",
|
||||||
|
onBack: _onBack,
|
||||||
|
action: chromeAction(CupertinoIcons.slider_horizontal_3, _onOpenTargets),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
displayTitle("Kept, not\nplanned."),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
segmentedSelector<ProgressPeriod>(
|
||||||
|
options: ProgressPeriod.values,
|
||||||
|
selected: _period,
|
||||||
|
label: periodLabel,
|
||||||
|
onSelected: _onPeriodSelected,
|
||||||
|
activeColor: colorNight,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
if (_loaded && !_targets.set) ...[
|
||||||
|
_setupPrompt(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
_headline(),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: _activeTile()),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: _streakTile()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
sectionBreak(
|
||||||
|
_period == ProgressPeriod.Day ? "Today's runs" : "Runs",
|
||||||
|
caption: "${_current.length} $_periodNoun",
|
||||||
|
),
|
||||||
|
if (_current.isEmpty)
|
||||||
|
_emptyRuns()
|
||||||
|
else
|
||||||
|
..._current.reversed
|
||||||
|
.take(_period == ProgressPeriod.Day ? 20 : 6)
|
||||||
|
.map(_runRow),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
outlinedActionButton(
|
||||||
|
"Weekly report card",
|
||||||
|
_onOpenReportCard,
|
||||||
|
icon: CupertinoIcons.doc_text,
|
||||||
|
radius: 0,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shown until the targets have been saved once: the numbers below use
|
||||||
|
/// defaults until then, and say so.
|
||||||
|
Widget _setupPrompt() {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: _onOpenTargets,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(18, 16, 14, 16),
|
||||||
|
color: colorNight,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(CupertinoIcons.scope, size: 22, color: colorLime),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
text("Set your weekly target", 15, TextType.Medium,
|
||||||
|
color: colorWhite),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
text(
|
||||||
|
"Measuring against your schedule, ${_targets.routinesPerWeek} routines a week, until you choose. Reminders are off.",
|
||||||
|
12,
|
||||||
|
TextType.Regular,
|
||||||
|
color: colorWhite.withValues(alpha: 0.7),
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Icon(CupertinoIcons.chevron_right,
|
||||||
|
size: 16, color: colorWhite.withValues(alpha: 0.7)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Held routines against the target, the change on the previous period,
|
||||||
|
/// and the bars underneath.
|
||||||
|
Widget _headline() {
|
||||||
|
final int held = ProgressEngine.held(_current);
|
||||||
|
final int target =
|
||||||
|
ProgressEngine.routineTarget(_targets, _period, _start);
|
||||||
|
final int delta = held - ProgressEngine.held(_previous);
|
||||||
|
|
||||||
|
return card(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 18, 20, 20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
text("ROUTINES HELD ${_periodNoun.toUpperCase()}", 10, TextType.Bold,
|
||||||
|
color: colorInkSoft, letterSpacing: 1.1),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
text("$held", 46, TextType.Light, color: colorInk, height: 1),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 6),
|
||||||
|
child: text("of $target target", 13, TextType.Regular,
|
||||||
|
color: colorInkSoft),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 6),
|
||||||
|
child: _deltaChip(delta),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
if (_period == ProgressPeriod.Day)
|
||||||
|
meter(target == 0 ? 0 : held / target,
|
||||||
|
fill: held >= target ? colorPositive : colorNight,
|
||||||
|
track: colorMuted,
|
||||||
|
height: 10)
|
||||||
|
else
|
||||||
|
_bars(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _deltaChip(int delta) {
|
||||||
|
final Color tone = delta > 0
|
||||||
|
? colorPositive
|
||||||
|
: (delta < 0 ? colorNegative : colorInkSoft);
|
||||||
|
final String sign = delta > 0 ? "+" : "";
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
|
||||||
|
color: tone.withValues(alpha: 0.12),
|
||||||
|
child: text("$sign$delta vs $_previousNoun", 11, TextType.Medium,
|
||||||
|
color: tone),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One bar per day (week) or per week (month). The current one is night,
|
||||||
|
/// the rest are muted, and future ones are a stub so the shape of the
|
||||||
|
/// period is still visible.
|
||||||
|
Widget _bars() {
|
||||||
|
final DateTime now = DateTime.now();
|
||||||
|
final List<int> values = ProgressEngine.buckets(_records, _period, _start);
|
||||||
|
final int current = ProgressEngine.currentBucket(_period, _start, now);
|
||||||
|
|
||||||
|
int peak = 1;
|
||||||
|
for (int value in values) {
|
||||||
|
if (value > peak) {
|
||||||
|
peak = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
height: 132,
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: List<Widget>.generate(values.length, (index) {
|
||||||
|
final bool isCurrent = index == current;
|
||||||
|
final bool future = index > current;
|
||||||
|
final double fraction = values[index] / peak;
|
||||||
|
|
||||||
|
return Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 5),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
if (!future && values[index] > 0)
|
||||||
|
text("${values[index]}", 11, TextType.Medium,
|
||||||
|
color: isCurrent ? colorInk : colorInkSoft),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Container(
|
||||||
|
height: future ? 4 : 6 + 84 * fraction,
|
||||||
|
color: isCurrent
|
||||||
|
? colorNight
|
||||||
|
: (future ? colorMuted : colorChartGrid),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
text(
|
||||||
|
_period == ProgressPeriod.Week
|
||||||
|
? _weekLetters[index]
|
||||||
|
: "W${index + 1}",
|
||||||
|
11,
|
||||||
|
isCurrent ? TextType.Bold : TextType.Regular,
|
||||||
|
color: isCurrent ? colorInk : colorInkSoft,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _activeTile() {
|
||||||
|
final int minutes = ProgressEngine.minutes(_current);
|
||||||
|
final int target =
|
||||||
|
ProgressEngine.minuteTarget(_targets, _period, _start);
|
||||||
|
|
||||||
|
return _tile(
|
||||||
|
CupertinoIcons.clock,
|
||||||
|
formatMinutes(minutes),
|
||||||
|
"active of ${formatMinutes(target)}",
|
||||||
|
footer: meter(target == 0 ? 0 : minutes / target,
|
||||||
|
fill: minutes >= target ? colorPositive : colorNight,
|
||||||
|
track: colorMuted,
|
||||||
|
height: 5),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _streakTile() {
|
||||||
|
final int streak = ProgressEngine.streak(_records, DateTime.now());
|
||||||
|
|
||||||
|
return _tile(
|
||||||
|
CupertinoIcons.flame_fill,
|
||||||
|
"$streak ${streak == 1 ? 'day' : 'days'}",
|
||||||
|
"streak, held daily",
|
||||||
|
iconColor: streak > 0 ? colorOverdueWarm : colorInkSoft,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _tile(IconData icon, String value, String label,
|
||||||
|
{Color? iconColor, Widget? footer}) {
|
||||||
|
return card(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 18, color: iconColor ?? colorInkSoft),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
text(value, 24, TextType.Light, color: colorInk),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
text(label, 11, TextType.Regular, color: colorInkSoft),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
footer ?? const SizedBox(height: 5),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _runRow(RoutineRunRecord record) {
|
||||||
|
final RoutineKind kind = RoutineKind.values.firstWhere(
|
||||||
|
(value) => value.name == record.kind,
|
||||||
|
orElse: () => RoutineKind.MustDo);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 12, 16, 12),
|
||||||
|
color: colorCard,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
color: kindColor(kind).withValues(alpha: 0.14),
|
||||||
|
child: Icon(kindIcon(kind), size: 20, color: kindColor(kind)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
text(record.title, 15, TextType.Medium,
|
||||||
|
color: colorInk,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
text(
|
||||||
|
"${_when(record.startedAt)} · ${formatMinutes(record.seconds / 60)}",
|
||||||
|
12,
|
||||||
|
TextType.Regular,
|
||||||
|
color: colorInkSoft,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
text(
|
||||||
|
record.held
|
||||||
|
? "Held"
|
||||||
|
: "${record.completedSteps}/${record.totalSteps}",
|
||||||
|
12,
|
||||||
|
TextType.Medium,
|
||||||
|
color: record.held ? colorPositive : colorWarning,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _when(DateTime value) {
|
||||||
|
final String clock = formatStartMinute(value.hour * 60 + value.minute);
|
||||||
|
if (_period == ProgressPeriod.Day) {
|
||||||
|
return clock;
|
||||||
|
}
|
||||||
|
return "${formatDate(value)} · $clock";
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _emptyRuns() {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||||
|
child: emptyState(
|
||||||
|
CupertinoIcons.chart_bar,
|
||||||
|
"Nothing run $_periodNoun",
|
||||||
|
"Finished routines land here, held or partial.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ConnectProgress ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onProgressLoaded(
|
||||||
|
List<RoutineRunRecord> records, ProgressTargets targets) {
|
||||||
|
setState(() {
|
||||||
|
_records = records
|
||||||
|
..sort((first, second) => first.startedAt.compareTo(second.startedAt));
|
||||||
|
_targets = targets;
|
||||||
|
_loaded = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
10
frontend/lib/Grounded/see/progress/TargetSetup.dart
Normal file
10
frontend/lib/Grounded/see/progress/TargetSetup.dart
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'TargetSetupState.dart';
|
||||||
|
|
||||||
|
class TargetSetup extends StatefulWidget {
|
||||||
|
const TargetSetup({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TargetSetup> createState() => TargetSetupState();
|
||||||
|
}
|
||||||
304
frontend/lib/Grounded/see/progress/TargetSetupState.dart
Normal file
304
frontend/lib/Grounded/see/progress/TargetSetupState.dart
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:stacked/stacked.dart';
|
||||||
|
|
||||||
|
import '../../about/internal/application/ProgressTargets.dart';
|
||||||
|
import '../../about/internal/application/TextType.dart';
|
||||||
|
import '../../designs/Component.dart';
|
||||||
|
import '../../designs/Responsive.dart';
|
||||||
|
import '../../designs/Shell.dart';
|
||||||
|
import '../../designs/buttons/Buttons.dart';
|
||||||
|
import '../../designs/text/Text.dart';
|
||||||
|
import '../../utils/Colors.dart';
|
||||||
|
import '../../utils/CommonUtils.dart';
|
||||||
|
import 'ConnectTargetSetup.dart';
|
||||||
|
import 'TargetSetup.dart';
|
||||||
|
import 'ViewTargetSetup.dart';
|
||||||
|
|
||||||
|
class TargetSetupState extends State<TargetSetup>
|
||||||
|
implements ConnectTargetSetup {
|
||||||
|
ViewTargetSetup? _model;
|
||||||
|
|
||||||
|
ProgressTargets _targets = ProgressTargets();
|
||||||
|
|
||||||
|
int _scheduled = 0;
|
||||||
|
|
||||||
|
static const int _routineFloor = 1;
|
||||||
|
|
||||||
|
static const int _routineCeiling = 28;
|
||||||
|
|
||||||
|
static const List<int> _minuteOptions = <int>[60, 90, 150, 210, 300];
|
||||||
|
|
||||||
|
static const List<int> _leadOptions = <int>[0, 5, 10, 15, 30];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ViewModelBuilder<ViewTargetSetup>.reactive(
|
||||||
|
viewModelBuilder: () => ViewTargetSetup(context, this),
|
||||||
|
onViewModelReady: (viewModel) {
|
||||||
|
_model = viewModel;
|
||||||
|
_initiate();
|
||||||
|
},
|
||||||
|
builder: (context, viewModel, child) => LayoutBuilder(
|
||||||
|
builder: (BuildContext context, BoxConstraints constraints) {
|
||||||
|
return Responsive(
|
||||||
|
mobile: _mobileView(constraints),
|
||||||
|
tablet: _mobileView(constraints),
|
||||||
|
desktop: _mobileView(constraints),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initiate() {
|
||||||
|
_model?.initialise();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void _onBack() {
|
||||||
|
Navigator.pop(context, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onRoutinesDown() {
|
||||||
|
setState(() {
|
||||||
|
if (_targets.routinesPerWeek > _routineFloor) {
|
||||||
|
_targets.routinesPerWeek = _targets.routinesPerWeek - 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onRoutinesUp() {
|
||||||
|
setState(() {
|
||||||
|
if (_targets.routinesPerWeek < _routineCeiling) {
|
||||||
|
_targets.routinesPerWeek = _targets.routinesPerWeek + 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onMatchSchedule() {
|
||||||
|
setState(() {
|
||||||
|
_targets.routinesPerWeek =
|
||||||
|
_scheduled.clamp(_routineFloor, _routineCeiling);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onMinutesSelected(int minutes) {
|
||||||
|
setState(() {
|
||||||
|
_targets.minutesPerWeek = minutes;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onToggleReminders() {
|
||||||
|
setState(() {
|
||||||
|
_targets.reminders = !_targets.reminders;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onLeadSelected(int lead) {
|
||||||
|
setState(() {
|
||||||
|
_targets.reminderLead = lead;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSave() {
|
||||||
|
_model?.save(_targets);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Views ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Widget _mobileView(BoxConstraints constraints) {
|
||||||
|
return Sheet(
|
||||||
|
eyebrow: "Progress",
|
||||||
|
title: "Targets",
|
||||||
|
onBack: _onBack,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
displayTitle("What counts as\na good week?"),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
text(
|
||||||
|
"Progress measures against these. Debt doesn't: missing a target costs nothing, only missing a commitment does.",
|
||||||
|
13,
|
||||||
|
TextType.Regular,
|
||||||
|
color: colorInkSoft,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
sectionBreak("Routines held", caption: "per week"),
|
||||||
|
_routineStepper(),
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
sectionBreak("Active time", caption: "minutes per week, in routines"),
|
||||||
|
segmentedSelector<int>(
|
||||||
|
options: _minuteOptions,
|
||||||
|
selected: _targets.minutesPerWeek,
|
||||||
|
label: _minuteLabel,
|
||||||
|
onSelected: _onMinutesSelected,
|
||||||
|
activeColor: colorNight,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
sectionBreak("Reminders", caption: "before each scheduled routine"),
|
||||||
|
_reminderToggle(),
|
||||||
|
if (_targets.reminders) ...[
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
segmentedSelector<int>(
|
||||||
|
options: _leadOptions,
|
||||||
|
selected: _targets.reminderLead,
|
||||||
|
label: _leadLabel,
|
||||||
|
onSelected: _onLeadSelected,
|
||||||
|
activeColor: colorNight,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
text(
|
||||||
|
kIsWeb
|
||||||
|
? "Reminders use the phone's notifications, so they only fire in the installed app."
|
||||||
|
: "Uses this phone's notifications. Non-negotiables still get their full-screen alarm when the window closes.",
|
||||||
|
12,
|
||||||
|
TextType.Regular,
|
||||||
|
color: colorGrey2,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
roundedCornerButton(
|
||||||
|
"Save targets",
|
||||||
|
_onSave,
|
||||||
|
icon: CupertinoIcons.checkmark,
|
||||||
|
radius: 0,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _minuteLabel(int minutes) => formatMinutes(minutes);
|
||||||
|
|
||||||
|
String _leadLabel(int lead) => lead == 0 ? "At start" : "${lead}m before";
|
||||||
|
|
||||||
|
Widget _routineStepper() {
|
||||||
|
return card(
|
||||||
|
padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_stepButton(CupertinoIcons.minus, _onRoutinesDown,
|
||||||
|
_targets.routinesPerWeek > _routineFloor),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
text("${_targets.routinesPerWeek}", 44, TextType.Light,
|
||||||
|
color: colorInk, height: 1),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
text(
|
||||||
|
"about ${(_targets.routinesPerWeek / 7).toStringAsFixed(1)} a day",
|
||||||
|
12,
|
||||||
|
TextType.Regular,
|
||||||
|
color: colorInkSoft,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_stepButton(CupertinoIcons.add, _onRoutinesUp,
|
||||||
|
_targets.routinesPerWeek < _routineCeiling),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_scheduled > 0) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Container(height: 1, color: colorDivider),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: text(
|
||||||
|
"Your schedule asks for $_scheduled a week.",
|
||||||
|
12,
|
||||||
|
TextType.Regular,
|
||||||
|
color: colorInkSoft,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_scheduled != _targets.routinesPerWeek)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _onMatchSchedule,
|
||||||
|
child: text("Match it", 12, TextType.Medium,
|
||||||
|
color: colorInk),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _stepButton(IconData icon, VoidCallback onTap, bool enabled) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: enabled ? onTap : null,
|
||||||
|
child: Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
color: enabled ? colorNight : colorMuted,
|
||||||
|
child: Icon(icon, size: 20, color: enabled ? colorLime : colorGrey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _reminderToggle() {
|
||||||
|
final bool on = _targets.reminders;
|
||||||
|
|
||||||
|
return card(
|
||||||
|
onTap: _onToggleReminders,
|
||||||
|
background: on ? colorStandingGoodBg : colorCard,
|
||||||
|
borderColor: on ? colorPositive.withValues(alpha: 0.2) : colorBorder,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
text(on ? "On" : "Off", 16, TextType.Medium,
|
||||||
|
color: colorPrimaryDark),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
text(
|
||||||
|
on
|
||||||
|
? "A heads-up before every scheduled routine. Tap to turn off."
|
||||||
|
: "No heads-up before routines. Tap to turn on.",
|
||||||
|
12,
|
||||||
|
TextType.Regular,
|
||||||
|
color: colorGrey2,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
on ? CupertinoIcons.bell_fill : CupertinoIcons.bell_slash,
|
||||||
|
size: 24,
|
||||||
|
color: on ? colorPositive : colorGrey2,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ConnectTargetSetup ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onTargetsLoaded(ProgressTargets targets, int scheduledPerWeek) {
|
||||||
|
setState(() {
|
||||||
|
_targets = targets;
|
||||||
|
_scheduled = scheduledPerWeek;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onTargetsSaved() {
|
||||||
|
Navigator.pop(context, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
frontend/lib/Grounded/see/progress/ViewProgress.dart
Normal file
46
frontend/lib/Grounded/see/progress/ViewProgress.dart
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import '../../about/external/data/pages/request/HistoryRequest.dart';
|
||||||
|
import '../../about/external/data/pages/request/PageAndSort.dart';
|
||||||
|
import '../../about/external/data/pages/request/Pageable.dart';
|
||||||
|
import '../../about/external/data/pages/request/Sort.dart';
|
||||||
|
import '../../about/internal/application/ProgressTargets.dart';
|
||||||
|
import '../../about/internal/application/RoutineRunRecord.dart';
|
||||||
|
import '../../utils/ObjectConvertors.dart';
|
||||||
|
import '../../utils/ProgressEngine.dart';
|
||||||
|
import '../parent/ParentViewModel.dart';
|
||||||
|
import 'ConnectProgress.dart';
|
||||||
|
|
||||||
|
class ViewProgress extends ParentViewModel {
|
||||||
|
ConnectProgress connection;
|
||||||
|
|
||||||
|
ViewProgress(super.context, this.connection);
|
||||||
|
|
||||||
|
/// The runs and targets are on the device, so there is no network guard
|
||||||
|
/// or spinner. Until a target is saved, the schedule stands in for it.
|
||||||
|
void initialise() async {
|
||||||
|
final List<RoutineRunRecord> records =
|
||||||
|
await getDataManager().getRunLog();
|
||||||
|
|
||||||
|
final ProgressTargets targets =
|
||||||
|
await getDataManager().getProgressTargets();
|
||||||
|
|
||||||
|
if (!targets.set) {
|
||||||
|
try {
|
||||||
|
final response = await getDataManager().getMyRoutines(HistoryRequest(
|
||||||
|
query: PageAndSort(
|
||||||
|
sort: Sort('asc', 'startMinute'),
|
||||||
|
page: Pageable(0, 0, 50, 0),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
final int scheduled = ProgressEngine.scheduledPerWeek(
|
||||||
|
getRoutineChainList(response.data));
|
||||||
|
if (scheduled > 0) {
|
||||||
|
targets.routinesPerWeek = scheduled;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Offline: the default target stands.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.onProgressLoaded(records, targets);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
frontend/lib/Grounded/see/progress/ViewTargetSetup.dart
Normal file
60
frontend/lib/Grounded/see/progress/ViewTargetSetup.dart
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import '../../about/external/data/RoutineChain.dart';
|
||||||
|
import '../../about/external/data/pages/request/HistoryRequest.dart';
|
||||||
|
import '../../about/external/data/pages/request/PageAndSort.dart';
|
||||||
|
import '../../about/external/data/pages/request/Pageable.dart';
|
||||||
|
import '../../about/external/data/pages/request/Sort.dart';
|
||||||
|
import '../../about/internal/application/ProgressTargets.dart';
|
||||||
|
import '../../configs/NotificationServiceConfig.dart';
|
||||||
|
import '../../utils/ObjectConvertors.dart';
|
||||||
|
import '../../utils/ProgressEngine.dart';
|
||||||
|
import '../parent/ParentViewModel.dart';
|
||||||
|
import 'ConnectTargetSetup.dart';
|
||||||
|
|
||||||
|
class ViewTargetSetup extends ParentViewModel {
|
||||||
|
ConnectTargetSetup connection;
|
||||||
|
|
||||||
|
List<RoutineChain> _routines = <RoutineChain>[];
|
||||||
|
|
||||||
|
ViewTargetSetup(super.context, this.connection);
|
||||||
|
|
||||||
|
/// Targets are on the device; the routines are only needed to count the
|
||||||
|
/// schedule and to place reminders, so their failure is quiet.
|
||||||
|
void initialise() async {
|
||||||
|
final ProgressTargets targets =
|
||||||
|
await getDataManager().getProgressTargets();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await getDataManager().getMyRoutines(HistoryRequest(
|
||||||
|
query: PageAndSort(
|
||||||
|
sort: Sort('asc', 'startMinute'),
|
||||||
|
page: Pageable(0, 0, 50, 0),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
_routines = getRoutineChainList(response.data);
|
||||||
|
} catch (e) {
|
||||||
|
_routines = <RoutineChain>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
final int scheduled = ProgressEngine.scheduledPerWeek(_routines);
|
||||||
|
if (!targets.set && scheduled > 0) {
|
||||||
|
targets.routinesPerWeek = scheduled;
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.onTargetsLoaded(targets, scheduled);
|
||||||
|
}
|
||||||
|
|
||||||
|
void save(ProgressTargets targets) async {
|
||||||
|
targets.set = true;
|
||||||
|
|
||||||
|
await getDataManager().setProgressTargets(targets);
|
||||||
|
|
||||||
|
if (targets.reminders) {
|
||||||
|
await LocalNotificationEngine.scheduleRoutineReminders(
|
||||||
|
_routines, targets.reminderLead);
|
||||||
|
} else {
|
||||||
|
await LocalNotificationEngine.cancelRoutineReminders();
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.onTargetsSaved();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
|
import '../../about/internal/application/RoutineRunRecord.dart';
|
||||||
|
|
||||||
abstract class ConnectRoutineRunner {
|
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
|
/// The run was recorded. [held] is false when the chain broke and the run
|
||||||
/// went in as partial.
|
/// went in as partial.
|
||||||
void onLogged(bool held);
|
void onLogged(bool held);
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:stacked/stacked.dart';
|
import 'package:stacked/stacked.dart';
|
||||||
|
|
||||||
|
import '../../about/internal/application/RoutineRunRecord.dart';
|
||||||
|
import '../../utils/ProgressEngine.dart';
|
||||||
import '../../about/external/data/RoutineStep.dart';
|
import '../../about/external/data/RoutineStep.dart';
|
||||||
import '../../about/internal/application/NotificationType.dart';
|
import '../../about/internal/application/NotificationType.dart';
|
||||||
import '../../about/internal/application/RoutineKind.dart';
|
import '../../about/internal/application/RoutineKind.dart';
|
||||||
@@ -36,6 +38,8 @@ class RoutineRunnerState extends State<RoutineRunner>
|
|||||||
implements ConnectRoutineRunner {
|
implements ConnectRoutineRunner {
|
||||||
ViewRoutineRunner? _model;
|
ViewRoutineRunner? _model;
|
||||||
|
|
||||||
|
List<RoutineRunRecord> _history = <RoutineRunRecord>[];
|
||||||
|
|
||||||
late RoutineRun _run;
|
late RoutineRun _run;
|
||||||
|
|
||||||
Timer? _ticker;
|
Timer? _ticker;
|
||||||
@@ -107,6 +111,7 @@ class RoutineRunnerState extends State<RoutineRunner>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _initiate() {
|
void _initiate() {
|
||||||
|
_model?.loadHistory();
|
||||||
_startTicker();
|
_startTicker();
|
||||||
_syncBreath();
|
_syncBreath();
|
||||||
_model?.publishRun(_run);
|
_model?.publishRun(_run);
|
||||||
@@ -1152,6 +1157,8 @@ class RoutineRunnerState extends State<RoutineRunner>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_todayLine(held),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
roundedCornerButton(
|
roundedCornerButton(
|
||||||
"Log it",
|
"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 ──────────────────────────────────────────────────
|
// ── ConnectRoutineRunner ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onHistoryLoaded(List<RoutineRunRecord> records) {
|
||||||
|
setState(() {
|
||||||
|
_history = records;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onLogged(bool held) {
|
void onLogged(bool held) {
|
||||||
Navigator.pop(context, true);
|
Navigator.pop(context, true);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import '../../about/external/data/LiveSession.dart';
|
import '../../about/external/data/LiveSession.dart';
|
||||||
import '../../about/external/initial/RoutineLogRequest.dart';
|
import '../../about/external/initial/RoutineLogRequest.dart';
|
||||||
import '../../about/internal/application/RoutineRun.dart';
|
import '../../about/internal/application/RoutineRun.dart';
|
||||||
|
import '../../about/internal/application/RoutineRunRecord.dart';
|
||||||
import '../../configs/NotificationServiceConfig.dart';
|
import '../../configs/NotificationServiceConfig.dart';
|
||||||
import '../parent/ParentViewModel.dart';
|
import '../parent/ParentViewModel.dart';
|
||||||
import 'ConnectRoutineRunner.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() {
|
void clearRun() {
|
||||||
LocalNotificationEngine.cancelSessionNotification();
|
LocalNotificationEngine.cancelSessionNotification();
|
||||||
}
|
}
|
||||||
@@ -48,6 +73,8 @@ class ViewRoutineRunner extends ParentViewModel {
|
|||||||
skippedCount: run.skippedCount,
|
skippedCount: run.skippedCount,
|
||||||
).toJson());
|
).toJson());
|
||||||
|
|
||||||
|
await _remember(run);
|
||||||
|
|
||||||
clearRun();
|
clearRun();
|
||||||
|
|
||||||
closeLoading();
|
closeLoading();
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import '../../utils/Colors.dart';
|
|||||||
import '../../utils/Thresholds.dart';
|
import '../../utils/Thresholds.dart';
|
||||||
import '../../utils/Validators.dart';
|
import '../../utils/Validators.dart';
|
||||||
import '../login/Login.dart';
|
import '../login/Login.dart';
|
||||||
|
import '../progress/TargetSetup.dart';
|
||||||
import 'ConnectSettings.dart';
|
import 'ConnectSettings.dart';
|
||||||
import 'Settings.dart';
|
import 'Settings.dart';
|
||||||
import 'ViewSettings.dart';
|
import 'ViewSettings.dart';
|
||||||
@@ -58,6 +59,11 @@ class SettingsState extends State<Settings> implements ConnectSettings {
|
|||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onOpenTargets() {
|
||||||
|
GroundedNavigation()
|
||||||
|
.navigateToPageWithData(const TargetSetup(), context);
|
||||||
|
}
|
||||||
|
|
||||||
void _onToneSelected(ToneLevel tone) {
|
void _onToneSelected(ToneLevel tone) {
|
||||||
_model?.changeTone(tone);
|
_model?.changeTone(tone);
|
||||||
}
|
}
|
||||||
@@ -242,6 +248,34 @@ class SettingsState extends State<Settings> implements ConnectSettings {
|
|||||||
height: 1.5,
|
height: 1.5,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
|
sectionBreak("Targets", caption: "what a good week is"),
|
||||||
|
card(
|
||||||
|
onTap: _onOpenTargets,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
text("Targets & reminders", 16, TextType.Medium,
|
||||||
|
color: colorPrimaryDark),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
text(
|
||||||
|
"Routines and active time per week, and a heads-up before each routine.",
|
||||||
|
12,
|
||||||
|
TextType.Regular,
|
||||||
|
color: colorGrey2,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(CupertinoIcons.chevron_right,
|
||||||
|
size: 18, color: colorGrey2),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 28),
|
||||||
sectionBreak("Pause", caption: "sick or travel"),
|
sectionBreak("Pause", caption: "sick or travel"),
|
||||||
card(
|
card(
|
||||||
background: _user.sickMode ? colorStandingGoodBg : colorCard,
|
background: _user.sickMode ? colorStandingGoodBg : colorCard,
|
||||||
|
|||||||
163
frontend/lib/Grounded/utils/ProgressEngine.dart
Normal file
163
frontend/lib/Grounded/utils/ProgressEngine.dart
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import '../about/external/data/RoutineChain.dart';
|
||||||
|
import '../about/internal/application/ProgressPeriod.dart';
|
||||||
|
import '../about/internal/application/ProgressTargets.dart';
|
||||||
|
import '../about/internal/application/RoutineRunRecord.dart';
|
||||||
|
|
||||||
|
/// Progress over time, derived from the device's run log. Pure functions:
|
||||||
|
/// the screen asks, this answers, nothing is stored.
|
||||||
|
class ProgressEngine {
|
||||||
|
static DateTime dayOf(DateTime value) =>
|
||||||
|
DateTime(value.year, value.month, value.day);
|
||||||
|
|
||||||
|
/// Weeks start on Monday, matching routine `days` (1 = Monday).
|
||||||
|
static DateTime weekStart(DateTime value) =>
|
||||||
|
dayOf(value).subtract(Duration(days: value.weekday - 1));
|
||||||
|
|
||||||
|
static DateTime monthStart(DateTime value) =>
|
||||||
|
DateTime(value.year, value.month);
|
||||||
|
|
||||||
|
/// The start of the period containing [now], and of the one before it.
|
||||||
|
static DateTime periodStart(ProgressPeriod period, DateTime now) {
|
||||||
|
switch (period) {
|
||||||
|
case ProgressPeriod.Day:
|
||||||
|
return dayOf(now);
|
||||||
|
case ProgressPeriod.Week:
|
||||||
|
return weekStart(now);
|
||||||
|
case ProgressPeriod.Month:
|
||||||
|
return monthStart(now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static DateTime periodEnd(ProgressPeriod period, DateTime start) {
|
||||||
|
switch (period) {
|
||||||
|
case ProgressPeriod.Day:
|
||||||
|
return start.add(const Duration(days: 1));
|
||||||
|
case ProgressPeriod.Week:
|
||||||
|
return start.add(const Duration(days: 7));
|
||||||
|
case ProgressPeriod.Month:
|
||||||
|
return DateTime(start.year, start.month + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static DateTime previousStart(ProgressPeriod period, DateTime start) {
|
||||||
|
switch (period) {
|
||||||
|
case ProgressPeriod.Day:
|
||||||
|
return start.subtract(const Duration(days: 1));
|
||||||
|
case ProgressPeriod.Week:
|
||||||
|
return start.subtract(const Duration(days: 7));
|
||||||
|
case ProgressPeriod.Month:
|
||||||
|
return DateTime(start.year, start.month - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Routine slots the schedule asks for each week.
|
||||||
|
static int scheduledPerWeek(List<RoutineChain> routines) {
|
||||||
|
int count = 0;
|
||||||
|
for (RoutineChain routine in routines) {
|
||||||
|
count = count + routine.days.length;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<RoutineRunRecord> between(
|
||||||
|
List<RoutineRunRecord> records, DateTime start, DateTime end) {
|
||||||
|
return records
|
||||||
|
.where((record) =>
|
||||||
|
!record.startedAt.isBefore(start) && record.startedAt.isBefore(end))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static int held(List<RoutineRunRecord> records) =>
|
||||||
|
records.where((record) => record.held).length;
|
||||||
|
|
||||||
|
static int minutes(List<RoutineRunRecord> records) {
|
||||||
|
int seconds = 0;
|
||||||
|
for (RoutineRunRecord record in records) {
|
||||||
|
seconds = seconds + record.seconds;
|
||||||
|
}
|
||||||
|
return (seconds / 60).round();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The weekly targets scaled to the period: a day is a seventh of a week,
|
||||||
|
/// a month is its days over seven.
|
||||||
|
static int routineTarget(
|
||||||
|
ProgressTargets targets, ProgressPeriod period, DateTime start) {
|
||||||
|
return _scale(targets.routinesPerWeek, period, start);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int minuteTarget(
|
||||||
|
ProgressTargets targets, ProgressPeriod period, DateTime start) {
|
||||||
|
return _scale(targets.minutesPerWeek, period, start);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _scale(int weekly, ProgressPeriod period, DateTime start) {
|
||||||
|
switch (period) {
|
||||||
|
case ProgressPeriod.Day:
|
||||||
|
return (weekly / 7).ceil();
|
||||||
|
case ProgressPeriod.Week:
|
||||||
|
return weekly;
|
||||||
|
case ProgressPeriod.Month:
|
||||||
|
final int days = periodEnd(period, start).difference(start).inDays;
|
||||||
|
return (weekly * days / 7).round();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Held routines per bucket: days of the week, or weeks of the month.
|
||||||
|
/// A day has no buckets; it lists its runs instead.
|
||||||
|
static List<int> buckets(
|
||||||
|
List<RoutineRunRecord> records, ProgressPeriod period, DateTime start) {
|
||||||
|
switch (period) {
|
||||||
|
case ProgressPeriod.Day:
|
||||||
|
return <int>[];
|
||||||
|
case ProgressPeriod.Week:
|
||||||
|
return List<int>.generate(7, (index) {
|
||||||
|
final DateTime day = start.add(Duration(days: index));
|
||||||
|
return held(between(records, day, day.add(const Duration(days: 1))));
|
||||||
|
});
|
||||||
|
case ProgressPeriod.Month:
|
||||||
|
final DateTime end = periodEnd(period, start);
|
||||||
|
final List<int> result = <int>[];
|
||||||
|
DateTime week = weekStart(start);
|
||||||
|
while (week.isBefore(end)) {
|
||||||
|
final DateTime from = week.isBefore(start) ? start : week;
|
||||||
|
final DateTime next = week.add(const Duration(days: 7));
|
||||||
|
result.add(held(between(records, from, next.isAfter(end) ? end : next)));
|
||||||
|
week = next;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which bucket [now] falls in, so the chart can highlight it.
|
||||||
|
static int currentBucket(ProgressPeriod period, DateTime start, DateTime now) {
|
||||||
|
switch (period) {
|
||||||
|
case ProgressPeriod.Day:
|
||||||
|
return 0;
|
||||||
|
case ProgressPeriod.Week:
|
||||||
|
return now.weekday - 1;
|
||||||
|
case ProgressPeriod.Month:
|
||||||
|
return dayOf(now).difference(weekStart(start)).inDays ~/ 7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consecutive days with at least one held routine, ending today — or
|
||||||
|
/// yesterday, so a streak is not shown as broken before the day is out.
|
||||||
|
static int streak(List<RoutineRunRecord> records, DateTime now) {
|
||||||
|
final Set<DateTime> days = records
|
||||||
|
.where((record) => record.held)
|
||||||
|
.map((record) => dayOf(record.startedAt))
|
||||||
|
.toSet();
|
||||||
|
|
||||||
|
DateTime cursor = dayOf(now);
|
||||||
|
if (!days.contains(cursor)) {
|
||||||
|
cursor = cursor.subtract(const Duration(days: 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
int count = 0;
|
||||||
|
while (days.contains(cursor)) {
|
||||||
|
count = count + 1;
|
||||||
|
cursor = cursor.subtract(const Duration(days: 1));
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
}
|
||||||
2
frontend/pubspec.lock
generated
2
frontend/pubspec.lock
generated
@@ -1062,7 +1062,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
version: "0.7.11"
|
||||||
timezone:
|
timezone:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: timezone
|
name: timezone
|
||||||
sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb"
|
sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb"
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ dependencies:
|
|||||||
|
|
||||||
# ── Notifications ───────────────────────────────────────────────────────
|
# ── Notifications ───────────────────────────────────────────────────────
|
||||||
flutter_local_notifications: ^21.0.0
|
flutter_local_notifications: ^21.0.0
|
||||||
|
timezone: ^0.11.1
|
||||||
local_notifier: ^0.1.6
|
local_notifier: ^0.1.6
|
||||||
|
|
||||||
# ── UI ──────────────────────────────────────────────────────────────────
|
# ── UI ──────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user