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:
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user