Files
GroundedHelper/frontend/lib/Grounded/see/progress/ProgressState.dart
alvocool d3431ca5de Switch to Inter, round the corners softly and make the nav switch tabs
Inter replaces General Sans (Inter Display for titles and big numbers).
Corners come back from before the squaring commit, scaled to a soft set
(16 cards, 14 buttons, 12 rows, 8 chips) and applied to the screens built
since. The nav is now a cluster of round frosted buttons that switches
Today, Routines, Goals, Groups and Progress in place inside Home: no
pushed pages, no back arrows, and Android back returns to Today.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
2026-11-09 07:08:46 +03:00

493 lines
16 KiB
Dart

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: widget.tab ? null : _onBack,
bottomSpace: widget.tab ? 130 : 40,
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,
),
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),
decoration: BoxDecoration(
color: colorNight,
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
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),
decoration: BoxDecoration(
color: tone.withValues(alpha: 0.12),
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
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,
decoration: BoxDecoration(
color: isCurrent
? colorNight
: (future ? colorMuted : colorChartGrid),
borderRadius: const BorderRadius.all(Radius.circular(4)),
),
),
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),
decoration: BoxDecoration(
color: colorCard,
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
child: Row(
children: [
Container(
width: 42,
height: 42,
alignment: Alignment.center,
decoration: BoxDecoration(
color: kindColor(kind).withValues(alpha: 0.14),
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
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;
});
}
}