This commit is contained in:
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:trainhub_flutter/presentation/analysis/analysis_page.dart';
|
import 'package:trainhub_flutter/presentation/analysis/analysis_page.dart';
|
||||||
import 'package:trainhub_flutter/presentation/calendar/calendar_page.dart';
|
import 'package:trainhub_flutter/presentation/calendar/calendar_page.dart';
|
||||||
import 'package:trainhub_flutter/presentation/chat/chat_page.dart';
|
import 'package:trainhub_flutter/presentation/chat/chat_page.dart';
|
||||||
|
import 'package:trainhub_flutter/presentation/grading/grading_page.dart';
|
||||||
import 'package:trainhub_flutter/presentation/home/home_page.dart';
|
import 'package:trainhub_flutter/presentation/home/home_page.dart';
|
||||||
import 'package:trainhub_flutter/presentation/plan_editor/plan_editor_page.dart';
|
import 'package:trainhub_flutter/presentation/plan_editor/plan_editor_page.dart';
|
||||||
import 'package:trainhub_flutter/presentation/settings/knowledge_base_page.dart';
|
import 'package:trainhub_flutter/presentation/settings/knowledge_base_page.dart';
|
||||||
@@ -29,6 +30,7 @@ class AppRouter extends RootStackRouter {
|
|||||||
AutoRoute(page: TrainingsRoute.page),
|
AutoRoute(page: TrainingsRoute.page),
|
||||||
AutoRoute(page: AnalysisRoute.page),
|
AutoRoute(page: AnalysisRoute.page),
|
||||||
AutoRoute(page: CalendarRoute.page),
|
AutoRoute(page: CalendarRoute.page),
|
||||||
|
AutoRoute(page: GradingRoute.page),
|
||||||
AutoRoute(page: ChatRoute.page),
|
AutoRoute(page: ChatRoute.page),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -58,6 +58,22 @@ class ChatRoute extends PageRouteInfo<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// generated route for
|
||||||
|
/// [GradingPage]
|
||||||
|
class GradingRoute extends PageRouteInfo<void> {
|
||||||
|
const GradingRoute({List<PageRouteInfo>? children})
|
||||||
|
: super(GradingRoute.name, initialChildren: children);
|
||||||
|
|
||||||
|
static const String name = 'GradingRoute';
|
||||||
|
|
||||||
|
static PageInfo page = PageInfo(
|
||||||
|
name,
|
||||||
|
builder: (data) {
|
||||||
|
return const GradingPage();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [HomePage]
|
/// [HomePage]
|
||||||
class HomeRoute extends PageRouteInfo<void> {
|
class HomeRoute extends PageRouteInfo<void> {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import 'package:path_provider/path_provider.dart';
|
|||||||
|
|
||||||
/// Which backend answers chat requests. Embeddings (knowledge base) always
|
/// Which backend answers chat requests. Embeddings (knowledge base) always
|
||||||
/// stay on the local Nomic server regardless of this choice.
|
/// stay on the local Nomic server regardless of this choice.
|
||||||
enum AiProvider { local, ollama, lmstudio, anthropic, openai, gemini }
|
enum AiProvider { local, ollama, lmstudio, anthropic, openai, gemini, openrouter }
|
||||||
|
|
||||||
extension AiProviderX on AiProvider {
|
extension AiProviderX on AiProvider {
|
||||||
String get label => switch (this) {
|
String get label => switch (this) {
|
||||||
@@ -17,6 +17,7 @@ extension AiProviderX on AiProvider {
|
|||||||
AiProvider.anthropic => 'Anthropic (Claude)',
|
AiProvider.anthropic => 'Anthropic (Claude)',
|
||||||
AiProvider.openai => 'OpenAI (GPT)',
|
AiProvider.openai => 'OpenAI (GPT)',
|
||||||
AiProvider.gemini => 'Google (Gemini)',
|
AiProvider.gemini => 'Google (Gemini)',
|
||||||
|
AiProvider.openrouter => 'OpenRouter',
|
||||||
};
|
};
|
||||||
|
|
||||||
String get defaultModel => switch (this) {
|
String get defaultModel => switch (this) {
|
||||||
@@ -26,6 +27,9 @@ extension AiProviderX on AiProvider {
|
|||||||
AiProvider.anthropic => 'claude-opus-4-8',
|
AiProvider.anthropic => 'claude-opus-4-8',
|
||||||
AiProvider.openai => 'gpt-5.1',
|
AiProvider.openai => 'gpt-5.1',
|
||||||
AiProvider.gemini => 'gemini-2.5-flash',
|
AiProvider.gemini => 'gemini-2.5-flash',
|
||||||
|
// OpenRouter routes to any model — IDs like "anthropic/claude-opus-4.8",
|
||||||
|
// "openai/gpt-4o", "meta-llama/llama-3.3-70b-instruct".
|
||||||
|
AiProvider.openrouter => 'openai/gpt-4o',
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Self-hosted OpenAI-compatible servers reachable over a base URL.
|
/// Self-hosted OpenAI-compatible servers reachable over a base URL.
|
||||||
@@ -39,7 +43,10 @@ extension AiProviderX on AiProvider {
|
|||||||
};
|
};
|
||||||
|
|
||||||
bool get needsApiKey => switch (this) {
|
bool get needsApiKey => switch (this) {
|
||||||
AiProvider.anthropic || AiProvider.openai || AiProvider.gemini => true,
|
AiProvider.anthropic ||
|
||||||
|
AiProvider.openai ||
|
||||||
|
AiProvider.gemini ||
|
||||||
|
AiProvider.openrouter => true,
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
252
lib/data/services/grading_service.dart
Normal file
252
lib/data/services/grading_service.dart
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
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',
|
||||||
|
],
|
||||||
|
),
|
||||||
|
];
|
||||||
@@ -72,6 +72,8 @@ class LlmClient {
|
|||||||
AiProvider.openai => 'https://api.openai.com/v1/chat/completions',
|
AiProvider.openai => 'https://api.openai.com/v1/chat/completions',
|
||||||
AiProvider.gemini =>
|
AiProvider.gemini =>
|
||||||
'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions',
|
'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions',
|
||||||
|
AiProvider.openrouter =>
|
||||||
|
'https://openrouter.ai/api/v1/chat/completions',
|
||||||
AiProvider.anthropic => throw StateError('handled separately'),
|
AiProvider.anthropic => throw StateError('handled separately'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import 'package:trainhub_flutter/domain/repositories/note_repository.dart';
|
|||||||
import 'package:trainhub_flutter/data/services/ai_process_manager.dart';
|
import 'package:trainhub_flutter/data/services/ai_process_manager.dart';
|
||||||
import 'package:trainhub_flutter/data/services/ai_settings_service.dart';
|
import 'package:trainhub_flutter/data/services/ai_settings_service.dart';
|
||||||
import 'package:trainhub_flutter/data/services/combo_service.dart';
|
import 'package:trainhub_flutter/data/services/combo_service.dart';
|
||||||
|
import 'package:trainhub_flutter/data/services/grading_service.dart';
|
||||||
import 'package:trainhub_flutter/data/services/embedding_service.dart';
|
import 'package:trainhub_flutter/data/services/embedding_service.dart';
|
||||||
import 'package:trainhub_flutter/data/services/llm_client.dart';
|
import 'package:trainhub_flutter/data/services/llm_client.dart';
|
||||||
import 'package:trainhub_flutter/data/database/daos/knowledge_chunk_dao.dart';
|
import 'package:trainhub_flutter/data/database/daos/knowledge_chunk_dao.dart';
|
||||||
@@ -50,6 +51,7 @@ void init() {
|
|||||||
getIt.registerSingleton<EmbeddingService>(EmbeddingService());
|
getIt.registerSingleton<EmbeddingService>(EmbeddingService());
|
||||||
getIt.registerSingleton<AiSettingsService>(AiSettingsService());
|
getIt.registerSingleton<AiSettingsService>(AiSettingsService());
|
||||||
getIt.registerSingleton<ComboService>(ComboService());
|
getIt.registerSingleton<ComboService>(ComboService());
|
||||||
|
getIt.registerSingleton<GradingService>(GradingService());
|
||||||
getIt.registerSingleton<LlmClient>(LlmClient(getIt<AiSettingsService>()));
|
getIt.registerSingleton<LlmClient>(LlmClient(getIt<AiSettingsService>()));
|
||||||
|
|
||||||
// Repositories
|
// Repositories
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:trainhub_flutter/core/constants/ai_constants.dart';
|
import 'package:trainhub_flutter/core/constants/ai_constants.dart';
|
||||||
|
import 'package:trainhub_flutter/domain/entities/training_exercise.dart';
|
||||||
|
import 'package:trainhub_flutter/domain/entities/training_section.dart';
|
||||||
import 'package:trainhub_flutter/domain/repositories/chat_repository.dart';
|
import 'package:trainhub_flutter/domain/repositories/chat_repository.dart';
|
||||||
import 'package:trainhub_flutter/domain/repositories/exercise_repository.dart';
|
import 'package:trainhub_flutter/domain/repositories/exercise_repository.dart';
|
||||||
import 'package:trainhub_flutter/domain/repositories/note_repository.dart';
|
import 'package:trainhub_flutter/domain/repositories/note_repository.dart';
|
||||||
@@ -206,6 +210,71 @@ class ChatController extends _$ChatController {
|
|||||||
/// kept and persisted like a normal reply.
|
/// kept and persisted like a normal reply.
|
||||||
void stopGeneration() => _cancelToken?.cancel();
|
void stopGeneration() => _cancelToken?.cancel();
|
||||||
|
|
||||||
|
/// Turns an assistant reply describing a workout into a real training plan
|
||||||
|
/// in the database. The model re-reads its own text and emits strict JSON,
|
||||||
|
/// which is parsed into sections/exercises. Returns the created plan name.
|
||||||
|
Future<String> createPlanFromText(String content) async {
|
||||||
|
const extractPrompt =
|
||||||
|
'You convert workout descriptions into strict JSON. '
|
||||||
|
'Respond with ONLY valid JSON — no markdown fences, no commentary. '
|
||||||
|
'Schema: {"name": string, "sections": [{"name": string, '
|
||||||
|
'"exercises": [{"name": string, "sets": int, "value": int, '
|
||||||
|
'"isTime": bool, "rest": int}]}]}. '
|
||||||
|
'"value" means repetitions when isTime=false, or seconds when '
|
||||||
|
'isTime=true. "rest" is rest between sets in seconds. '
|
||||||
|
'Keep exercise names short. If the text contains no workout plan, '
|
||||||
|
'respond with {"name": "", "sections": []}.';
|
||||||
|
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
await for (final delta in _llm.streamChat([
|
||||||
|
{'role': 'system', 'content': extractPrompt},
|
||||||
|
{'role': 'user', 'content': content},
|
||||||
|
])) {
|
||||||
|
buffer.write(delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
final raw = buffer.toString();
|
||||||
|
final start = raw.indexOf('{');
|
||||||
|
final end = raw.lastIndexOf('}');
|
||||||
|
if (start < 0 || end <= start) {
|
||||||
|
throw Exception('The model did not return a valid plan.');
|
||||||
|
}
|
||||||
|
final data =
|
||||||
|
jsonDecode(raw.substring(start, end + 1)) as Map<String, dynamic>;
|
||||||
|
final sectionsJson = (data['sections'] as List?) ?? [];
|
||||||
|
if (sectionsJson.isEmpty) {
|
||||||
|
throw Exception('No workout plan found in this reply.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final sections = <TrainingSectionEntity>[
|
||||||
|
for (final s in sectionsJson)
|
||||||
|
TrainingSectionEntity(
|
||||||
|
id: const Uuid().v4(),
|
||||||
|
name: (s['name'] ?? 'Section') as String,
|
||||||
|
exercises: [
|
||||||
|
for (final e in (s['exercises'] as List? ?? []))
|
||||||
|
TrainingExerciseEntity(
|
||||||
|
instanceId: const Uuid().v4(),
|
||||||
|
exerciseId: '',
|
||||||
|
name: (e['name'] ?? 'Exercise') as String,
|
||||||
|
sets: (e['sets'] as num?)?.toInt() ?? 3,
|
||||||
|
value: (e['value'] as num?)?.toInt() ?? 10,
|
||||||
|
isTime: (e['isTime'] as bool?) ?? false,
|
||||||
|
rest: (e['rest'] as num?)?.toInt() ?? 60,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
final planRepo = getIt<TrainingPlanRepository>();
|
||||||
|
final name = ((data['name'] as String?)?.trim().isEmpty ?? true)
|
||||||
|
? 'AI Plan'
|
||||||
|
: (data['name'] as String).trim();
|
||||||
|
final created = await planRepo.create(name);
|
||||||
|
await planRepo.update(created.copyWith(sections: sections));
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
Future<String> _streamResponse(
|
Future<String> _streamResponse(
|
||||||
String systemPrompt,
|
String systemPrompt,
|
||||||
List<Map<String, String>> history,
|
List<Map<String, String>> history,
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ final aiSettingsServiceProvider =
|
|||||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||||
// ignore: unused_element
|
// ignore: unused_element
|
||||||
typedef AiSettingsServiceRef = AutoDisposeProviderRef<AiSettingsService>;
|
typedef AiSettingsServiceRef = AutoDisposeProviderRef<AiSettingsService>;
|
||||||
String _$chatControllerHash() => r'227ef80f7bcc8787d85a726f151d878a3ba954d6';
|
String _$chatControllerHash() => r'3856f4ae716d8ea0fab459f0b8182bc76e9f1e1b';
|
||||||
|
|
||||||
/// See also [ChatController].
|
/// See also [ChatController].
|
||||||
@ProviderFor(ChatController)
|
@ProviderFor(ChatController)
|
||||||
|
|||||||
@@ -58,6 +58,37 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
_inputFocusNode.requestFocus();
|
_inputFocusNode.requestFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _createPlanFromMessage(
|
||||||
|
ChatController controller,
|
||||||
|
String content,
|
||||||
|
) async {
|
||||||
|
// Simple progress dialog — extraction is a second LLM round-trip.
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (_) => const Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
final name = await controller.createPlanFromText(content);
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.of(context, rootNavigator: true).pop();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Plan "$name" created — find it under Trainings.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.of(context, rootNavigator: true).pop();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Could not create a plan: $e'),
|
||||||
|
backgroundColor: AppColors.surfaceContainerHigh,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String _formatTimestamp(String timestamp) {
|
String _formatTimestamp(String timestamp) {
|
||||||
try {
|
try {
|
||||||
final dt = DateTime.parse(timestamp);
|
final dt = DateTime.parse(timestamp);
|
||||||
@@ -306,6 +337,9 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
return MessageBubble(
|
return MessageBubble(
|
||||||
message: msg,
|
message: msg,
|
||||||
formattedTime: _formatTimestamp(msg.createdAt),
|
formattedTime: _formatTimestamp(msg.createdAt),
|
||||||
|
onCreatePlan: msg.isUser
|
||||||
|
? null
|
||||||
|
: () => _createPlanFromMessage(controller, msg.content),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||||
import 'package:trainhub_flutter/domain/entities/chat_message.dart';
|
import 'package:trainhub_flutter/domain/entities/chat_message.dart';
|
||||||
@@ -9,11 +10,16 @@ class MessageBubble extends StatelessWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.message,
|
required this.message,
|
||||||
required this.formattedTime,
|
required this.formattedTime,
|
||||||
|
this.onCreatePlan,
|
||||||
});
|
});
|
||||||
|
|
||||||
final ChatMessageEntity message;
|
final ChatMessageEntity message;
|
||||||
final String formattedTime;
|
final String formattedTime;
|
||||||
|
|
||||||
|
/// When set (assistant messages), shows a "create plan" action that turns
|
||||||
|
/// the reply into a real training plan.
|
||||||
|
final VoidCallback? onCreatePlan;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isUser = message.isUser;
|
final isUser = message.isUser;
|
||||||
@@ -21,8 +27,9 @@ class MessageBubble extends StatelessWidget {
|
|||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: UIConstants.spacing12),
|
padding: const EdgeInsets.only(bottom: UIConstants.spacing12),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment:
|
mainAxisAlignment: isUser
|
||||||
isUser ? MainAxisAlignment.end : MainAxisAlignment.start,
|
? MainAxisAlignment.end
|
||||||
|
: MainAxisAlignment.start,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (!isUser) ...[
|
if (!isUser) ...[
|
||||||
@@ -34,8 +41,9 @@ class MessageBubble extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment:
|
crossAxisAlignment: isUser
|
||||||
isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
? CrossAxisAlignment.end
|
||||||
|
: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||||
@@ -84,13 +92,51 @@ class MessageBubble extends StatelessWidget {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
child: Text(
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
formattedTime,
|
formattedTime,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: AppColors.textMuted,
|
color: AppColors.textMuted,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (!isUser && onCreatePlan != null) ...[
|
||||||
|
const SizedBox(width: UIConstants.spacing12),
|
||||||
|
InkWell(
|
||||||
|
onTap: onCreatePlan,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.playlist_add_rounded,
|
||||||
|
size: 13,
|
||||||
|
color: AppColors.accent,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'CREATE PLAN',
|
||||||
|
style: GoogleFonts.jetBrainsMono(
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
color: AppColors.accent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class GraphEdgeData {
|
|||||||
///
|
///
|
||||||
/// Interactions:
|
/// Interactions:
|
||||||
/// - drag empty space to pan, pinch / scroll to zoom
|
/// - drag empty space to pan, pinch / scroll to zoom
|
||||||
/// - drag a node to move it ([onNodeMoved] per frame, [onNodeDragEnd] once)
|
/// - drag a node to move it ([onNodeMoved] fires once, at drag end)
|
||||||
/// - tap the link button on a node, then tap another node to connect them
|
/// - tap the link button on a node, then tap another node to connect them
|
||||||
/// ([onConnect]); connecting an already-linked pair is the caller's chance
|
/// ([onConnect]); connecting an already-linked pair is the caller's chance
|
||||||
/// to toggle the edge off
|
/// to toggle the edge off
|
||||||
@@ -44,7 +44,6 @@ class NodeGraphCanvas extends StatefulWidget {
|
|||||||
required this.nodes,
|
required this.nodes,
|
||||||
required this.edges,
|
required this.edges,
|
||||||
this.onNodeMoved,
|
this.onNodeMoved,
|
||||||
this.onNodeDragEnd,
|
|
||||||
this.onConnect,
|
this.onConnect,
|
||||||
this.onNodeTap,
|
this.onNodeTap,
|
||||||
this.onNodeLongPress,
|
this.onNodeLongPress,
|
||||||
@@ -53,8 +52,11 @@ class NodeGraphCanvas extends StatefulWidget {
|
|||||||
|
|
||||||
final List<GraphNodeData> nodes;
|
final List<GraphNodeData> nodes;
|
||||||
final List<GraphEdgeData> edges;
|
final List<GraphEdgeData> edges;
|
||||||
|
|
||||||
|
/// Called once, when a drag ends, with the final position. During the
|
||||||
|
/// drag the canvas tracks positions locally so no parent rebuild (or
|
||||||
|
/// persistence) happens per pointer event — that's what keeps it smooth.
|
||||||
final void Function(String id, Offset position)? onNodeMoved;
|
final void Function(String id, Offset position)? onNodeMoved;
|
||||||
final void Function(String id)? onNodeDragEnd;
|
|
||||||
final void Function(String fromId, String toId)? onConnect;
|
final void Function(String fromId, String toId)? onConnect;
|
||||||
final void Function(String id)? onNodeTap;
|
final void Function(String id)? onNodeTap;
|
||||||
final void Function(String id)? onNodeLongPress;
|
final void Function(String id)? onNodeLongPress;
|
||||||
@@ -76,6 +78,27 @@ class _NodeGraphCanvasState extends State<NodeGraphCanvas> {
|
|||||||
/// this is what makes node dragging feel 1:1 with the mouse.
|
/// this is what makes node dragging feel 1:1 with the mouse.
|
||||||
String? _draggingId;
|
String? _draggingId;
|
||||||
|
|
||||||
|
/// Live positions — the canvas is the source of truth while dragging,
|
||||||
|
/// so a drag never triggers a parent rebuild per pointer event.
|
||||||
|
late Map<String, Offset> _positions;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_positions = {for (final n in widget.nodes) n.id: n.position};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(NodeGraphCanvas oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
// Re-sync from the parent, but never yank the node under the cursor.
|
||||||
|
final dragging = _draggingId;
|
||||||
|
_positions = {
|
||||||
|
for (final n in widget.nodes)
|
||||||
|
n.id: n.id == dragging ? (_positions[n.id] ?? n.position) : n.position,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_transform.dispose();
|
_transform.dispose();
|
||||||
@@ -116,18 +139,25 @@ class _NodeGraphCanvasState extends State<NodeGraphCanvas> {
|
|||||||
height: widget.canvasSize.height,
|
height: widget.canvasSize.height,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
|
// Static dot grid — painted once, never repaints on drag.
|
||||||
|
Positioned.fill(
|
||||||
|
child: RepaintBoundary(
|
||||||
|
child: CustomPaint(painter: _DotGridPainter()),
|
||||||
|
),
|
||||||
|
),
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: CustomPaint(
|
child: CustomPaint(
|
||||||
painter: _GraphPainter(
|
painter: _EdgesPainter(
|
||||||
nodesById: nodesById,
|
nodesById: nodesById,
|
||||||
|
positions: _positions,
|
||||||
edges: widget.edges,
|
edges: widget.edges,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
for (final node in widget.nodes)
|
for (final node in widget.nodes)
|
||||||
Positioned(
|
Positioned(
|
||||||
left: node.position.dx,
|
left: (_positions[node.id] ?? node.position).dx,
|
||||||
top: node.position.dy,
|
top: (_positions[node.id] ?? node.position).dy,
|
||||||
// Raw pointer events instead of a pan GestureDetector:
|
// Raw pointer events instead of a pan GestureDetector:
|
||||||
// no gesture-arena delay, no drag slop — the node
|
// no gesture-arena delay, no drag slop — the node
|
||||||
// follows the cursor from the first pixel.
|
// follows the cursor from the first pixel.
|
||||||
@@ -139,15 +169,19 @@ class _NodeGraphCanvasState extends State<NodeGraphCanvas> {
|
|||||||
? null
|
? null
|
||||||
: (event) {
|
: (event) {
|
||||||
if (_draggingId != node.id) return;
|
if (_draggingId != node.id) return;
|
||||||
widget.onNodeMoved!(
|
setState(() {
|
||||||
node.id,
|
_positions[node.id] =
|
||||||
node.position + event.delta / _scale,
|
(_positions[node.id] ?? node.position) +
|
||||||
);
|
event.delta / _scale;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onPointerUp: (_) {
|
onPointerUp: (_) {
|
||||||
if (_draggingId == node.id) {
|
if (_draggingId == node.id) {
|
||||||
setState(() => _draggingId = null);
|
setState(() => _draggingId = null);
|
||||||
widget.onNodeDragEnd?.call(node.id);
|
widget.onNodeMoved?.call(
|
||||||
|
node.id,
|
||||||
|
_positions[node.id] ?? node.position,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onPointerCancel: (_) {
|
onPointerCancel: (_) {
|
||||||
@@ -155,6 +189,7 @@ class _NodeGraphCanvasState extends State<NodeGraphCanvas> {
|
|||||||
setState(() => _draggingId = null);
|
setState(() => _draggingId = null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
child: RepaintBoundary(
|
||||||
child: _NodeCard(
|
child: _NodeCard(
|
||||||
node: node,
|
node: node,
|
||||||
isConnectSource: _connectingFrom == node.id,
|
isConnectSource: _connectingFrom == node.id,
|
||||||
@@ -172,6 +207,7 @@ class _NodeGraphCanvasState extends State<NodeGraphCanvas> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -326,19 +362,45 @@ class _NodeCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Edges + dot grid painter
|
// Painters — the static dot grid is separate from the edges so that a node
|
||||||
|
// drag only repaints the (cheap) edge layer, never the thousands of dots.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class _GraphPainter extends CustomPainter {
|
class _DotGridPainter extends CustomPainter {
|
||||||
_GraphPainter({required this.nodesById, required this.edges});
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
const step = 28.0;
|
||||||
|
final points = <Offset>[
|
||||||
|
for (double x = step; x < size.width; x += step)
|
||||||
|
for (double y = step; y < size.height; y += step) Offset(x, y),
|
||||||
|
];
|
||||||
|
canvas.drawPoints(
|
||||||
|
PointMode.points,
|
||||||
|
points,
|
||||||
|
Paint()
|
||||||
|
..color = AppColors.surfaceContainerHigh
|
||||||
|
..strokeWidth = 1.6
|
||||||
|
..strokeCap = StrokeCap.round,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(_DotGridPainter oldDelegate) => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EdgesPainter extends CustomPainter {
|
||||||
|
_EdgesPainter({
|
||||||
|
required this.nodesById,
|
||||||
|
required this.positions,
|
||||||
|
required this.edges,
|
||||||
|
});
|
||||||
|
|
||||||
final Map<String, GraphNodeData> nodesById;
|
final Map<String, GraphNodeData> nodesById;
|
||||||
|
final Map<String, Offset> positions;
|
||||||
final List<GraphEdgeData> edges;
|
final List<GraphEdgeData> edges;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void paint(Canvas canvas, Size size) {
|
void paint(Canvas canvas, Size size) {
|
||||||
_paintDotGrid(canvas, size);
|
|
||||||
|
|
||||||
final edgePaint = Paint()
|
final edgePaint = Paint()
|
||||||
..style = PaintingStyle.stroke
|
..style = PaintingStyle.stroke
|
||||||
..strokeWidth = 2;
|
..strokeWidth = 2;
|
||||||
@@ -349,12 +411,14 @@ class _GraphPainter extends CustomPainter {
|
|||||||
if (from == null || to == null) continue;
|
if (from == null || to == null) continue;
|
||||||
|
|
||||||
final start =
|
final start =
|
||||||
from.position +
|
(positions[edge.fromId] ?? from.position) +
|
||||||
const Offset(
|
const Offset(
|
||||||
NodeGraphCanvas.nodeWidth,
|
NodeGraphCanvas.nodeWidth,
|
||||||
NodeGraphCanvas.nodeHeight / 2,
|
NodeGraphCanvas.nodeHeight / 2,
|
||||||
);
|
);
|
||||||
final end = to.position + const Offset(0, NodeGraphCanvas.nodeHeight / 2);
|
final end =
|
||||||
|
(positions[edge.toId] ?? to.position) +
|
||||||
|
const Offset(0, NodeGraphCanvas.nodeHeight / 2);
|
||||||
|
|
||||||
edgePaint.color = from.accent.withValues(alpha: 0.75);
|
edgePaint.color = from.accent.withValues(alpha: 0.75);
|
||||||
|
|
||||||
@@ -384,22 +448,6 @@ class _GraphPainter extends CustomPainter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _paintDotGrid(Canvas canvas, Size size) {
|
|
||||||
const step = 28.0;
|
|
||||||
final points = <Offset>[
|
|
||||||
for (double x = step; x < size.width; x += step)
|
|
||||||
for (double y = step; y < size.height; y += step) Offset(x, y),
|
|
||||||
];
|
|
||||||
canvas.drawPoints(
|
|
||||||
PointMode.points,
|
|
||||||
points,
|
|
||||||
Paint()
|
|
||||||
..color = AppColors.surfaceContainerHigh
|
|
||||||
..strokeWidth = 1.6
|
|
||||||
..strokeCap = StrokeCap.round,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldRepaint(_GraphPainter oldDelegate) => true;
|
bool shouldRepaint(_EdgesPainter oldDelegate) => true;
|
||||||
}
|
}
|
||||||
|
|||||||
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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ class ShellPage extends StatelessWidget {
|
|||||||
TrainingsRoute(),
|
TrainingsRoute(),
|
||||||
AnalysisRoute(),
|
AnalysisRoute(),
|
||||||
CalendarRoute(),
|
CalendarRoute(),
|
||||||
|
GradingRoute(),
|
||||||
ChatRoute(),
|
ChatRoute(),
|
||||||
],
|
],
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
@@ -86,6 +87,11 @@ class _Sidebar extends StatelessWidget {
|
|||||||
activeIcon: Icons.calendar_today,
|
activeIcon: Icons.calendar_today,
|
||||||
label: 'Calendar',
|
label: 'Calendar',
|
||||||
),
|
),
|
||||||
|
_NavItemData(
|
||||||
|
icon: Icons.military_tech_outlined,
|
||||||
|
activeIcon: Icons.military_tech,
|
||||||
|
label: 'Grading',
|
||||||
|
),
|
||||||
_NavItemData(
|
_NavItemData(
|
||||||
icon: Icons.chat_bubble_outline,
|
icon: Icons.chat_bubble_outline,
|
||||||
activeIcon: Icons.chat_bubble,
|
activeIcon: Icons.chat_bubble,
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ class _ComboBuilderTabState extends State<ComboBuilderTab> {
|
|||||||
_service.update(combo.copyWith(edges: edges));
|
_service.update(combo.copyWith(edges: edges));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Called once, when a drag ends — the canvas tracks positions itself
|
||||||
|
// during the drag, so this can persist immediately.
|
||||||
void _moveNode(Combo combo, String id, Offset position) {
|
void _moveNode(Combo combo, String id, Offset position) {
|
||||||
_service.update(
|
_service.update(
|
||||||
combo.copyWith(
|
combo.copyWith(
|
||||||
@@ -103,7 +105,6 @@ class _ComboBuilderTabState extends State<ComboBuilderTab> {
|
|||||||
n.id == id ? n.copyWith(dx: position.dx, dy: position.dy) : n,
|
n.id == id ? n.copyWith(dx: position.dx, dy: position.dy) : n,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
persist: false,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +206,6 @@ class _ComboBuilderTabState extends State<ComboBuilderTab> {
|
|||||||
GraphEdgeData(e.fromId, e.toId),
|
GraphEdgeData(e.fromId, e.toId),
|
||||||
],
|
],
|
||||||
onNodeMoved: (id, pos) => _moveNode(combo, id, pos),
|
onNodeMoved: (id, pos) => _moveNode(combo, id, pos),
|
||||||
onNodeDragEnd: (_) => _service.persistNow(),
|
|
||||||
onConnect: (from, to) => _toggleEdge(combo, from, to),
|
onConnect: (from, to) => _toggleEdge(combo, from, to),
|
||||||
onNodeLongPress: (id) => _deleteNode(combo, id),
|
onNodeLongPress: (id) => _deleteNode(combo, id),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user