import 'dart:convert'; import 'package:flutter/material.dart'; import 'api.dart'; // ---------- общие ---------- typedef Json = Map; class FutureBuilderJson extends StatelessWidget { const FutureBuilderJson(this.future, this.builder, {super.key}); final Future? 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(); return DropdownButton( 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 createState() => _AthletesPageState(); } class _AthletesPageState extends State { Future? _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(); 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 _createDialog(BuildContext context) async { final nameController = TextEditingController(); final key = await showDialog( 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 _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 createState() => _WorkoutsPageState(); } class _WorkoutsPageState extends State { 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(); 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 _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(); 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(); 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(); 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 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 createState() => _ProgramsPageState(); } class _ProgramsPageState extends State { 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(); 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 _confirm(String title, Future Function() action, {bool reload = false}) async { final ok = await showDialog( 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 _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 createState() => _MetricsPageState(); } class _MetricsPageState extends State { 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(); final workouts = (data['workouts'] as List).cast(); 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 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 createState() => _PlanPageState(); } class _PlanPageState extends State { 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(); 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, ), ), ), ), ]); }), ), ], ); }