321 lines
10 KiB
Dart
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),
|
|
),
|
|
);
|
|
}
|
|
}
|