diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index ea59930..30be0f7 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:trainhub_flutter/presentation/analysis/analysis_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/grading/grading_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/settings/knowledge_base_page.dart'; @@ -18,25 +19,26 @@ part 'app_router.gr.dart'; class AppRouter extends RootStackRouter { @override List get routes => [ - // First-launch welcome / model download screen - AutoRoute(page: WelcomeRoute.page, initial: true), + // First-launch welcome / model download screen + AutoRoute(page: WelcomeRoute.page, initial: true), - // Main app shell (side-nav + tab router) - AutoRoute( - page: ShellRoute.page, - children: [ - AutoRoute(page: HomeRoute.page, initial: true), - AutoRoute(page: TrainingsRoute.page), - AutoRoute(page: AnalysisRoute.page), - AutoRoute(page: CalendarRoute.page), - AutoRoute(page: ChatRoute.page), - ], - ), + // Main app shell (side-nav + tab router) + AutoRoute( + page: ShellRoute.page, + children: [ + AutoRoute(page: HomeRoute.page, initial: true), + AutoRoute(page: TrainingsRoute.page), + AutoRoute(page: AnalysisRoute.page), + AutoRoute(page: CalendarRoute.page), + AutoRoute(page: GradingRoute.page), + AutoRoute(page: ChatRoute.page), + ], + ), - // Full-screen standalone pages - AutoRoute(page: PlanEditorRoute.page), - AutoRoute(page: WorkoutSessionRoute.page), - AutoRoute(page: SettingsRoute.page), - AutoRoute(page: KnowledgeBaseRoute.page), - ]; + // Full-screen standalone pages + AutoRoute(page: PlanEditorRoute.page), + AutoRoute(page: WorkoutSessionRoute.page), + AutoRoute(page: SettingsRoute.page), + AutoRoute(page: KnowledgeBaseRoute.page), + ]; } diff --git a/lib/core/router/app_router.gr.dart b/lib/core/router/app_router.gr.dart index c036ae3..1836850 100644 --- a/lib/core/router/app_router.gr.dart +++ b/lib/core/router/app_router.gr.dart @@ -58,6 +58,22 @@ class ChatRoute extends PageRouteInfo { ); } +/// generated route for +/// [GradingPage] +class GradingRoute extends PageRouteInfo { + const GradingRoute({List? children}) + : super(GradingRoute.name, initialChildren: children); + + static const String name = 'GradingRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const GradingPage(); + }, + ); +} + /// generated route for /// [HomePage] class HomeRoute extends PageRouteInfo { diff --git a/lib/data/services/ai_settings_service.dart b/lib/data/services/ai_settings_service.dart index f564ac5..f9cc1e7 100644 --- a/lib/data/services/ai_settings_service.dart +++ b/lib/data/services/ai_settings_service.dart @@ -7,7 +7,7 @@ import 'package:path_provider/path_provider.dart'; /// Which backend answers chat requests. Embeddings (knowledge base) always /// 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 { String get label => switch (this) { @@ -17,6 +17,7 @@ extension AiProviderX on AiProvider { AiProvider.anthropic => 'Anthropic (Claude)', AiProvider.openai => 'OpenAI (GPT)', AiProvider.gemini => 'Google (Gemini)', + AiProvider.openrouter => 'OpenRouter', }; String get defaultModel => switch (this) { @@ -26,6 +27,9 @@ extension AiProviderX on AiProvider { AiProvider.anthropic => 'claude-opus-4-8', AiProvider.openai => 'gpt-5.1', 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. @@ -39,7 +43,10 @@ extension AiProviderX on AiProvider { }; bool get needsApiKey => switch (this) { - AiProvider.anthropic || AiProvider.openai || AiProvider.gemini => true, + AiProvider.anthropic || + AiProvider.openai || + AiProvider.gemini || + AiProvider.openrouter => true, _ => false, }; } diff --git a/lib/data/services/grading_service.dart b/lib/data/services/grading_service.dart new file mode 100644 index 0000000..d14a400 --- /dev/null +++ b/lib/data/services/grading_service.dart @@ -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 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> _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() async { + final dir = await getApplicationDocumentsDirectory(); + return File(p.join(dir.path, _fileName)); + } + + Future 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 _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( + 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', + ], + ), +]; diff --git a/lib/data/services/llm_client.dart b/lib/data/services/llm_client.dart index 7203348..fae1ebe 100644 --- a/lib/data/services/llm_client.dart +++ b/lib/data/services/llm_client.dart @@ -72,6 +72,8 @@ class LlmClient { AiProvider.openai => 'https://api.openai.com/v1/chat/completions', AiProvider.gemini => 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions', + AiProvider.openrouter => + 'https://openrouter.ai/api/v1/chat/completions', AiProvider.anthropic => throw StateError('handled separately'), }; diff --git a/lib/injection.dart b/lib/injection.dart index 9f6aa2b..55613ed 100644 --- a/lib/injection.dart +++ b/lib/injection.dart @@ -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_settings_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/llm_client.dart'; import 'package:trainhub_flutter/data/database/daos/knowledge_chunk_dao.dart'; @@ -50,6 +51,7 @@ void init() { getIt.registerSingleton(EmbeddingService()); getIt.registerSingleton(AiSettingsService()); getIt.registerSingleton(ComboService()); + getIt.registerSingleton(GradingService()); getIt.registerSingleton(LlmClient(getIt())); // Repositories diff --git a/lib/presentation/chat/chat_controller.dart b/lib/presentation/chat/chat_controller.dart index 045dff9..0d6362c 100644 --- a/lib/presentation/chat/chat_controller.dart +++ b/lib/presentation/chat/chat_controller.dart @@ -1,6 +1,10 @@ +import 'dart:convert'; + import 'package:dio/dio.dart'; import 'package:riverpod_annotation/riverpod_annotation.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/exercise_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. 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 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; + final sectionsJson = (data['sections'] as List?) ?? []; + if (sectionsJson.isEmpty) { + throw Exception('No workout plan found in this reply.'); + } + + final sections = [ + 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(); + 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 _streamResponse( String systemPrompt, List> history, diff --git a/lib/presentation/chat/chat_controller.g.dart b/lib/presentation/chat/chat_controller.g.dart index 725dad5..0121771 100644 --- a/lib/presentation/chat/chat_controller.g.dart +++ b/lib/presentation/chat/chat_controller.g.dart @@ -41,7 +41,7 @@ final aiSettingsServiceProvider = @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef AiSettingsServiceRef = AutoDisposeProviderRef; -String _$chatControllerHash() => r'227ef80f7bcc8787d85a726f151d878a3ba954d6'; +String _$chatControllerHash() => r'3856f4ae716d8ea0fab459f0b8182bc76e9f1e1b'; /// See also [ChatController]. @ProviderFor(ChatController) diff --git a/lib/presentation/chat/chat_page.dart b/lib/presentation/chat/chat_page.dart index d04ea4b..c22fd1c 100644 --- a/lib/presentation/chat/chat_page.dart +++ b/lib/presentation/chat/chat_page.dart @@ -58,6 +58,37 @@ class _ChatPageState extends ConsumerState { _inputFocusNode.requestFocus(); } + Future _createPlanFromMessage( + ChatController controller, + String content, + ) async { + // Simple progress dialog — extraction is a second LLM round-trip. + showDialog( + 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) { try { final dt = DateTime.parse(timestamp); @@ -306,6 +337,9 @@ class _ChatPageState extends ConsumerState { return MessageBubble( message: msg, formattedTime: _formatTimestamp(msg.createdAt), + onCreatePlan: msg.isUser + ? null + : () => _createPlanFromMessage(controller, msg.content), ); }, ); diff --git a/lib/presentation/chat/widgets/message_bubble.dart b/lib/presentation/chat/widgets/message_bubble.dart index 76a8daa..f5f5e24 100644 --- a/lib/presentation/chat/widgets/message_bubble.dart +++ b/lib/presentation/chat/widgets/message_bubble.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.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/theme/app_colors.dart'; import 'package:trainhub_flutter/domain/entities/chat_message.dart'; @@ -9,11 +10,16 @@ class MessageBubble extends StatelessWidget { super.key, required this.message, required this.formattedTime, + this.onCreatePlan, }); final ChatMessageEntity message; 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 Widget build(BuildContext context) { final isUser = message.isUser; @@ -21,8 +27,9 @@ class MessageBubble extends StatelessWidget { return Padding( padding: const EdgeInsets.only(bottom: UIConstants.spacing12), child: Row( - mainAxisAlignment: - isUser ? MainAxisAlignment.end : MainAxisAlignment.start, + mainAxisAlignment: isUser + ? MainAxisAlignment.end + : MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (!isUser) ...[ @@ -34,8 +41,9 @@ class MessageBubble extends StatelessWidget { ], Flexible( child: Column( - crossAxisAlignment: - isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, + crossAxisAlignment: isUser + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, children: [ Container( constraints: BoxConstraints(maxWidth: maxWidth), @@ -84,12 +92,50 @@ class MessageBubble extends StatelessWidget { const SizedBox(height: 4), Padding( padding: const EdgeInsets.symmetric(horizontal: 4), - child: Text( - formattedTime, - style: const TextStyle( - color: AppColors.textMuted, - fontSize: 11, - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + formattedTime, + style: const TextStyle( + color: AppColors.textMuted, + 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, + ), + ), + ], + ), + ), + ), + ], + ], ), ), ], diff --git a/lib/presentation/common/widgets/node_graph_canvas.dart b/lib/presentation/common/widgets/node_graph_canvas.dart index 1d4ee3c..e94098b 100644 --- a/lib/presentation/common/widgets/node_graph_canvas.dart +++ b/lib/presentation/common/widgets/node_graph_canvas.dart @@ -33,7 +33,7 @@ class GraphEdgeData { /// /// Interactions: /// - 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 /// ([onConnect]); connecting an already-linked pair is the caller's chance /// to toggle the edge off @@ -44,7 +44,6 @@ class NodeGraphCanvas extends StatefulWidget { required this.nodes, required this.edges, this.onNodeMoved, - this.onNodeDragEnd, this.onConnect, this.onNodeTap, this.onNodeLongPress, @@ -53,8 +52,11 @@ class NodeGraphCanvas extends StatefulWidget { final List nodes; final List 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)? onNodeDragEnd; final void Function(String fromId, String toId)? onConnect; final void Function(String id)? onNodeTap; final void Function(String id)? onNodeLongPress; @@ -76,6 +78,27 @@ class _NodeGraphCanvasState extends State { /// this is what makes node dragging feel 1:1 with the mouse. 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 _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 void dispose() { _transform.dispose(); @@ -116,18 +139,25 @@ class _NodeGraphCanvasState extends State { height: widget.canvasSize.height, child: Stack( children: [ + // Static dot grid — painted once, never repaints on drag. + Positioned.fill( + child: RepaintBoundary( + child: CustomPaint(painter: _DotGridPainter()), + ), + ), Positioned.fill( child: CustomPaint( - painter: _GraphPainter( + painter: _EdgesPainter( nodesById: nodesById, + positions: _positions, edges: widget.edges, ), ), ), for (final node in widget.nodes) Positioned( - left: node.position.dx, - top: node.position.dy, + left: (_positions[node.id] ?? node.position).dx, + top: (_positions[node.id] ?? node.position).dy, // Raw pointer events instead of a pan GestureDetector: // no gesture-arena delay, no drag slop — the node // follows the cursor from the first pixel. @@ -139,15 +169,19 @@ class _NodeGraphCanvasState extends State { ? null : (event) { if (_draggingId != node.id) return; - widget.onNodeMoved!( - node.id, - node.position + event.delta / _scale, - ); + setState(() { + _positions[node.id] = + (_positions[node.id] ?? node.position) + + event.delta / _scale; + }); }, onPointerUp: (_) { if (_draggingId == node.id) { setState(() => _draggingId = null); - widget.onNodeDragEnd?.call(node.id); + widget.onNodeMoved?.call( + node.id, + _positions[node.id] ?? node.position, + ); } }, onPointerCancel: (_) { @@ -155,20 +189,22 @@ class _NodeGraphCanvasState extends State { setState(() => _draggingId = null); } }, - child: _NodeCard( - node: node, - isConnectSource: _connectingFrom == node.id, - connectMode: _connectingFrom != null, - showLinkButton: widget.onConnect != null, - onTap: () => _handleNodeBodyTap(node.id), - onLongPress: widget.onNodeLongPress == null - ? null - : () => widget.onNodeLongPress!(node.id), - onStartConnect: () => setState(() { - _connectingFrom = _connectingFrom == node.id + child: RepaintBoundary( + child: _NodeCard( + node: node, + isConnectSource: _connectingFrom == node.id, + connectMode: _connectingFrom != null, + showLinkButton: widget.onConnect != null, + onTap: () => _handleNodeBodyTap(node.id), + onLongPress: widget.onNodeLongPress == null ? null - : node.id; - }), + : () => widget.onNodeLongPress!(node.id), + onStartConnect: () => setState(() { + _connectingFrom = _connectingFrom == node.id + ? null + : node.id; + }), + ), ), ), ), @@ -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 { - _GraphPainter({required this.nodesById, required this.edges}); +class _DotGridPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + const step = 28.0; + final points = [ + 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 nodesById; + final Map positions; final List edges; @override void paint(Canvas canvas, Size size) { - _paintDotGrid(canvas, size); - final edgePaint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 2; @@ -349,12 +411,14 @@ class _GraphPainter extends CustomPainter { if (from == null || to == null) continue; final start = - from.position + + (positions[edge.fromId] ?? from.position) + const Offset( NodeGraphCanvas.nodeWidth, 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); @@ -384,22 +448,6 @@ class _GraphPainter extends CustomPainter { } } - void _paintDotGrid(Canvas canvas, Size size) { - const step = 28.0; - final points = [ - 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(_GraphPainter oldDelegate) => true; + bool shouldRepaint(_EdgesPainter oldDelegate) => true; } diff --git a/lib/presentation/grading/grading_page.dart b/lib/presentation/grading/grading_page.dart new file mode 100644 index 0000000..bc258f9 --- /dev/null +++ b/lib/presentation/grading/grading_page.dart @@ -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 createState() => _GradingPageState(); +} + +class _GradingPageState extends State { + late final GradingService _service = getIt(); + + @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( + 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), + ), + ); + } +} diff --git a/lib/presentation/shell/shell_page.dart b/lib/presentation/shell/shell_page.dart index 9e76063..a62ef85 100644 --- a/lib/presentation/shell/shell_page.dart +++ b/lib/presentation/shell/shell_page.dart @@ -20,6 +20,7 @@ class ShellPage extends StatelessWidget { TrainingsRoute(), AnalysisRoute(), CalendarRoute(), + GradingRoute(), ChatRoute(), ], builder: (context, child) { @@ -86,6 +87,11 @@ class _Sidebar extends StatelessWidget { activeIcon: Icons.calendar_today, label: 'Calendar', ), + _NavItemData( + icon: Icons.military_tech_outlined, + activeIcon: Icons.military_tech, + label: 'Grading', + ), _NavItemData( icon: Icons.chat_bubble_outline, activeIcon: Icons.chat_bubble, diff --git a/lib/presentation/trainings/widgets/combo_builder_tab.dart b/lib/presentation/trainings/widgets/combo_builder_tab.dart index 68947d2..ee9b3f5 100644 --- a/lib/presentation/trainings/widgets/combo_builder_tab.dart +++ b/lib/presentation/trainings/widgets/combo_builder_tab.dart @@ -95,6 +95,8 @@ class _ComboBuilderTabState extends State { _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) { _service.update( combo.copyWith( @@ -103,7 +105,6 @@ class _ComboBuilderTabState extends State { n.id == id ? n.copyWith(dx: position.dx, dy: position.dy) : n, ], ), - persist: false, ); } @@ -205,7 +206,6 @@ class _ComboBuilderTabState extends State { GraphEdgeData(e.fromId, e.toId), ], onNodeMoved: (id, pos) => _moveNode(combo, id, pos), - onNodeDragEnd: (_) => _service.persistNow(), onConnect: (from, to) => _toggleEdge(combo, from, to), onNodeLongPress: (id) => _deleteNode(combo, id), ),