VEU.WathBody/3-app/3-web/admin-web/lib/screens.dart

707 lines
25 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
),
),
),
),
]);
}),
),
],
);
}