253 lines
7.1 KiB
Dart
253 lines
7.1 KiB
Dart
import 'dart:convert';
|
||
import 'dart:io';
|
||
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter/material.dart' show Color;
|
||
import 'package:path/path.dart' as p;
|
||
import 'package:path_provider/path_provider.dart';
|
||
|
||
/// One belt level with its ITF grading requirements.
|
||
class BeltLevel {
|
||
const BeltLevel({
|
||
required this.id,
|
||
required this.gup,
|
||
required this.name,
|
||
required this.baseColor,
|
||
this.stripeColor,
|
||
required this.requirements,
|
||
});
|
||
|
||
final String id;
|
||
final String gup;
|
||
final String name;
|
||
final Color baseColor;
|
||
final Color? stripeColor;
|
||
final List<String> requirements;
|
||
}
|
||
|
||
/// Tracks grading preparation: which requirements are checked off per belt,
|
||
/// and which belt the user is currently preparing for. Persisted as JSON.
|
||
class GradingService extends ChangeNotifier {
|
||
static const _fileName = 'trainhub_grading.json';
|
||
|
||
String? _currentBeltId;
|
||
Map<String, Set<int>> _checked = {};
|
||
bool _loaded = false;
|
||
|
||
String? get currentBeltId => _currentBeltId;
|
||
|
||
bool isChecked(String beltId, int index) =>
|
||
_checked[beltId]?.contains(index) ?? false;
|
||
|
||
int checkedCount(String beltId) => _checked[beltId]?.length ?? 0;
|
||
|
||
double progress(BeltLevel belt) => belt.requirements.isEmpty
|
||
? 0
|
||
: checkedCount(belt.id) / belt.requirements.length;
|
||
|
||
Future<File> _file() async {
|
||
final dir = await getApplicationDocumentsDirectory();
|
||
return File(p.join(dir.path, _fileName));
|
||
}
|
||
|
||
Future<void> ensureLoaded() async {
|
||
if (_loaded) return;
|
||
_loaded = true;
|
||
try {
|
||
final file = await _file();
|
||
if (!file.existsSync()) return;
|
||
final json = jsonDecode(await file.readAsString());
|
||
_currentBeltId = json['current'] as String?;
|
||
_checked = {
|
||
for (final entry in (json['checked'] as Map? ?? {}).entries)
|
||
entry.key as String: {
|
||
for (final i in entry.value as List) (i as num).toInt(),
|
||
},
|
||
};
|
||
notifyListeners();
|
||
} catch (e) {
|
||
if (kDebugMode) debugPrint('Failed to load grading state: $e');
|
||
}
|
||
}
|
||
|
||
void toggle(String beltId, int index) {
|
||
final set = _checked.putIfAbsent(beltId, () => {});
|
||
set.contains(index) ? set.remove(index) : set.add(index);
|
||
notifyListeners();
|
||
_persist();
|
||
}
|
||
|
||
void setCurrentBelt(String? beltId) {
|
||
_currentBeltId = beltId;
|
||
notifyListeners();
|
||
_persist();
|
||
}
|
||
|
||
Future<void> _persist() async {
|
||
try {
|
||
final file = await _file();
|
||
await file.writeAsString(
|
||
jsonEncode({
|
||
'current': _currentBeltId,
|
||
'checked': {
|
||
for (final entry in _checked.entries)
|
||
entry.key: entry.value.toList(),
|
||
},
|
||
}),
|
||
);
|
||
} catch (e) {
|
||
if (kDebugMode) debugPrint('Failed to save grading state: $e');
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// ITF colored-belt syllabus (10 gup → 1 gup)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const _white = Color(0xFFF4F4F5);
|
||
const _yellow = Color(0xFFFACC15);
|
||
const _green = Color(0xFF22C55E);
|
||
const _blue = Color(0xFF3B82F6);
|
||
const _red = Color(0xFFFF2B2B);
|
||
const _black = Color(0xFF18181B);
|
||
|
||
const kBeltSyllabus = <BeltLevel>[
|
||
BeltLevel(
|
||
id: '10gup',
|
||
gup: '10 GUP',
|
||
name: 'White → Yellow Stripe',
|
||
baseColor: _white,
|
||
stripeColor: _yellow,
|
||
requirements: [
|
||
'Saju Jirugi (4-direction punch)',
|
||
'Saju Makgi (4-direction block)',
|
||
'Stances: charyot, narani, annun, gunnun sogi',
|
||
'Ap Chagi (front snap kick) — both legs',
|
||
'Theory: meaning of Taekwon-Do, belt colors, dojang etiquette',
|
||
],
|
||
),
|
||
BeltLevel(
|
||
id: '9gup',
|
||
gup: '9 GUP',
|
||
name: 'Yellow Stripe → Yellow',
|
||
baseColor: _white,
|
||
stripeColor: _yellow,
|
||
requirements: [
|
||
'Tul: Chon-Ji (19 movements)',
|
||
'Niunja sogi (L-stance) + palmok daebi makgi',
|
||
'Ap Chagi and Yop Chagi from walking stance',
|
||
'3-step sparring (Sambo Matsogi) #1–2',
|
||
'Theory: meaning of Chon-Ji, counting in Korean 1–10',
|
||
],
|
||
),
|
||
BeltLevel(
|
||
id: '8gup',
|
||
gup: '8 GUP',
|
||
name: 'Yellow',
|
||
baseColor: _yellow,
|
||
requirements: [
|
||
'Tul: Dan-Gun (21 movements)',
|
||
'Sonkal daebi makgi (knife-hand guarding block)',
|
||
'Dollyo Chagi (turning kick) — both legs',
|
||
'3-step sparring #1–4',
|
||
'Theory: meaning of Dan-Gun, yellow belt symbolism',
|
||
],
|
||
),
|
||
BeltLevel(
|
||
id: '7gup',
|
||
gup: '7 GUP',
|
||
name: 'Yellow → Green Stripe',
|
||
baseColor: _yellow,
|
||
stripeColor: _green,
|
||
requirements: [
|
||
'Tul: Do-San (24 movements)',
|
||
'Bakat palmok makgi + sonkal taerigi combinations',
|
||
'Yopcha Jirugi (side piercing kick) — height improvement',
|
||
'3-step sparring full set',
|
||
'Theory: meaning of Do-San (Ahn Chang-Ho)',
|
||
],
|
||
),
|
||
BeltLevel(
|
||
id: '6gup',
|
||
gup: '6 GUP',
|
||
name: 'Green',
|
||
baseColor: _green,
|
||
requirements: [
|
||
'Tul: Won-Hyo (28 movements)',
|
||
'Gojung sogi (fixed stance) + punch',
|
||
'Goro Chagi (hooking kick), Naeryo Chagi (downward kick)',
|
||
'2-step sparring (Ibo Matsogi) #1–3',
|
||
'Theory: meaning of Won-Hyo, green belt symbolism',
|
||
],
|
||
),
|
||
BeltLevel(
|
||
id: '5gup',
|
||
gup: '5 GUP',
|
||
name: 'Green → Blue Stripe',
|
||
baseColor: _green,
|
||
stripeColor: _blue,
|
||
requirements: [
|
||
'Tul: Yul-Gok (38 movements)',
|
||
'Palkup taerigi (elbow strike), dwijibo jirugi',
|
||
'Dwit Chagi (back piercing kick) — both legs',
|
||
'2-step sparring full set + free sparring intro',
|
||
'Theory: meaning of Yul-Gok (Yi I, "Confucius of Korea")',
|
||
],
|
||
),
|
||
BeltLevel(
|
||
id: '4gup',
|
||
gup: '4 GUP',
|
||
name: 'Blue',
|
||
baseColor: _blue,
|
||
requirements: [
|
||
'Tul: Joong-Gun (32 movements)',
|
||
'Kyocha sogi (X-stance), sang palmok makgi',
|
||
'Bandae Dollyo Chagi (reverse turning kick)',
|
||
'1-step sparring (Ilbo Matsogi) #1–3',
|
||
'Theory: meaning of Joong-Gun (Ahn Joong-Gun)',
|
||
],
|
||
),
|
||
BeltLevel(
|
||
id: '3gup',
|
||
gup: '3 GUP',
|
||
name: 'Blue → Red Stripe',
|
||
baseColor: _blue,
|
||
stripeColor: _red,
|
||
requirements: [
|
||
'Tul: Toi-Gye (37 movements)',
|
||
'W-shape block (san makgi) in annun sogi',
|
||
'Twimyo Yop Chagi (flying side kick)',
|
||
'1-step sparring full set + free sparring 2×2 min',
|
||
'Theory: meaning of Toi-Gye (Yi Hwang)',
|
||
],
|
||
),
|
||
BeltLevel(
|
||
id: '2gup',
|
||
gup: '2 GUP',
|
||
name: 'Red',
|
||
baseColor: _red,
|
||
requirements: [
|
||
'Tul: Hwa-Rang (29 movements)',
|
||
'Combination kicking: dollyo + bandae dollyo chagi',
|
||
'Twimyo Dollyo Chagi (flying turning kick)',
|
||
'Free sparring 2×2 min + self-defence (hosinsul) basics',
|
||
'Theory: meaning of Hwa-Rang, red belt symbolism',
|
||
],
|
||
),
|
||
BeltLevel(
|
||
id: '1gup',
|
||
gup: '1 GUP',
|
||
name: 'Red → Black Stripe',
|
||
baseColor: _red,
|
||
stripeColor: _black,
|
||
requirements: [
|
||
'Tul: Choong-Moo (30 movements)',
|
||
'All previous patterns on demand',
|
||
'Twimyo Bandae Dollyo Chagi (flying reverse turning kick)',
|
||
'Free sparring vs two opponents + breaking (kyokpa)',
|
||
'Theory: meaning of Choong-Moo (Admiral Yi Sun-Sin), full terminology',
|
||
],
|
||
),
|
||
];
|