Files
GroundedHelper/frontend/lib/Grounded/see/progress/TargetSetupState.dart
alvocool add94f621c 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
2026-11-08 20:17:58 +03:00

305 lines
9.2 KiB
Dart

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