This commit is contained in:
320
lib/presentation/grading/grading_page.dart
Normal file
320
lib/presentation/grading/grading_page.dart
Normal file
@@ -0,0 +1,320 @@
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user