707 lines
25 KiB
Dart
707 lines
25 KiB
Dart
import 'dart:convert';
|
||
|
||
import 'package:flutter/material.dart';
|
||
|
||
import 'api.dart';
|
||
|
||
// ---------- общие ----------
|
||
|
||
typedef Json = Map<String, dynamic>;
|
||
|
||
class FutureBuilderJson extends StatelessWidget {
|
||
const FutureBuilderJson(this.future, this.builder, {super.key});
|
||
|
||
final Future<dynamic>? future;
|
||
final Widget Function(dynamic data) builder;
|
||
|
||
@override
|
||
Widget build(BuildContext context) => FutureBuilder(
|
||
future: future,
|
||
builder: (context, snap) {
|
||
if (snap.connectionState != ConnectionState.done) {
|
||
return const Center(child: CircularProgressIndicator());
|
||
}
|
||
if (snap.hasError) {
|
||
return Center(child: Text('Ошибка: ${snap.error}'));
|
||
}
|
||
return builder(snap.data);
|
||
});
|
||
}
|
||
|
||
/// Выпадающий список атлетов (общий для 4 экранов).
|
||
class AthletePicker extends StatelessWidget {
|
||
const AthletePicker({super.key, required this.selected, required this.onChanged});
|
||
|
||
final int? selected;
|
||
final void Function(int?) onChanged;
|
||
|
||
@override
|
||
Widget build(BuildContext context) =>
|
||
FutureBuilderJson(Api.get('/api/admin/athletes'), (data) {
|
||
final items = (data['items'] as List).cast<Json>();
|
||
return DropdownButton<int>(
|
||
hint: const Text('Атлет'),
|
||
value: selected != null &&
|
||
items.any((a) => a['id'] == selected)
|
||
? selected
|
||
: null,
|
||
items: [
|
||
for (final a in items)
|
||
DropdownMenuItem(
|
||
value: a['id'] as int,
|
||
child: Text('${a['name']} (#${a['id']})'
|
||
'${a['isBlocked'] == true ? " [заблокирован]" : ""}')),
|
||
],
|
||
onChanged: onChanged,
|
||
);
|
||
});
|
||
}
|
||
|
||
// ---------- 1. Атлеты ----------
|
||
|
||
class AthletesPage extends StatefulWidget {
|
||
const AthletesPage({super.key});
|
||
|
||
@override
|
||
State<AthletesPage> createState() => _AthletesPageState();
|
||
}
|
||
|
||
class _AthletesPageState extends State<AthletesPage> {
|
||
Future<dynamic>? _future;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_reload();
|
||
}
|
||
|
||
void _reload() => setState(() => _future = Api.get('/api/admin/athletes'));
|
||
|
||
@override
|
||
Widget build(BuildContext context) =>
|
||
Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
||
Padding(
|
||
padding: const EdgeInsets.all(12),
|
||
child: Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: FilledButton.tonalIcon(
|
||
onPressed: () => _createDialog(context),
|
||
icon: const Icon(Icons.person_add),
|
||
label: const Text('Добавить атлета'),
|
||
),
|
||
),
|
||
),
|
||
Expanded(
|
||
child: FutureBuilderJson(_future, (data) {
|
||
final items = (data['items'] as List).cast<Json>();
|
||
return SingleChildScrollView(
|
||
scrollDirection: Axis.horizontal,
|
||
child: DataTable(columns: const [
|
||
DataColumn(label: Text('id')),
|
||
DataColumn(label: Text('Имя')),
|
||
DataColumn(label: Text('Заблокирован')),
|
||
DataColumn(label: Text('Создан')),
|
||
DataColumn(label: Text('Действия')),
|
||
], rows: [
|
||
for (final a in items)
|
||
DataRow(cells: [
|
||
DataCell(Text('${a['id']}')),
|
||
DataCell(Text('${a['name']}')),
|
||
DataCell(Text(a['isBlocked'] == true ? 'да' : 'нет')),
|
||
DataCell(Text('${a['createdAt']}'.split('T').first)),
|
||
DataCell(Row(children: [
|
||
TextButton(
|
||
onPressed: () => _newKeyDialog(context, a),
|
||
child: const Text('Смена ключа')),
|
||
Switch(
|
||
value: a['isBlocked'] == true,
|
||
onChanged: (blocked) async {
|
||
await Api.post(
|
||
'/api/admin/athletes/${a['id']}/block',
|
||
{'isBlocked': blocked});
|
||
_reload();
|
||
},
|
||
),
|
||
])),
|
||
]),
|
||
]),
|
||
);
|
||
}),
|
||
),
|
||
]);
|
||
|
||
Future<void> _createDialog(BuildContext context) async {
|
||
final nameController = TextEditingController();
|
||
final key = await showDialog<String>(
|
||
context: context,
|
||
builder: (context) => AlertDialog(
|
||
title: const Text('Новый атлет'),
|
||
content: TextField(
|
||
controller: nameController,
|
||
decoration: const InputDecoration(labelText: 'Имя')),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(context), child: const Text('Отмена')),
|
||
FilledButton(
|
||
onPressed: () async {
|
||
final created = await Api.post('/api/admin/athletes',
|
||
{'name': nameController.text});
|
||
if (context.mounted) Navigator.pop(context, created['apiKey']);
|
||
},
|
||
child: const Text('Создать')),
|
||
],
|
||
),
|
||
);
|
||
_reload();
|
||
if (key is String && mounted) {
|
||
await showDialog(
|
||
context: context,
|
||
builder: (context) => AlertDialog(
|
||
title: const Text('API-ключ создан'),
|
||
content: Text('Ключ показывается ОДИН раз:\n\n$key\n\n'
|
||
'Передайте его атлету для настройки телефона.'),
|
||
actions: [
|
||
FilledButton(
|
||
onPressed: () => Navigator.pop(context),
|
||
child: const Text('Понятно')),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
Future<void> _newKeyDialog(BuildContext context, Json athlete) async {
|
||
final reissued = await Api.post('/api/admin/athletes/${athlete['id']}/key');
|
||
if (!mounted) return;
|
||
await showDialog(
|
||
context: context,
|
||
builder: (context) => AlertDialog(
|
||
title: const Text('Новый API-ключ'),
|
||
content: Text('Старый ключ больше не работает.\n\n${reissued['apiKey']}'),
|
||
actions: [
|
||
FilledButton(
|
||
onPressed: () => Navigator.pop(context), child: const Text('Ок')),
|
||
],
|
||
),
|
||
);
|
||
_reload();
|
||
}
|
||
}
|
||
|
||
// ---------- 2. Тренировки ----------
|
||
|
||
class WorkoutsPage extends StatefulWidget {
|
||
const WorkoutsPage({super.key});
|
||
|
||
@override
|
||
State<WorkoutsPage> createState() => _WorkoutsPageState();
|
||
}
|
||
|
||
class _WorkoutsPageState extends State<WorkoutsPage> {
|
||
int? _athleteId;
|
||
String? _from, _to;
|
||
int _offset = 0;
|
||
static const _limit = 50;
|
||
|
||
String get _query {
|
||
var query = 'athleteId=${_athleteId ?? ''}&limit=$_limit&offset=$_offset';
|
||
if (_from != null && _from!.isNotEmpty) query += '&dateFrom=$_from';
|
||
if (_to != null && _to!.isNotEmpty) query += '&dateTo=$_to';
|
||
return query;
|
||
}
|
||
|
||
String get _csvQuery {
|
||
var query = 'athleteId=${_athleteId ?? ''}';
|
||
if (_from != null && _from!.isNotEmpty) query += '&dateFrom=$_from';
|
||
if (_to != null && _to!.isNotEmpty) query += '&dateTo=$_to';
|
||
return query;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.all(12),
|
||
child: Wrap(spacing: 16, children: [
|
||
AthletePicker(
|
||
selected: _athleteId,
|
||
onChanged: (id) => setState(() {
|
||
_athleteId = id;
|
||
_offset = 0;
|
||
})),
|
||
SizedBox(
|
||
width: 130,
|
||
child: TextField(
|
||
onChanged: (v) => setState(() { _from = v; _offset = 0; }),
|
||
decoration: const InputDecoration(
|
||
hintText: 'с (ГГГГ-ММ-ДД)'))),
|
||
SizedBox(
|
||
width: 130,
|
||
child: TextField(
|
||
onChanged: (v) => setState(() { _to = v; _offset = 0; }),
|
||
decoration: const InputDecoration(
|
||
hintText: 'по (ГГГГ-ММ-ДД)'))),
|
||
OutlinedButton.icon(
|
||
onPressed: () => Api.downloadCsv('/api/admin/workouts.csv?$_csvQuery'),
|
||
icon: const Icon(Icons.download),
|
||
label: const Text('CSV'),
|
||
),
|
||
]),
|
||
),
|
||
Expanded(
|
||
child: FutureBuilderJson(Api.get('/api/admin/workouts?$_query'),
|
||
(data) {
|
||
final items = (data['items'] as List).cast<Json>();
|
||
final total = data['total'] as int;
|
||
return Column(children: [
|
||
Expanded(
|
||
child: SingleChildScrollView(
|
||
scrollDirection: Axis.horizontal,
|
||
child: DataTable(columns: const [
|
||
DataColumn(label: Text('Дата')),
|
||
DataColumn(label: Text('Атлет')),
|
||
DataColumn(label: Text('Мин')),
|
||
DataColumn(label: Text('Ср. пульс')),
|
||
DataColumn(label: Text('Подходы')),
|
||
DataColumn(label: Text('Adherence')),
|
||
DataColumn(label: Text('Вид')),
|
||
], rows: [
|
||
for (final w in items)
|
||
DataRow(onSelectChanged: (_) => _openCard(w['workoutId']), cells: [
|
||
DataCell(Text('${w['startedAt']}'.split('T').first)),
|
||
DataCell(Text('${w['athleteName']}')),
|
||
DataCell(Text('${w['durationMin'] ?? '—'}')),
|
||
DataCell(Text('${w['avgHr'] ?? '—'}')),
|
||
DataCell(Text('${w['setsCount']}')),
|
||
DataCell(Text('${w['adherence'] ?? '—'}')),
|
||
DataCell(Text('${w['sessionKind']}')),
|
||
]),
|
||
]),
|
||
),
|
||
),
|
||
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||
TextButton(
|
||
onPressed: _offset >= _limit
|
||
? () => setState(() => _offset -= _limit)
|
||
: null,
|
||
child: const Text('← Назад')),
|
||
Text(' ${_offset + 1}–${_offset + items.length} из $total '),
|
||
TextButton(
|
||
onPressed: _offset + _limit < total
|
||
? () => setState(() => _offset += _limit)
|
||
: null,
|
||
child: const Text('Вперёд →')),
|
||
]),
|
||
]);
|
||
}),
|
||
),
|
||
],
|
||
);
|
||
|
||
Future<void> _openCard(dynamic workoutId) async {
|
||
final card = await Api.get('/api/admin/workouts/$workoutId');
|
||
if (!mounted) return;
|
||
await showDialog(
|
||
context: context,
|
||
builder: (context) => AlertDialog(
|
||
title: Text('Тренировка ${card['startedAt']}'.split('T').first),
|
||
content: SizedBox(
|
||
width: 720,
|
||
height: 480,
|
||
child: DefaultTabController(
|
||
length: 3,
|
||
child: Column(children: [
|
||
const TabBar(tabs: [
|
||
Tab(text: 'План/факт'),
|
||
Tab(text: 'Подходы (.fit)'),
|
||
Tab(text: 'Пульсограмма'),
|
||
]),
|
||
Expanded(
|
||
child: TabBarView(children: [
|
||
_planFact(card),
|
||
_timeline(card),
|
||
_hrCurve(card),
|
||
]),
|
||
),
|
||
]),
|
||
),
|
||
),
|
||
actions: [
|
||
FilledButton(
|
||
onPressed: () => Navigator.pop(context), child: const Text('Закрыть')),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _planFact(Json card) {
|
||
final planFact = card['planFact'] as Json?;
|
||
final plan = (planFact?['plan'] as List? ?? []).cast<Json>();
|
||
if (plan.isEmpty) {
|
||
return const Center(child: Text('План на этот день не найден'));
|
||
}
|
||
return DataTable(columns: const [
|
||
DataColumn(label: Text('Упражнение')),
|
||
DataColumn(label: Text('План')),
|
||
DataColumn(label: Text('Факт')),
|
||
], rows: [
|
||
for (final p in plan)
|
||
DataRow(cells: [
|
||
DataCell(Text('${p['exercise']}')),
|
||
DataCell(Text(
|
||
'${p['plannedSets']}×${p['plannedReps'] ?? '—'} @ ${p['plannedWeight'] ?? '—'} кг')),
|
||
DataCell(Text(
|
||
'${p['factSets']}×${p['factReps'] ?? '—'} @ ${p['factWeight'] ?? '—'} кг')),
|
||
]),
|
||
]);
|
||
}
|
||
|
||
Widget _timeline(Json card) {
|
||
final timeline = (card['timeline'] as List? ?? []).cast<Json>();
|
||
if (timeline.isEmpty) {
|
||
return const Center(child: Text('Данных .fit нет'));
|
||
}
|
||
return ListView(children: [
|
||
for (final t in timeline)
|
||
ListTile(
|
||
title: Text('Подход ${t['index']}: ${t['durationS']} c'),
|
||
subtitle: Text('старт +${t['startOffsetS']} c · '
|
||
'пульс ${t['avgHr'] ?? '—'}/${t['peakHr'] ?? '—'} · '
|
||
'отдых ${t['restAfterS']} c'),
|
||
),
|
||
]);
|
||
}
|
||
|
||
Widget _hrCurve(Json card) {
|
||
final curve = (card['hrCurve'] as List? ?? []).cast<Json>();
|
||
if (curve.isEmpty) {
|
||
return const Center(child: Text('Данных .fit нет'));
|
||
}
|
||
return CustomPaint(
|
||
painter: _HrCurvePainter(curve),
|
||
size: Size.infinite,
|
||
);
|
||
}
|
||
}
|
||
|
||
class _HrCurvePainter extends CustomPainter {
|
||
_HrCurvePainter(this.points);
|
||
|
||
final List<Json> points;
|
||
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
if (points.length < 2) return;
|
||
final maxT = (points.last['t'] as num).toDouble();
|
||
final minHr = points
|
||
.map((p) => (p['hr'] as num).toDouble())
|
||
.reduce((a, b) => a < b ? a : b);
|
||
final maxHr = points
|
||
.map((p) => (p['hr'] as num).toDouble())
|
||
.reduce((a, b) => a > b ? a : b);
|
||
final spanHr = (maxHr - minHr).clamp(1.0, double.infinity);
|
||
Offset pointAt(Json p) => Offset(
|
||
(p['t'] as num).toDouble() / maxT * size.width,
|
||
size.height -
|
||
((p['hr'] as num).toDouble() - minHr) / spanHr * size.height,
|
||
);
|
||
final path = Path()..moveTo(0, pointAt(points.first).dy);
|
||
for (final p in points) {
|
||
final o = pointAt(p);
|
||
path.lineTo(o.dx, o.dy);
|
||
}
|
||
final paintLine = Paint()
|
||
..color = const Color(0xFF4FC3F7)
|
||
..style = PaintingStyle.stroke
|
||
..strokeWidth = 2;
|
||
canvas.drawPath(path, paintLine);
|
||
final tp = TextPainter(textDirection: TextDirection.ltr);
|
||
tp.text = const TextSpan(text: '');
|
||
tp.layout();
|
||
tp.paint(canvas, Offset.zero);
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
||
}
|
||
|
||
// ---------- 3. Программы ----------
|
||
|
||
class ProgramsPage extends StatefulWidget {
|
||
const ProgramsPage({super.key});
|
||
|
||
@override
|
||
State<ProgramsPage> createState() => _ProgramsPageState();
|
||
}
|
||
|
||
class _ProgramsPageState extends State<ProgramsPage> {
|
||
int? _athleteId;
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.all(12),
|
||
child: AthletePicker(
|
||
selected: _athleteId, onChanged: (id) => setState(() => _athleteId = id)),
|
||
),
|
||
Expanded(
|
||
child: _athleteId == null
|
||
? const Center(child: Text('Выберите атлета'))
|
||
: FutureBuilderJson(
|
||
Api.get('/api/admin/programs?athleteId=$_athleteId'),
|
||
(data) {
|
||
final items = (data['items'] as List).cast<Json>();
|
||
if (items.isEmpty) {
|
||
return const Center(child: Text('Версий нет'));
|
||
}
|
||
return ListView(children: [
|
||
for (final v in items)
|
||
ListTile(
|
||
title: Text('v${v['version']} · ${v['status']} · '
|
||
'${v['author']}'),
|
||
subtitle: Text('${v['rationale']}'),
|
||
trailing: Wrap(children: [
|
||
if (v['author'] == 'rules' && v['status'] == 'draft')
|
||
TextButton(
|
||
onPressed: () => _confirm(
|
||
'Применить предложение правил?',
|
||
() => Api.post(
|
||
'/api/admin/programs/${v['versionId']}/apply?athleteId=$_athleteId'),
|
||
reload: true),
|
||
child: const Text('Применить'),
|
||
),
|
||
if (v['status'] == 'draft')
|
||
TextButton(
|
||
onPressed: () => _confirm(
|
||
'Активировать v${v['version']}?',
|
||
() => Api.post(
|
||
'/api/admin/programs/${v['versionId']}/activate?athleteId=$_athleteId'),
|
||
reload: true),
|
||
child: const Text('Активировать'),
|
||
),
|
||
TextButton(
|
||
onPressed: () => _showBody(
|
||
v['versionId'], v['version']),
|
||
child: const Text('Body'),
|
||
),
|
||
]),
|
||
),
|
||
]);
|
||
}),
|
||
),
|
||
],
|
||
);
|
||
|
||
Future<void> _confirm(String title, Future<dynamic> Function() action,
|
||
{bool reload = false}) async {
|
||
final ok = await showDialog<bool>(
|
||
context: context,
|
||
builder: (context) => AlertDialog(
|
||
title: Text(title),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(context, false),
|
||
child: const Text('Отмена')),
|
||
FilledButton(
|
||
onPressed: () => Navigator.pop(context, true),
|
||
child: const Text('Да')),
|
||
],
|
||
),
|
||
);
|
||
if (ok == true) {
|
||
await action();
|
||
if (reload && mounted) setState(() {});
|
||
}
|
||
}
|
||
|
||
Future<void> _showBody(dynamic versionId, dynamic version) async {
|
||
final v = await Api.get(
|
||
'/api/admin/programs/$versionId?athleteId=$_athleteId');
|
||
if (!mounted) return;
|
||
final pretty = const JsonEncoder.withIndent(' ')
|
||
.convert(jsonDecode(v['body'] as String));
|
||
await showDialog(
|
||
context: context,
|
||
builder: (context) => AlertDialog(
|
||
title: Text('Body v$version'),
|
||
content: SizedBox(
|
||
width: 640,
|
||
height: 480,
|
||
child: SingleChildScrollView(child: SelectableText(pretty))),
|
||
actions: [
|
||
FilledButton(
|
||
onPressed: () => Navigator.pop(context),
|
||
child: const Text('Закрыть')),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ---------- 4. Метрики ----------
|
||
|
||
class MetricsPage extends StatefulWidget {
|
||
const MetricsPage({super.key});
|
||
|
||
@override
|
||
State<MetricsPage> createState() => _MetricsPageState();
|
||
}
|
||
|
||
class _MetricsPageState extends State<MetricsPage> {
|
||
int? _athleteId;
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.all(12),
|
||
child: AthletePicker(
|
||
selected: _athleteId, onChanged: (id) => setState(() => _athleteId = id)),
|
||
),
|
||
Expanded(
|
||
child: _athleteId == null
|
||
? const Center(child: Text('Выберите атлета'))
|
||
: FutureBuilderJson(
|
||
Api.get('/api/admin/metrics?athleteId=$_athleteId'),
|
||
(data) {
|
||
final weeks = (data['weekly'] as List).cast<Json>();
|
||
final workouts = (data['workouts'] as List).cast<Json>();
|
||
return Column(children: [
|
||
if (weeks.isNotEmpty)
|
||
SizedBox(
|
||
height: 160,
|
||
child: CustomPaint(
|
||
painter: _WeeklyChartPainter(weeks),
|
||
size: Size.infinite,
|
||
),
|
||
),
|
||
Expanded(
|
||
child: DataTable(columns: const [
|
||
DataColumn(label: Text('Дата')),
|
||
DataColumn(label: Text('Adherence')),
|
||
DataColumn(label: Text('Тоннаж')),
|
||
DataColumn(label: Text('Флаги')),
|
||
], rows: [
|
||
for (final m in workouts)
|
||
DataRow(cells: [
|
||
DataCell(Text('${m['date'] ?? '—'}')),
|
||
DataCell(Text('${m['adherence']}')),
|
||
DataCell(Text('${m['tonnage']}')),
|
||
DataCell(Text(_flags(m))),
|
||
]),
|
||
]),
|
||
),
|
||
]);
|
||
}),
|
||
),
|
||
],
|
||
);
|
||
|
||
String _flags(Json m) {
|
||
final flags = m['redFlags'];
|
||
if (flags is List) return flags.isEmpty ? '—' : '${flags.length}';
|
||
return '—';
|
||
}
|
||
}
|
||
|
||
class _WeeklyChartPainter extends CustomPainter {
|
||
_WeeklyChartPainter(this.weeks);
|
||
|
||
final List<Json> weeks;
|
||
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
if (weeks.isEmpty) return;
|
||
final maxTonnage = weeks
|
||
.map((w) => (w['tonnage'] as num).toDouble())
|
||
.reduce((a, b) => a > b ? a : b);
|
||
final barWidth = size.width / weeks.length * 0.6;
|
||
final step = size.width / weeks.length;
|
||
final barPaint = Paint()..color = const Color(0x804FC3F7);
|
||
final tp = TextPainter(textDirection: TextDirection.ltr);
|
||
for (var i = 0; i < weeks.length; i++) {
|
||
final tonnage = (weeks[i]['tonnage'] as num).toDouble();
|
||
final h = tonnage / maxTonnage * (size.height - 40);
|
||
canvas.drawRect(
|
||
Rect.fromLTWH(i * step + (step - barWidth) / 2,
|
||
size.height - 24 - h, barWidth, h),
|
||
barPaint);
|
||
// подписи текстом (цвет дублируется значением — UI/UX-стандарт)
|
||
tp.text = TextSpan(
|
||
text: '${weeks[i]['week']}'.split('-').last,
|
||
style: const TextStyle(color: Color(0xFFB0BEC5), fontSize: 11));
|
||
tp.layout();
|
||
tp.paint(canvas, Offset(i * step + step / 2 - tp.width / 2, size.height - 20));
|
||
tp.text = TextSpan(
|
||
text: '${weeks[i]['avgAdherence']}%',
|
||
style: const TextStyle(color: Color(0xFF80CBC4), fontSize: 11));
|
||
tp.layout();
|
||
tp.paint(canvas, Offset(i * step + step / 2 - tp.width / 2, size.height - 40));
|
||
}
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
||
}
|
||
|
||
// ---------- 5. План/факт ----------
|
||
|
||
class PlanPage extends StatefulWidget {
|
||
const PlanPage({super.key});
|
||
|
||
@override
|
||
State<PlanPage> createState() => _PlanPageState();
|
||
}
|
||
|
||
class _PlanPageState extends State<PlanPage> {
|
||
int? _athleteId;
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.all(12),
|
||
child: AthletePicker(
|
||
selected: _athleteId, onChanged: (id) => setState(() => _athleteId = id)),
|
||
),
|
||
Expanded(
|
||
child: _athleteId == null
|
||
? const Center(child: Text('Выберите атлета'))
|
||
: FutureBuilderJson(
|
||
Api.get('/api/admin/plan?athleteId=$_athleteId'), (data) {
|
||
if (data['activeVersion'] == null) {
|
||
return const Center(child: Text('Активной версии нет'));
|
||
}
|
||
final weeks = (data['weeks'] as List).cast<Json>();
|
||
return ListView(children: [
|
||
for (final w in weeks)
|
||
Card(
|
||
child: ListTile(
|
||
title: Text(
|
||
'Неделя ${w['week']} (${w['from']} — ${w['to']})'),
|
||
subtitle: Text(
|
||
'запланировано ${w['planned']} · '
|
||
'выполнено ${w['performed']} · '
|
||
'пропущено ${w['missed']}'),
|
||
trailing: Text(
|
||
'${w['avgAdherence'] ?? '—'}%',
|
||
style: TextStyle(
|
||
color: (w['missed'] as int) > 0
|
||
? Colors.orangeAccent
|
||
: Colors.greenAccent,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
]);
|
||
}),
|
||
),
|
||
],
|
||
);
|
||
}
|