Add Progress over time and a targets and reminders setup

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

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

View File

@@ -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<Home> 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<Home> 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(

View File

@@ -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<RoutineChain> routines = getRoutineChainList(response.data);
connection.onRoutinesLoaded(routines);
_placeReminders(routines);
} catch (e) {
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 {
try {
final response = await getDataManager().getOverdueQueue(HistoryRequest(

View 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);
}

View File

@@ -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();
}

View 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();
}

View 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;
});
}
}

View 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();
}

View 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);
}
}

View 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);
}
}

View 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();
}
}

View File

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

View File

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

View File

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

View File

@@ -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<Settings> 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<Settings> 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,