Files
PeachDeployments/lib/Peach/designs/ActivityChart.dart
alvocool 38881d1fe1 Add app source and tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 10:15:30 +03:00

225 lines
6.5 KiB
Dart

import 'package:flutter/material.dart';
import '../about/internal/application/TextType.dart';
import '../about/internal/application/DeploymentRecord.dart';
import '../utils/Colors.dart';
import 'text/Text.dart';
enum _Range { week, month, year }
class _Bucket {
final String label;
final int count;
_Bucket(this.label, this.count);
}
/// "Compute Activity" style card: a bar chart of deployment counts over time,
/// with the peak column highlighted, and a Week / Month / Year toggle.
class ActivityChart extends StatefulWidget {
final List<DeploymentRecord> history;
const ActivityChart({super.key, required this.history});
@override
State<ActivityChart> createState() => _ActivityChartState();
}
class _ActivityChartState extends State<ActivityChart> {
_Range _range = _Range.week;
List<_Bucket> _buckets() {
final now = DateTime.now();
final h = widget.history;
int between(DateTime a, DateTime b) =>
h.where((r) => !r.time.isBefore(a) && r.time.isBefore(b)).length;
const wd = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const mo = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
switch (_range) {
case _Range.week:
final today = DateTime(now.year, now.month, now.day);
return List.generate(7, (i) {
final d = today.subtract(Duration(days: 6 - i));
return _Bucket(
wd[d.weekday - 1],
between(d, d.add(const Duration(days: 1))),
);
});
case _Range.month:
// last 6 weeks
final startOfWeek = DateTime(
now.year,
now.month,
now.day,
).subtract(Duration(days: now.weekday - 1));
return List.generate(6, (i) {
final s = startOfWeek.subtract(Duration(days: (5 - i) * 7));
final e = s.add(const Duration(days: 7));
return _Bucket('${s.month}/${s.day}', between(s, e));
});
case _Range.year:
// last 6 months
return List.generate(6, (i) {
final m = DateTime(now.year, now.month - (5 - i), 1);
final next = DateTime(m.year, m.month + 1, 1);
return _Bucket(mo[m.month - 1], between(m, next));
});
}
}
@override
Widget build(BuildContext context) {
final jp = context.jp;
final buckets = _buckets();
final max = buckets.fold(0, (m, b) => b.count > m ? b.count : m);
final peakIdx = buckets.indexWhere((b) => b.count == max && max > 0);
final total = buckets.fold(0, (n, b) => n + b.count);
return Container(
padding: const EdgeInsets.all(18),
decoration: PeachTheme.panel(context),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
text(
'Deployment activity',
16,
TextType.Bold,
color: jp.text,
weight: FontWeight.w800,
),
const SizedBox(height: 2),
text(
'$total deploys this period',
12.5,
TextType.Regular,
color: jp.textDim,
weight: FontWeight.w400,
),
],
),
),
_toggle(jp),
],
),
const SizedBox(height: 20),
SizedBox(
height: 190,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (var i = 0; i < buckets.length; i++)
Expanded(child: _bar(jp, buckets[i], max, i == peakIdx)),
],
),
),
],
),
);
}
Widget _bar(JpColors jp, _Bucket b, int max, bool peak) {
final frac = max == 0 ? 0.0 : b.count / max;
final h = 12 + frac * 130;
return Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
text(
'${b.count}',
12,
TextType.Regular,
color: peak ? PeachTheme.seed : jp.textDim,
weight: FontWeight.w800,
),
const SizedBox(height: 6),
Container(
width: 26,
height: h,
decoration: BoxDecoration(
gradient: peak ? PeachTheme.brandGradient : null,
color: peak ? null : jp.surface2,
borderRadius: BorderRadius.circular(9),
boxShadow: peak
? PeachTheme.glow(PeachTheme.seed, opacity: 0.35, blur: 16)
: null,
),
),
const SizedBox(height: 8),
text(
b.label,
11,
TextType.Regular,
color: peak ? jp.text : jp.textFaint,
maxLines: 1,
overflow: TextOverflow.clip,
weight: peak ? FontWeight.w700 : FontWeight.w500,
),
],
);
}
Widget _toggle(JpColors jp) {
Widget seg(_Range r, String label) {
final active = _range == r;
return GestureDetector(
onTap: () => setState(() => _range = r),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
decoration: BoxDecoration(
color: active ? jp.surface : Colors.transparent,
borderRadius: BorderRadius.circular(9),
boxShadow: active
? [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 6,
offset: const Offset(0, 2),
),
]
: null,
),
child: text(
label,
12.5,
TextType.Regular,
color: active ? jp.text : jp.textDim,
weight: active ? FontWeight.w700 : FontWeight.w500,
),
),
);
}
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: jp.surface2,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
seg(_Range.week, 'Week'),
seg(_Range.month, 'Month'),
seg(_Range.year, 'Year'),
],
),
);
}
}