diff --git a/frontend/design.md b/frontend/design.md index 76b8829..c71542e 100644 --- a/frontend/design.md +++ b/frontend/design.md @@ -109,3 +109,19 @@ goals and a chat. (`designs/MemberAvatar.dart`): square painted portraits with the initial. - Groups are reached from the nav (Groups tab), the Home "Your groups" rail (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. diff --git a/frontend/lib/Grounded/about/internal/application/ProgressPeriod.dart b/frontend/lib/Grounded/about/internal/application/ProgressPeriod.dart new file mode 100644 index 0000000..b517bac --- /dev/null +++ b/frontend/lib/Grounded/about/internal/application/ProgressPeriod.dart @@ -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"; + } +} diff --git a/frontend/lib/Grounded/about/internal/application/ProgressTargets.dart b/frontend/lib/Grounded/about/internal/application/ProgressTargets.dart new file mode 100644 index 0000000..02accab --- /dev/null +++ b/frontend/lib/Grounded/about/internal/application/ProgressTargets.dart @@ -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 json) { + return ProgressTargets( + routinesPerWeek: json['routinesPerWeek'] ?? 5, + minutesPerWeek: json['minutesPerWeek'] ?? 150, + reminders: json['reminders'] ?? false, + reminderLead: json['reminderLead'] ?? 10, + set: json['set'] ?? false, + ); + } + + Map toJson() { + final Map data = {}; + data['routinesPerWeek'] = routinesPerWeek; + data['minutesPerWeek'] = minutesPerWeek; + data['reminders'] = reminders; + data['reminderLead'] = reminderLead; + data['set'] = set; + return data; + } +} diff --git a/frontend/lib/Grounded/about/internal/application/RoutineRunRecord.dart b/frontend/lib/Grounded/about/internal/application/RoutineRunRecord.dart new file mode 100644 index 0000000..5cbbf21 --- /dev/null +++ b/frontend/lib/Grounded/about/internal/application/RoutineRunRecord.dart @@ -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 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 toJson() { + final Map data = {}; + data['routineId'] = routineId; + data['title'] = title; + data['kind'] = kind; + data['startedAt'] = startedAt.toIso8601String(); + data['seconds'] = seconds; + data['completedSteps'] = completedSteps; + data['totalSteps'] = totalSteps; + return data; + } +} diff --git a/frontend/lib/Grounded/configs/NotificationServiceConfig.dart b/frontend/lib/Grounded/configs/NotificationServiceConfig.dart index a3667a7..313dc83 100644 --- a/frontend/lib/Grounded/configs/NotificationServiceConfig.dart +++ b/frontend/lib/Grounded/configs/NotificationServiceConfig.dart @@ -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 scheduleRoutineReminders( + List 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 cancelRoutineReminders() async { + if (kIsWeb) { + return; + } + await init(); + for (int index = 0; index < reminderSlots; index++) { + await plugin.cancel(id: reminderBase + index); + } + } + static Future cancelAll() async { // The browser has no notification channels; the runner works without. if (kIsWeb) { diff --git a/frontend/lib/Grounded/informatics/AppDataManager.dart b/frontend/lib/Grounded/informatics/AppDataManager.dart index 5086bcd..c8329ef 100644 --- a/frontend/lib/Grounded/informatics/AppDataManager.dart +++ b/frontend/lib/Grounded/informatics/AppDataManager.dart @@ -27,6 +27,8 @@ import '../about/external/initial/SessionLogRequest.dart'; import '../about/external/initial/SickModeRequest.dart'; import '../about/external/initial/ToneRequest.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/MeDescription.dart'; import '../about/internal/application/Token.dart'; @@ -132,6 +134,21 @@ class AppDataManager implements DataManager { @override Future setTodos(List todos) => memory.setTodos(todos); + @override + Future> getRunLog() => memory.getRunLog(); + + @override + Future setRunLog(List records) => + memory.setRunLog(records); + + @override + Future getProgressTargets() => + memory.getProgressTargets(); + + @override + Future setProgressTargets(ProgressTargets targets) => + memory.setProgressTargets(targets); + @override Future showOnboarding() => memory.showOnboarding(); diff --git a/frontend/lib/Grounded/memory/ConnectInternalMemory.dart b/frontend/lib/Grounded/memory/ConnectInternalMemory.dart index 5b891fc..61be3ce 100644 --- a/frontend/lib/Grounded/memory/ConnectInternalMemory.dart +++ b/frontend/lib/Grounded/memory/ConnectInternalMemory.dart @@ -3,6 +3,8 @@ import '../about/external/data/Program.dart'; import '../about/external/data/SystemResponse.dart'; import '../about/internal/application/CapacityProfile.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/Token.dart'; import '../about/internal/application/UserDetails.dart'; @@ -65,6 +67,16 @@ abstract class ConnectInternalMemory { Future setTodos(List todos); + /// Finished routine runs, newest last. Feeds Progress. + Future> getRunLog(); + + Future setRunLog(List records); + + /// Weekly targets and reminder preferences. + Future getProgressTargets(); + + Future setProgressTargets(ProgressTargets targets); + Future showOnboarding(); Future setOnboardingOption(bool value); diff --git a/frontend/lib/Grounded/memory/InternalMemory.dart b/frontend/lib/Grounded/memory/InternalMemory.dart index 38c45e1..de0927d 100644 --- a/frontend/lib/Grounded/memory/InternalMemory.dart +++ b/frontend/lib/Grounded/memory/InternalMemory.dart @@ -8,6 +8,8 @@ import '../about/external/data/ResponseState.dart'; import '../about/external/data/SystemResponse.dart'; import '../about/internal/application/CapacityProfile.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/Token.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 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"; @@ -275,6 +279,44 @@ class InternalMemory implements ConnectInternalMemory { value: jsonEncode(todos.map((item) => item.toJson()).toList())); } + @override + Future> getRunLog() async { + String? value = await groundedStorage.read(key: RUN_LOG); + + if (value != null) { + return (jsonDecode(value) as List) + .map((item) => + RoutineRunRecord.fromJson(item as Map)) + .toList(); + } + + return []; + } + + @override + Future setRunLog(List records) async { + await groundedStorage.write( + key: RUN_LOG, + value: jsonEncode(records.map((item) => item.toJson()).toList())); + } + + @override + Future 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 Future showOnboarding() async { String? value = await groundedStorage.read(key: SHOW_ONBOARDING); diff --git a/frontend/lib/Grounded/mock/MockMemory.dart b/frontend/lib/Grounded/mock/MockMemory.dart index f7050d2..a5d905d 100644 --- a/frontend/lib/Grounded/mock/MockMemory.dart +++ b/frontend/lib/Grounded/mock/MockMemory.dart @@ -1,8 +1,11 @@ // MOCK DATA — TEMPORARY. See MockSwitch.dart. +import 'dart:math'; + import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import '../about/internal/application/MeDescription.dart'; +import '../about/internal/application/RoutineRunRecord.dart'; import '../about/internal/application/TodoItem.dart'; import '../about/internal/application/UserDetails.dart'; import '../memory/InternalMemory.dart'; @@ -24,9 +27,79 @@ class MockMemory extends InternalMemory { TodoItem(id: "t2", title: "Book physio for Thursday"), 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 _history() { + final Random random = Random(11); + final DateTime now = DateTime.now(); + final DateTime today = DateTime(now.year, now.month, now.day); + final List records = []; + + 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 FlutterSecureStorage get groundedStorage => const MockStorage(); } diff --git a/frontend/lib/Grounded/see/home/HomeState.dart b/frontend/lib/Grounded/see/home/HomeState.dart index 2de0ac1..a894fc2 100644 --- a/frontend/lib/Grounded/see/home/HomeState.dart +++ b/frontend/lib/Grounded/see/home/HomeState.dart @@ -42,6 +42,7 @@ import '../../utils/ToneEngine.dart'; import '../commitment/NewCommitment.dart'; import '../excuse/ExcuseReport.dart'; import '../goal/Goals.dart'; +import '../progress/Progress.dart'; import '../group/GroupChat.dart'; import '../group/GroupEditor.dart'; import '../group/Groups.dart'; @@ -182,6 +183,10 @@ class HomeState extends State implements ConnectHome { _model?.tick(item); } + void _onOpenProgress() { + GroundedNavigation().navigateToPageWithData(const Progress(), context); + } + void _onOpenReportCard() { GroundedNavigation().navigateToPage( NavigatorType.justOpen, const ReportCardScreen(), context); @@ -445,8 +450,8 @@ class HomeState extends State implements ConnectHome { ), ( icon: CupertinoIcons.chart_bar_alt_fill, - label: "Insights", - onTap: _onOpenReportCard + label: "Progress", + onTap: _onOpenProgress ), ], ).animate().fadeIn(duration: 400.ms, delay: 250.ms).slideY( diff --git a/frontend/lib/Grounded/see/home/ViewHome.dart b/frontend/lib/Grounded/see/home/ViewHome.dart index 7bb4c51..739f67a 100644 --- a/frontend/lib/Grounded/see/home/ViewHome.dart +++ b/frontend/lib/Grounded/see/home/ViewHome.dart @@ -12,9 +12,11 @@ import '../../about/external/data/pages/response/CommitmentPage.dart'; import '../../about/external/initial/CompletionRequest.dart'; import '../../about/external/initial/ReportCardRequest.dart'; import '../../about/internal/application/CommitmentClass.dart'; +import '../../about/internal/application/ProgressTargets.dart'; import '../../about/internal/application/Standing.dart'; import '../../about/internal/application/TodoItem.dart'; import '../../about/internal/application/UserDetails.dart'; +import '../../configs/NotificationServiceConfig.dart'; import '../../utils/DebtEngine.dart'; import '../../utils/GuardrailEngine.dart'; import '../../utils/ObjectConvertors.dart'; @@ -125,12 +127,27 @@ class ViewHome extends ParentViewModel { ), )); - connection.onRoutinesLoaded(getRoutineChainList(response.data)); + final List routines = getRoutineChainList(response.data); + + connection.onRoutinesLoaded(routines); + + _placeReminders(routines); } catch (e) { connection.onRoutinesLoaded([]); } } + /// Reminders are one-shots for the coming week, so they are re-placed + /// whenever the schedule is read. + void _placeReminders(List routines) async { + final ProgressTargets targets = + await getDataManager().getProgressTargets(); + if (targets.reminders) { + await LocalNotificationEngine.scheduleRoutineReminders( + routines, targets.reminderLead); + } + } + void loadOverdue() async { try { final response = await getDataManager().getOverdueQueue(HistoryRequest( diff --git a/frontend/lib/Grounded/see/progress/ConnectProgress.dart b/frontend/lib/Grounded/see/progress/ConnectProgress.dart new file mode 100644 index 0000000..7270335 --- /dev/null +++ b/frontend/lib/Grounded/see/progress/ConnectProgress.dart @@ -0,0 +1,7 @@ +import '../../about/internal/application/ProgressTargets.dart'; +import '../../about/internal/application/RoutineRunRecord.dart'; + +abstract class ConnectProgress { + void onProgressLoaded( + List records, ProgressTargets targets); +} diff --git a/frontend/lib/Grounded/see/progress/ConnectTargetSetup.dart b/frontend/lib/Grounded/see/progress/ConnectTargetSetup.dart new file mode 100644 index 0000000..4077829 --- /dev/null +++ b/frontend/lib/Grounded/see/progress/ConnectTargetSetup.dart @@ -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(); +} diff --git a/frontend/lib/Grounded/see/progress/Progress.dart b/frontend/lib/Grounded/see/progress/Progress.dart new file mode 100644 index 0000000..71d91d3 --- /dev/null +++ b/frontend/lib/Grounded/see/progress/Progress.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +import 'ProgressState.dart'; + +class Progress extends StatefulWidget { + const Progress({super.key}); + + @override + State createState() => ProgressState(); +} diff --git a/frontend/lib/Grounded/see/progress/ProgressState.dart b/frontend/lib/Grounded/see/progress/ProgressState.dart new file mode 100644 index 0000000..ae8fbd3 --- /dev/null +++ b/frontend/lib/Grounded/see/progress/ProgressState.dart @@ -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 implements ConnectProgress { + ViewProgress? _model; + + List _records = []; + + ProgressTargets _targets = ProgressTargets(); + + ProgressPeriod _period = ProgressPeriod.Week; + + bool _loaded = false; + + static const List _weekLetters = [ + "M", "T", "W", "T", "F", "S", "S" + ]; + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.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 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 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( + 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 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.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 records, ProgressTargets targets) { + setState(() { + _records = records + ..sort((first, second) => first.startedAt.compareTo(second.startedAt)); + _targets = targets; + _loaded = true; + }); + } +} diff --git a/frontend/lib/Grounded/see/progress/TargetSetup.dart b/frontend/lib/Grounded/see/progress/TargetSetup.dart new file mode 100644 index 0000000..fd9d401 --- /dev/null +++ b/frontend/lib/Grounded/see/progress/TargetSetup.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +import 'TargetSetupState.dart'; + +class TargetSetup extends StatefulWidget { + const TargetSetup({super.key}); + + @override + State createState() => TargetSetupState(); +} diff --git a/frontend/lib/Grounded/see/progress/TargetSetupState.dart b/frontend/lib/Grounded/see/progress/TargetSetupState.dart new file mode 100644 index 0000000..952da20 --- /dev/null +++ b/frontend/lib/Grounded/see/progress/TargetSetupState.dart @@ -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 + implements ConnectTargetSetup { + ViewTargetSetup? _model; + + ProgressTargets _targets = ProgressTargets(); + + int _scheduled = 0; + + static const int _routineFloor = 1; + + static const int _routineCeiling = 28; + + static const List _minuteOptions = [60, 90, 150, 210, 300]; + + static const List _leadOptions = [0, 5, 10, 15, 30]; + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.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( + 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( + 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); + } +} diff --git a/frontend/lib/Grounded/see/progress/ViewProgress.dart b/frontend/lib/Grounded/see/progress/ViewProgress.dart new file mode 100644 index 0000000..ec0639a --- /dev/null +++ b/frontend/lib/Grounded/see/progress/ViewProgress.dart @@ -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 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); + } +} diff --git a/frontend/lib/Grounded/see/progress/ViewTargetSetup.dart b/frontend/lib/Grounded/see/progress/ViewTargetSetup.dart new file mode 100644 index 0000000..cd87cc7 --- /dev/null +++ b/frontend/lib/Grounded/see/progress/ViewTargetSetup.dart @@ -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 _routines = []; + + 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 = []; + } + + 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(); + } +} diff --git a/frontend/lib/Grounded/see/routine/ConnectRoutineRunner.dart b/frontend/lib/Grounded/see/routine/ConnectRoutineRunner.dart index f39746f..aa664db 100644 --- a/frontend/lib/Grounded/see/routine/ConnectRoutineRunner.dart +++ b/frontend/lib/Grounded/see/routine/ConnectRoutineRunner.dart @@ -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 records); + /// The run was recorded. [held] is false when the chain broke and the run /// went in as partial. void onLogged(bool held); diff --git a/frontend/lib/Grounded/see/routine/RoutineRunnerState.dart b/frontend/lib/Grounded/see/routine/RoutineRunnerState.dart index d623b30..6bbe8ab 100644 --- a/frontend/lib/Grounded/see/routine/RoutineRunnerState.dart +++ b/frontend/lib/Grounded/see/routine/RoutineRunnerState.dart @@ -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 implements ConnectRoutineRunner { ViewRoutineRunner? _model; + List _history = []; + late RoutineRun _run; Timer? _ticker; @@ -107,6 +111,7 @@ class RoutineRunnerState extends State } void _initiate() { + _model?.loadHistory(); _startTicker(); _syncBreath(); _model?.publishRun(_run); @@ -1152,6 +1157,8 @@ class RoutineRunnerState extends State ], ), ), + const SizedBox(height: 10), + _todayLine(held), const Spacer(), roundedCornerButton( "Log it", @@ -1167,8 +1174,70 @@ class RoutineRunnerState extends State ); } + /// 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 today = ProgressEngine.between( + _history, + ProgressEngine.dayOf(now), + ProgressEngine.dayOf(now).add(const Duration(days: 1))); + + final List withThis = + List.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 records) { + setState(() { + _history = records; + }); + } + @override void onLogged(bool held) { Navigator.pop(context, true); diff --git a/frontend/lib/Grounded/see/routine/ViewRoutineRunner.dart b/frontend/lib/Grounded/see/routine/ViewRoutineRunner.dart index f0f40d6..9179527 100644 --- a/frontend/lib/Grounded/see/routine/ViewRoutineRunner.dart +++ b/frontend/lib/Grounded/see/routine/ViewRoutineRunner.dart @@ -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 _remember(RoutineRun run) async { + final List 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(); diff --git a/frontend/lib/Grounded/see/settings/SettingsState.dart b/frontend/lib/Grounded/see/settings/SettingsState.dart index d965f6d..fa2d76c 100644 --- a/frontend/lib/Grounded/see/settings/SettingsState.dart +++ b/frontend/lib/Grounded/see/settings/SettingsState.dart @@ -17,6 +17,7 @@ import '../../utils/Colors.dart'; import '../../utils/Thresholds.dart'; import '../../utils/Validators.dart'; import '../login/Login.dart'; +import '../progress/TargetSetup.dart'; import 'ConnectSettings.dart'; import 'Settings.dart'; import 'ViewSettings.dart'; @@ -58,6 +59,11 @@ class SettingsState extends State implements ConnectSettings { Navigator.pop(context); } + void _onOpenTargets() { + GroundedNavigation() + .navigateToPageWithData(const TargetSetup(), context); + } + void _onToneSelected(ToneLevel tone) { _model?.changeTone(tone); } @@ -242,6 +248,34 @@ class SettingsState extends State implements ConnectSettings { height: 1.5, ), 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"), card( background: _user.sickMode ? colorStandingGoodBg : colorCard, diff --git a/frontend/lib/Grounded/utils/ProgressEngine.dart b/frontend/lib/Grounded/utils/ProgressEngine.dart new file mode 100644 index 0000000..aec743b --- /dev/null +++ b/frontend/lib/Grounded/utils/ProgressEngine.dart @@ -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 routines) { + int count = 0; + for (RoutineChain routine in routines) { + count = count + routine.days.length; + } + return count; + } + + static List between( + List records, DateTime start, DateTime end) { + return records + .where((record) => + !record.startedAt.isBefore(start) && record.startedAt.isBefore(end)) + .toList(); + } + + static int held(List records) => + records.where((record) => record.held).length; + + static int minutes(List 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 buckets( + List records, ProgressPeriod period, DateTime start) { + switch (period) { + case ProgressPeriod.Day: + return []; + case ProgressPeriod.Week: + return List.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 result = []; + 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 records, DateTime now) { + final Set 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; + } +} diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index 4b43340..8ebeed5 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -1062,7 +1062,7 @@ packages: source: hosted version: "0.7.11" timezone: - dependency: transitive + dependency: "direct main" description: name: timezone sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb" diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index 60484de..3228b71 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -46,6 +46,7 @@ dependencies: # ── Notifications ─────────────────────────────────────────────────────── flutter_local_notifications: ^21.0.0 + timezone: ^0.11.1 local_notifier: ^0.1.6 # ── UI ──────────────────────────────────────────────────────────────────