mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 13:27:39 +01:00
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWYndZhdUrRGFEjBazQ
154 lines
4.8 KiB
Dart
154 lines
4.8 KiB
Dart
/// The weight-history line chart with 30d / 90d / 1y / all range chips.
|
|
///
|
|
/// X is days since the first plotted point, so gaps between weigh-ins are
|
|
/// drawn to scale rather than evenly spaced.
|
|
library;
|
|
|
|
import 'package:diet_guard_app/ui/cues.dart';
|
|
import 'package:diet_guard_app/ui/theme.dart';
|
|
import 'package:fl_chart/fl_chart.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
/// Selectable chart ranges, `(spoken name, days)`; null days = everything.
|
|
const List<(String, int?)> weightChartRanges = [
|
|
('30d', 30),
|
|
('90d', 90),
|
|
('1y', 365),
|
|
('All', null),
|
|
];
|
|
|
|
/// Filters [series] (oldest first) to the last [days] before [today].
|
|
List<(String, double)> seriesInRange(
|
|
List<(String, double)> series,
|
|
int? days,
|
|
DateTime today,
|
|
) {
|
|
if (days == null) return series;
|
|
final cutoff = DateTime(today.year, today.month, today.day - days);
|
|
return [
|
|
for (final point in series)
|
|
if (!DateTime.parse(point.$1).isBefore(cutoff)) point,
|
|
];
|
|
}
|
|
|
|
/// A line chart of weights with its range selector.
|
|
class WeightChart extends StatefulWidget {
|
|
/// Creates the chart for [series] (`(YYYY-MM-DD, kg)`, oldest first).
|
|
const WeightChart({required this.series, this.today, super.key});
|
|
|
|
/// Weights to plot.
|
|
final List<(String, double)> series;
|
|
|
|
/// Overrides "today" for tests.
|
|
final DateTime? today;
|
|
|
|
@override
|
|
State<WeightChart> createState() => _WeightChartState();
|
|
}
|
|
|
|
class _WeightChartState extends State<WeightChart> {
|
|
int? _days = 90;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final points = seriesInRange(
|
|
widget.series,
|
|
_days,
|
|
widget.today ?? DateTime.now(),
|
|
);
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Wrap(
|
|
spacing: AppSpacing.sm,
|
|
children: [
|
|
for (final (spoken, days) in weightChartRanges)
|
|
SpokenLabel(
|
|
semanticLabel: spoken,
|
|
child: ChoiceChip(
|
|
// Days as a bare numeral; "everything" as the infinity glyph.
|
|
label: ExcludeSemantics(
|
|
child: days == null
|
|
? const Icon(Icons.all_inclusive, size: 18)
|
|
: Text('$days'),
|
|
),
|
|
selected: _days == days,
|
|
onSelected: (_) => setState(() => _days = days),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: AppSpacing.sm),
|
|
SizedBox(
|
|
height: 200,
|
|
child: points.length < 2
|
|
? Center(
|
|
child: Icon(
|
|
Icons.show_chart,
|
|
size: 64,
|
|
color: Theme.of(context).colorScheme.outline,
|
|
semanticLabel: 'Need two readings for a graph.',
|
|
),
|
|
)
|
|
: _chart(context, points),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _chart(BuildContext context, List<(String, double)> points) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
final origin = DateTime.parse(points.first.$1);
|
|
double x(String day) =>
|
|
DateTime.parse(day).difference(origin).inHours / 24.0;
|
|
final spots = [for (final (day, kg) in points) FlSpot(x(day), kg)];
|
|
final kgs = [for (final (_, kg) in points) kg];
|
|
final low = kgs.reduce((a, b) => a < b ? a : b);
|
|
final high = kgs.reduce((a, b) => a > b ? a : b);
|
|
final span = x(points.last.$1).clamp(1, double.infinity).toDouble();
|
|
return LineChart(
|
|
LineChartData(
|
|
minY: (low - 1).floorToDouble(),
|
|
maxY: (high + 1).ceilToDouble(),
|
|
gridData: const FlGridData(drawVerticalLine: false),
|
|
borderData: FlBorderData(show: false),
|
|
titlesData: FlTitlesData(
|
|
topTitles: const AxisTitles(),
|
|
rightTitles: const AxisTitles(),
|
|
leftTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: true, reservedSize: 40),
|
|
),
|
|
bottomTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
reservedSize: 28,
|
|
interval: span / 3,
|
|
getTitlesWidget: (value, meta) => SideTitleWidget(
|
|
meta: meta,
|
|
child: Text(
|
|
_dayLabel(origin, value),
|
|
style: const TextStyle(fontSize: AppTextSize.caption),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
lineBarsData: [
|
|
LineChartBarData(
|
|
spots: spots,
|
|
color: scheme.primary,
|
|
barWidth: 3,
|
|
dotData: FlDotData(show: spots.length <= 60),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
String _dayLabel(DateTime origin, double offset) {
|
|
final day = DateTime(origin.year, origin.month, origin.day + offset.round());
|
|
String two(int v) => v.toString().padLeft(2, '0');
|
|
return '${two(day.day)}.${two(day.month)}';
|
|
}
|