Files
trainhub-flutter/lib/presentation/grading/grading_page.dart
Kazimierz Ciołek ebb7426c67
Some checks failed
Build Linux App / build (push) Failing after 1m12s
Poprawki
2026-07-07 22:03:36 +02:00

321 lines
10 KiB
Dart

import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
import 'package:trainhub_flutter/data/services/grading_service.dart';
import 'package:trainhub_flutter/injection.dart';
/// Belt grading preparation: the ITF colored-belt syllabus as a checklist.
/// Pick the belt you're testing for, tick off requirements as they're ready.
@RoutePage()
class GradingPage extends StatefulWidget {
const GradingPage({super.key});
@override
State<GradingPage> createState() => _GradingPageState();
}
class _GradingPageState extends State<GradingPage> {
late final GradingService _service = getIt<GradingService>();
@override
void initState() {
super.initState();
_service.addListener(_onChanged);
_service.ensureLoaded();
}
@override
void dispose() {
_service.removeListener(_onChanged);
super.dispose();
}
void _onChanged() {
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
final currentId = _service.currentBeltId;
final current = kBeltSyllabus.where((b) => b.id == currentId).firstOrNull;
return ListView(
padding: const EdgeInsets.all(UIConstants.pagePadding),
children: [
Text(
'GRADING PREP',
style: GoogleFonts.chakraPetch(
fontSize: 22,
fontWeight: FontWeight.w700,
letterSpacing: 1,
color: AppColors.textPrimary,
),
),
const SizedBox(height: UIConstants.spacing4),
Text(
current == null
? 'Tap a belt and set it as your current goal.'
: 'Preparing for ${current.gup} · ${current.name}',
style: GoogleFonts.inter(
fontSize: 13,
color: AppColors.textSecondary,
),
),
if (current != null) ...[
const SizedBox(height: UIConstants.spacing16),
_ReadinessBanner(belt: current, service: _service),
],
const SizedBox(height: UIConstants.spacing24),
for (final belt in kBeltSyllabus) ...[
_BeltCard(
belt: belt,
service: _service,
isCurrent: belt.id == currentId,
),
const SizedBox(height: UIConstants.spacing12),
],
],
);
}
}
// ---------------------------------------------------------------------------
// Readiness banner for the current belt
// ---------------------------------------------------------------------------
class _ReadinessBanner extends StatelessWidget {
const _ReadinessBanner({required this.belt, required this.service});
final BeltLevel belt;
final GradingService service;
@override
Widget build(BuildContext context) {
final progress = service.progress(belt);
final percent = (progress * 100).round();
final ready = progress >= 1.0;
return Container(
padding: const EdgeInsets.all(UIConstants.cardPadding),
decoration: BoxDecoration(
color: AppColors.surfaceContainer,
borderRadius: UIConstants.cardBorderRadius,
border: Border.all(
color: ready ? AppColors.success : AppColors.accentBorder,
),
),
child: Row(
children: [
_BeltChip(belt: belt, size: 40),
const SizedBox(width: UIConstants.spacing16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
ready ? 'READY FOR GRADING' : 'GRADING READINESS',
style: GoogleFonts.jetBrainsMono(
fontSize: 10,
fontWeight: FontWeight.w600,
letterSpacing: 1.5,
color: ready ? AppColors.success : AppColors.accent,
),
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(
value: progress,
minHeight: 6,
backgroundColor: AppColors.surfaceContainerHigh,
valueColor: AlwaysStoppedAnimation<Color>(
ready ? AppColors.success : AppColors.accent,
),
),
),
],
),
),
const SizedBox(width: UIConstants.spacing16),
Text(
'$percent%',
style: GoogleFonts.jetBrainsMono(
fontSize: 22,
fontWeight: FontWeight.w700,
color: ready ? AppColors.success : AppColors.textPrimary,
),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Belt card with expandable requirement checklist
// ---------------------------------------------------------------------------
class _BeltCard extends StatelessWidget {
const _BeltCard({
required this.belt,
required this.service,
required this.isCurrent,
});
final BeltLevel belt;
final GradingService service;
final bool isCurrent;
@override
Widget build(BuildContext context) {
final checked = service.checkedCount(belt.id);
final total = belt.requirements.length;
return Container(
decoration: BoxDecoration(
color: AppColors.surfaceContainer,
borderRadius: UIConstants.cardBorderRadius,
border: Border.all(
color: isCurrent ? AppColors.accentBorder : AppColors.border,
),
),
child: Theme(
// Remove the ExpansionTile divider lines.
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
initiallyExpanded: isCurrent,
tilePadding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing16,
vertical: 4,
),
leading: _BeltChip(belt: belt, size: 34),
title: Row(
children: [
Text(
belt.gup,
style: GoogleFonts.chakraPetch(
fontSize: 14,
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
color: AppColors.textPrimary,
),
),
const SizedBox(width: UIConstants.spacing12),
Expanded(
child: Text(
belt.name,
style: GoogleFonts.inter(
fontSize: 12,
color: AppColors.textSecondary,
),
),
),
if (isCurrent)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: AppColors.accentMuted,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.accentBorder),
),
child: Text(
'CURRENT',
style: GoogleFonts.jetBrainsMono(
fontSize: 8,
fontWeight: FontWeight.w700,
letterSpacing: 1,
color: AppColors.accent,
),
),
),
],
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'$checked / $total requirements',
style: GoogleFonts.jetBrainsMono(
fontSize: 10,
color: checked == total
? AppColors.success
: AppColors.textMuted,
),
),
),
children: [
for (var i = 0; i < belt.requirements.length; i++)
CheckboxListTile(
dense: true,
controlAffinity: ListTileControlAffinity.leading,
contentPadding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing16,
),
value: service.isChecked(belt.id, i),
onChanged: (_) => service.toggle(belt.id, i),
title: Text(
belt.requirements[i],
style: GoogleFonts.inter(
fontSize: 13,
color: service.isChecked(belt.id, i)
? AppColors.textMuted
: AppColors.textPrimary,
decoration: service.isChecked(belt.id, i)
? TextDecoration.lineThrough
: null,
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
child: Align(
alignment: Alignment.centerLeft,
child: OutlinedButton(
onPressed: () =>
service.setCurrentBelt(isCurrent ? null : belt.id),
child: Text(
isCurrent ? 'UNSET CURRENT' : 'SET AS CURRENT GOAL',
),
),
),
),
],
),
),
);
}
}
// ---------------------------------------------------------------------------
// Belt color chip
// ---------------------------------------------------------------------------
class _BeltChip extends StatelessWidget {
const _BeltChip({required this.belt, required this.size});
final BeltLevel belt;
final double size;
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size * 0.55,
decoration: BoxDecoration(
color: belt.baseColor,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: AppColors.border),
),
child: belt.stripeColor == null
? null
: Center(
child: Container(height: size * 0.14, color: belt.stripeColor),
),
);
}
}