diff --git a/.gitignore b/.gitignore index faeb7c3..873eedb 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,4 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release +/dist/ diff --git a/lib/core/constants/ai_constants.dart b/lib/core/constants/ai_constants.dart index 5aec6f0..2f93810 100644 --- a/lib/core/constants/ai_constants.dart +++ b/lib/core/constants/ai_constants.dart @@ -12,9 +12,12 @@ class AiConstants { 'http://localhost:$chatServerPort/v1/chat/completions'; static String get embeddingApiUrl => 'http://localhost:$embeddingServerPort/v1/embeddings'; + static String chatHealthUrl = 'http://localhost:$chatServerPort/health'; + static String embeddingHealthUrl = + 'http://localhost:$embeddingServerPort/health'; // Model files - static const String qwenModelFile = 'qwen2.5-7b-instruct-q4_k_m.gguf'; + static const String qwenModelFile = 'qwen3-4b-instruct-2507-q4_k_m.gguf'; static const String nomicModelFile = 'nomic-embed-text-v1.5.Q4_K_M.gguf'; static const String nomicModelName = 'nomic-embed-text-v1.5.Q4_K_M'; @@ -27,13 +30,30 @@ class AiConstants { static const String nomicModelUrl = 'https://huggingface.co/nomic-ai/nomic-embed-text-v1.5-GGUF/resolve/main/nomic-embed-text-v1.5.Q4_K_M.gguf'; static const String qwenModelUrl = - 'https://huggingface.co/bartowski/Qwen2.5-7B-Instruct-GGUF/resolve/main/Qwen2.5-7B-Instruct-Q4_K_M.gguf'; + 'https://huggingface.co/bartowski/Qwen_Qwen3-4B-Instruct-2507-GGUF/resolve/main/Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf'; + + // Human-readable model labels (single source of truth for UI copy) + static const String chatModelLabel = 'Qwen3 4B Instruct Q4_K_M'; + static const String embeddingModelLabel = 'Nomic Embed Text v1.5 Q4_K_M'; + + // Minimum plausible file sizes — a partially downloaded file is smaller. + static const int qwenModelMinBytes = 2_000_000_000; // full file ≈ 2.4 GB + static const int nomicModelMinBytes = 50_000_000; // full file ≈ 84 MB + static const int serverBinaryMinBytes = 1_000_000; // Server configuration - static const int qwenContextSize = 4096; - static const int nomicContextSize = 8192; + static const int qwenContextSize = 8192; + static const int nomicContextSize = 2048; static const int gpuLayerOffload = 99; static const double chatTemperature = 0.7; + static const int chatMaxTokens = 1536; + + /// How many most-recent messages are sent back to the model as history. + static const int chatHistoryLimit = 12; + + /// Nomic v1.5 requires task prefixes for good retrieval quality. + static const String nomicDocumentPrefix = 'search_document: '; + static const String nomicQueryPrefix = 'search_query: '; // Timeouts static const Duration serverConnectTimeout = Duration(seconds: 30); @@ -41,9 +61,21 @@ class AiConstants { static const Duration embeddingConnectTimeout = Duration(seconds: 10); static const Duration embeddingReceiveTimeout = Duration(seconds: 60); + /// Cold-loading a multi-GB model can take minutes: on the first Vulkan + /// run llama.cpp additionally compiles GPU pipelines for the device. + static const Duration serverStartupTimeout = Duration(minutes: 5); + + /// CPU fallback loads via mmap and needs no pipeline compilation. + static const Duration serverStartupTimeoutCpu = Duration(minutes: 3); + static const Duration healthPollInterval = Duration(milliseconds: 750); + // System prompt static const String baseSystemPrompt = - 'You are a helpful AI fitness assistant for personal trainers. ' - 'Help users design training plans, analyse exercise technique, ' - 'and answer questions about sports science and nutrition.'; + 'You are the AI coach inside TrainHub, a training hub for ITF Taekwon-Do. ' + 'You help with: designing drills and training plans (patterns/tul, ' + 'sparring/matsogi, kicks, conditioning), analysing technique, belt ' + 'grading preparation, and sports science questions. ' + 'Use correct ITF terminology (e.g. dollyo chagi, yop chagi, tul names). ' + 'Always answer in the same language the user writes in. ' + 'Be concise and practical — coaches read this between rounds.'; } diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart index f4fbe05..9d125a2 100644 --- a/lib/core/constants/app_constants.dart +++ b/lib/core/constants/app_constants.dart @@ -2,7 +2,7 @@ class AppConstants { AppConstants._(); static const String appName = 'TrainHub'; - static const String appVersion = '2.0.0'; + static const String appVersion = '2.1.0'; static const double windowWidth = 1280; static const double windowHeight = 800; static const double minWindowWidth = 800; diff --git a/lib/core/constants/ui_constants.dart b/lib/core/constants/ui_constants.dart index 00acf05..09cad62 100644 --- a/lib/core/constants/ui_constants.dart +++ b/lib/core/constants/ui_constants.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:trainhub_flutter/core/theme/app_colors.dart'; class UIConstants { UIConstants._(); @@ -29,4 +30,9 @@ class UIConstants { static BorderRadius get smallCardBorderRadius => BorderRadius.circular(smallBorderRadius); + + /// Soft red glow behind primary accents (CTA buttons, active timers). + static List get accentGlow => const [ + BoxShadow(color: AppColors.accentGlow, blurRadius: 24, spreadRadius: 1), + ]; } diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index e2f5b1d..d8dcc7a 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -16,19 +16,22 @@ class AppColors { static const Color zinc900 = Color(0xFF18181B); static const Color zinc950 = Color(0xFF09090B); - // Semantic colors - static const Color surface = zinc950; - static const Color surfaceContainer = zinc900; - static const Color surfaceContainerHigh = zinc800; - static const Color border = zinc800; - static const Color borderSubtle = Color(0xFF1F1F23); + // Semantic colors — "dojang" dark: near-black surfaces, subtle borders + static const Color surface = Color(0xFF070708); + static const Color surfaceContainer = Color(0xFF111113); + static const Color surfaceContainerHigh = Color(0xFF1A1A1D); + static const Color border = Color(0xFF232326); + static const Color borderSubtle = Color(0xFF1A1A1E); static const Color textPrimary = zinc50; static const Color textSecondary = zinc400; static const Color textMuted = zinc500; - // Accent colors - static const Color accent = Color(0xFFFF9800); - static const Color accentMuted = Color(0x33FF9800); + // Accent colors — signature red + static const Color accent = Color(0xFFFF2B2B); + static const Color accentBright = Color(0xFFFF4D4D); + static const Color accentMuted = Color(0x2EFF2B2B); + static const Color accentBorder = Color(0x40FF2B2B); + static const Color accentGlow = Color(0x59FF2B2B); static const Color success = Color(0xFF22C55E); static const Color successMuted = Color(0x3322C55E); static const Color destructive = Color(0xFFEF4444); diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 2ba3683..119f2ea 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -10,23 +10,32 @@ class AppTheme { useMaterial3: true, brightness: Brightness.dark, colorScheme: const ColorScheme.dark( - primary: AppColors.zinc50, + primary: AppColors.accent, onPrimary: AppColors.zinc950, secondary: AppColors.zinc200, onSecondary: AppColors.zinc950, - surface: AppColors.zinc950, + surface: AppColors.surface, onSurface: AppColors.zinc50, - surfaceContainer: AppColors.zinc900, + surfaceContainer: AppColors.surfaceContainer, error: AppColors.destructive, onError: AppColors.zinc50, - outline: AppColors.zinc800, + outline: AppColors.border, outlineVariant: AppColors.zinc700, ), scaffoldBackgroundColor: AppColors.surface, - textTheme: GoogleFonts.interTextTheme(ThemeData.dark().textTheme).apply( - bodyColor: AppColors.textPrimary, - displayColor: AppColors.textPrimary, - ), + textTheme: GoogleFonts.interTextTheme(ThemeData.dark().textTheme) + .copyWith( + displayLarge: GoogleFonts.chakraPetch(fontWeight: FontWeight.w700), + displayMedium: GoogleFonts.chakraPetch(fontWeight: FontWeight.w700), + displaySmall: GoogleFonts.chakraPetch(fontWeight: FontWeight.w700), + headlineLarge: GoogleFonts.chakraPetch(fontWeight: FontWeight.w700), + headlineMedium: GoogleFonts.chakraPetch(fontWeight: FontWeight.w600), + headlineSmall: GoogleFonts.chakraPetch(fontWeight: FontWeight.w600), + ) + .apply( + bodyColor: AppColors.textPrimary, + displayColor: AppColors.textPrimary, + ), cardTheme: CardThemeData( color: AppColors.surfaceContainer, elevation: 0, @@ -41,13 +50,10 @@ class AppTheme { thickness: 1, space: 1, ), - iconTheme: const IconThemeData( - color: AppColors.textSecondary, - size: 20, - ), + iconTheme: const IconThemeData(color: AppColors.textSecondary, size: 20), navigationRailTheme: NavigationRailThemeData( backgroundColor: AppColors.surfaceContainer, - selectedIconTheme: const IconThemeData(color: AppColors.textPrimary), + selectedIconTheme: const IconThemeData(color: AppColors.accent), unselectedIconTheme: const IconThemeData(color: AppColors.textMuted), selectedLabelTextStyle: GoogleFonts.inter( fontSize: 12, @@ -59,7 +65,7 @@ class AppTheme { fontWeight: FontWeight.w400, color: AppColors.textMuted, ), - indicatorColor: AppColors.zinc700, + indicatorColor: AppColors.accentMuted, ), inputDecorationTheme: InputDecorationTheme( filled: true, @@ -75,12 +81,9 @@ class AppTheme { ), focusedBorder: OutlineInputBorder( borderRadius: UIConstants.smallCardBorderRadius, - borderSide: const BorderSide(color: AppColors.zinc400, width: 1), - ), - hintStyle: GoogleFonts.inter( - color: AppColors.textMuted, - fontSize: 14, + borderSide: const BorderSide(color: AppColors.accent, width: 1), ), + hintStyle: GoogleFonts.inter(color: AppColors.textMuted, fontSize: 14), labelStyle: GoogleFonts.inter( color: AppColors.textSecondary, fontSize: 14, @@ -100,15 +103,16 @@ class AppTheme { ), filledButtonTheme: FilledButtonThemeData( style: FilledButton.styleFrom( - backgroundColor: AppColors.zinc50, + backgroundColor: AppColors.accent, foregroundColor: AppColors.zinc950, shape: RoundedRectangleBorder( borderRadius: UIConstants.smallCardBorderRadius, ), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - textStyle: GoogleFonts.inter( + textStyle: GoogleFonts.chakraPetch( fontSize: 14, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, + letterSpacing: 0.8, ), ), ), @@ -120,10 +124,7 @@ class AppTheme { borderRadius: UIConstants.smallCardBorderRadius, ), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - textStyle: GoogleFonts.inter( - fontSize: 14, - fontWeight: FontWeight.w500, - ), + textStyle: GoogleFonts.inter(fontSize: 14, fontWeight: FontWeight.w500), ), ), textButtonTheme: TextButtonThemeData( @@ -133,24 +134,23 @@ class AppTheme { borderRadius: UIConstants.smallCardBorderRadius, ), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - textStyle: GoogleFonts.inter( - fontSize: 14, - fontWeight: FontWeight.w500, - ), + textStyle: GoogleFonts.inter(fontSize: 14, fontWeight: FontWeight.w500), ), ), tabBarTheme: TabBarThemeData( labelColor: AppColors.textPrimary, unselectedLabelColor: AppColors.textMuted, - indicatorColor: AppColors.textPrimary, + indicatorColor: AppColors.accent, dividerColor: AppColors.border, - labelStyle: GoogleFonts.inter( - fontSize: 14, - fontWeight: FontWeight.w500, + labelStyle: GoogleFonts.chakraPetch( + fontSize: 13, + fontWeight: FontWeight.w600, + letterSpacing: 1, ), - unselectedLabelStyle: GoogleFonts.inter( - fontSize: 14, - fontWeight: FontWeight.w400, + unselectedLabelStyle: GoogleFonts.chakraPetch( + fontSize: 13, + fontWeight: FontWeight.w500, + letterSpacing: 1, ), ), snackBarTheme: SnackBarThemeData( @@ -184,15 +184,13 @@ class AppTheme { checkboxTheme: CheckboxThemeData( fillColor: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.selected)) { - return AppColors.zinc50; + return AppColors.accent; } return Colors.transparent; }), checkColor: WidgetStateProperty.all(AppColors.zinc950), side: const BorderSide(color: AppColors.zinc400), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), ), tooltipTheme: TooltipThemeData( decoration: BoxDecoration( @@ -200,10 +198,7 @@ class AppTheme { borderRadius: BorderRadius.circular(6), border: Border.all(color: AppColors.zinc700), ), - textStyle: GoogleFonts.inter( - color: AppColors.textPrimary, - fontSize: 12, - ), + textStyle: GoogleFonts.inter(color: AppColors.textPrimary, fontSize: 12), ), ); } diff --git a/lib/core/theme/app_typography.dart b/lib/core/theme/app_typography.dart index 97f0181..57cbfbf 100644 --- a/lib/core/theme/app_typography.dart +++ b/lib/core/theme/app_typography.dart @@ -5,99 +5,114 @@ import 'package:trainhub_flutter/core/theme/app_colors.dart'; class AppTypography { AppTypography._(); - static TextStyle get displayLarge => GoogleFonts.inter( - fontSize: 36, - fontWeight: FontWeight.w700, - color: AppColors.textPrimary, - height: 1.2, - ); + /// Display font for headings — squarish techno look ("dojang" style). + /// Pair with `.toUpperCase()` on the text for the full effect. + static TextStyle get displayLarge => GoogleFonts.chakraPetch( + fontSize: 36, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + height: 1.2, + letterSpacing: 0.5, + ); - static TextStyle get headlineLarge => GoogleFonts.inter( - fontSize: 28, - fontWeight: FontWeight.w700, - color: AppColors.textPrimary, - height: 1.3, - ); + static TextStyle get headlineLarge => GoogleFonts.chakraPetch( + fontSize: 28, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + height: 1.3, + letterSpacing: 0.5, + ); - static TextStyle get headlineMedium => GoogleFonts.inter( - fontSize: 24, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary, - height: 1.3, - ); + static TextStyle get headlineMedium => GoogleFonts.chakraPetch( + fontSize: 24, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + height: 1.3, + letterSpacing: 0.5, + ); - static TextStyle get titleLarge => GoogleFonts.inter( - fontSize: 20, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary, - height: 1.4, - ); + static TextStyle get titleLarge => GoogleFonts.chakraPetch( + fontSize: 20, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + height: 1.4, + letterSpacing: 0.3, + ); static TextStyle get titleMedium => GoogleFonts.inter( - fontSize: 16, - fontWeight: FontWeight.w500, - color: AppColors.textPrimary, - height: 1.4, - ); + fontSize: 16, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + height: 1.4, + ); static TextStyle get titleSmall => GoogleFonts.inter( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppColors.textPrimary, - height: 1.4, - ); + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + height: 1.4, + ); static TextStyle get bodyLarge => GoogleFonts.inter( - fontSize: 16, - fontWeight: FontWeight.w400, - color: AppColors.textPrimary, - height: 1.5, - ); + fontSize: 16, + fontWeight: FontWeight.w400, + color: AppColors.textPrimary, + height: 1.5, + ); static TextStyle get bodyMedium => GoogleFonts.inter( - fontSize: 14, - fontWeight: FontWeight.w400, - color: AppColors.textPrimary, - height: 1.5, - ); + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.textPrimary, + height: 1.5, + ); static TextStyle get bodySmall => GoogleFonts.inter( - fontSize: 12, - fontWeight: FontWeight.w400, - color: AppColors.textSecondary, - height: 1.5, - ); + fontSize: 12, + fontWeight: FontWeight.w400, + color: AppColors.textSecondary, + height: 1.5, + ); static TextStyle get labelLarge => GoogleFonts.inter( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppColors.textPrimary, - height: 1.4, - ); + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + height: 1.4, + ); static TextStyle get labelMedium => GoogleFonts.inter( - fontSize: 12, - fontWeight: FontWeight.w500, - color: AppColors.textPrimary, - height: 1.4, - ); + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + height: 1.4, + ); static TextStyle get caption => GoogleFonts.inter( - fontSize: 12, - fontWeight: FontWeight.w400, - color: AppColors.textMuted, - height: 1.4, - ); + fontSize: 12, + fontWeight: FontWeight.w400, + color: AppColors.textMuted, + height: 1.4, + ); static TextStyle get monoLarge => GoogleFonts.jetBrainsMono( - fontSize: 72, - fontWeight: FontWeight.w700, - color: AppColors.textPrimary, - ); + fontSize: 72, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ); static TextStyle get monoMedium => GoogleFonts.jetBrainsMono( - fontSize: 14, - fontWeight: FontWeight.w400, - color: AppColors.textPrimary, - ); + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.textPrimary, + ); + + /// Small tracked-out mono label, e.g. "TODAY · THU 29 MAY". + /// Callers uppercase the text and override the color (often [AppColors.accent]). + static TextStyle get overline => GoogleFonts.jetBrainsMono( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.textMuted, + letterSpacing: 2, + ); } diff --git a/lib/data/repositories/note_repository_impl.dart b/lib/data/repositories/note_repository_impl.dart index e18ba96..4d83036 100644 --- a/lib/data/repositories/note_repository_impl.dart +++ b/lib/data/repositories/note_repository_impl.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:math' show sqrt; import 'package:drift/drift.dart'; +import 'package:trainhub_flutter/core/constants/ai_constants.dart'; import 'package:trainhub_flutter/data/database/app_database.dart'; import 'package:trainhub_flutter/data/database/daos/knowledge_chunk_dao.dart'; import 'package:trainhub_flutter/domain/repositories/note_repository.dart'; @@ -29,7 +30,11 @@ class NoteRepositoryImpl implements NoteRepository { final now = DateTime.now().toIso8601String(); for (final chunk in chunks) { - final embedding = await _embeddingService.embed(chunk); + // Nomic v1.5 is trained with task prefixes — embedding without them + // noticeably degrades retrieval quality. + final embedding = await _embeddingService.embed( + '${AiConstants.nomicDocumentPrefix}$chunk', + ); await _dao.insertChunk( KnowledgeChunksCompanion( id: Value(_uuid.v4()), @@ -47,19 +52,19 @@ class NoteRepositoryImpl implements NoteRepository { final allRows = await _dao.getAllChunks(); if (allRows.isEmpty) return []; - final queryEmbedding = await _embeddingService.embed(query); + final queryEmbedding = await _embeddingService.embed( + '${AiConstants.nomicQueryPrefix}$query', + ); final scored = allRows.map((row) { - final emb = - (jsonDecode(row.embedding) as List) - .map((e) => (e as num).toDouble()) - .toList(); + final emb = (jsonDecode(row.embedding) as List) + .map((e) => (e as num).toDouble()) + .toList(); return _Scored( score: _cosineSimilarity(queryEmbedding, emb), text: row.content, ); - }).toList() - ..sort((a, b) => b.score.compareTo(a.score)); + }).toList()..sort((a, b) => b.score.compareTo(a.score)); return scored.take(topK).map((s) => s.text).toList(); } @@ -92,13 +97,11 @@ class NoteRepositoryImpl implements NoteRepository { } // Split long paragraph by sentence boundaries (. ! ?) - final sentences = - p.split(RegExp(r'(?<=[.!?])\s+')); + final sentences = p.split(RegExp(r'(?<=[.!?])\s+')); var current = ''; for (final sentence in sentences) { - final candidate = - current.isEmpty ? sentence : '$current $sentence'; + final candidate = current.isEmpty ? sentence : '$current $sentence'; if (candidate.length <= maxChars) { current = candidate; } else { diff --git a/lib/data/services/ai_process_manager.dart b/lib/data/services/ai_process_manager.dart index 6230d9d..85314f3 100644 --- a/lib/data/services/ai_process_manager.dart +++ b/lib/data/services/ai_process_manager.dart @@ -1,6 +1,8 @@ +import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:trainhub_flutter/core/constants/ai_constants.dart'; @@ -12,13 +14,26 @@ enum AiServerStatus { offline, starting, ready, error } /// Both processes are kept alive for the lifetime of the app and must be /// killed on shutdown to prevent zombie processes from consuming RAM. /// -/// - Qwen 2.5 7B → port 8080 (chat / completions) -/// - Nomic Embed → port 8081 (embeddings) +/// - Qwen3 4B → port 8080 (chat / completions) +/// - Nomic Embed → port 8081 (embeddings) +/// +/// Startup strategy: first attempt offloads all layers to the GPU (Vulkan). +/// If the chat server does not become healthy in time — first-run pipeline +/// compilation can be very slow, and some GPUs hang outright — everything is +/// restarted in CPU-only mode, which loads via mmap and is reliably fast. class AiProcessManager extends ChangeNotifier { Process? _qwenProcess; Process? _nomicProcess; AiServerStatus _status = AiServerStatus.offline; String? _lastError; + String? _statusDetail; + + // Last stderr lines per server — llama.cpp prints its actual failure + // reason (bad model file, out of memory, …) there, so keep a short tail + // to show the user instead of a generic "crashed" message. + final List _qwenStderrTail = []; + final List _nomicStderrTail = []; + static const int _stderrTailLines = 8; // ------------------------------------------------------------------------- // Public API @@ -28,6 +43,10 @@ class AiProcessManager extends ChangeNotifier { bool get isRunning => _status == AiServerStatus.ready; String? get errorMessage => _lastError; + /// Live progress line while [status] is [AiServerStatus.starting], + /// e.g. "Loading AI model on GPU… 45s". + String? get statusDetail => _statusDetail; + /// Starts both inference servers. No-ops if already running or starting. Future startServers() async { if (_status == AiServerStatus.starting || _status == AiServerStatus.ready) { @@ -48,97 +67,64 @@ class AiProcessManager extends ChangeNotifier { } try { - _qwenProcess = await Process.start(serverBin, [ - '-m', p.join(base, AiConstants.qwenModelFile), - '--port', '${AiConstants.chatServerPort}', - '--ctx-size', '${AiConstants.qwenContextSize}', - '-ngl', '${AiConstants.gpuLayerOffload}', - ], runInShell: false); - - _qwenProcess!.stdout.listen((event) { - if (kDebugMode) print('[QWEN STDOUT] ${String.fromCharCodes(event)}'); - }); - _qwenProcess!.stderr.listen((event) { - if (kDebugMode) print('[QWEN STDERR] ${String.fromCharCodes(event)}'); - }); - - // Monitor for unexpected crash - _qwenProcess!.exitCode.then((code) { - if (_status == AiServerStatus.ready || - _status == AiServerStatus.starting) { - _lastError = 'Qwen Chat Server crashed with code $code'; - _updateStatus(AiServerStatus.error); - } - }); - - _nomicProcess = await Process.start(serverBin, [ - '-m', p.join(base, AiConstants.nomicModelFile), - '--port', '${AiConstants.embeddingServerPort}', - '--ctx-size', '${AiConstants.nomicContextSize}', - '--embedding', - ], runInShell: false); - - _nomicProcess!.stdout.listen((event) { - if (kDebugMode) print('[NOMIC STDOUT] ${String.fromCharCodes(event)}'); - }); - _nomicProcess!.stderr.listen((event) { - if (kDebugMode) print('[NOMIC STDERR] ${String.fromCharCodes(event)}'); - }); - - // Monitor for unexpected crash - _nomicProcess!.exitCode.then((code) { - if (_status == AiServerStatus.ready || - _status == AiServerStatus.starting) { - _lastError = 'Nomic Embedding Server crashed with code $code'; - _updateStatus(AiServerStatus.error); - } - }); - - // Wait for servers to bind to their ports and allocate memory. - // This is crucial because loading models (especially 7B) takes several - // seconds and significant RAM, which might cause the dart process to appear hung. - int attempts = 0; - bool qwenReady = false; - bool nomicReady = false; - - while (attempts < 20 && (!qwenReady || !nomicReady)) { - await Future.delayed(const Duration(milliseconds: 500)); - - if (!qwenReady) { - qwenReady = await _isPortReady(AiConstants.chatServerPort); - } - if (!nomicReady) { - nomicReady = await _isPortReady(AiConstants.embeddingServerPort); - } - - attempts++; + // Attempt 1: full GPU offload. + final gpuReady = await _startAttempt( + serverBin, + base, + gpuLayers: AiConstants.gpuLayerOffload, + timeout: AiConstants.serverStartupTimeout, + detailLabel: 'GPU', + ); + if (gpuReady) { + _updateStatus(AiServerStatus.ready); + return; } - if (!qwenReady || !nomicReady) { - throw Exception('Servers failed to start within 10 seconds.'); + // Attempt 2: CPU only. Covers GPUs where Vulkan hangs, crashes + // (e.g. out of VRAM) or pipeline compilation never finishes. + await _killProcesses(); + _lastError = null; + _qwenStderrTail.clear(); + _nomicStderrTail.clear(); + final cpuReady = await _startAttempt( + serverBin, + base, + gpuLayers: 0, + timeout: AiConstants.serverStartupTimeoutCpu, + detailLabel: 'CPU', + ); + if (cpuReady) { + _updateStatus(AiServerStatus.ready); + return; } - _updateStatus(AiServerStatus.ready); + throw Exception( + _lastError ?? + 'The AI server did not become ready (tried GPU, then CPU).' + '${_diagnostics()}', + ); } catch (e) { // Clean up any partially-started processes before returning error. - _qwenProcess?.kill(); - _nomicProcess?.kill(); - _qwenProcess = null; - _nomicProcess = null; + await _killProcesses(); _lastError = e.toString(); _updateStatus(AiServerStatus.error); + } finally { + _statusDetail = null; } } /// Kills both processes and resets the running flag. /// Safe to call even if servers were never started. Future stopServers() async { - _qwenProcess?.kill(); - _nomicProcess?.kill(); + await _killProcesses(); if (Platform.isWindows) { try { - await Process.run('taskkill', ['/F', '/IM', AiConstants.serverBinaryName]); + await Process.run('taskkill', [ + '/F', + '/IM', + AiConstants.serverBinaryName, + ]); } catch (_) {} } else if (Platform.isMacOS || Platform.isLinux) { try { @@ -146,25 +132,157 @@ class AiProcessManager extends ChangeNotifier { } catch (_) {} } - _qwenProcess = null; - _nomicProcess = null; _updateStatus(AiServerStatus.offline); } - Future _isPortReady(int port) async { + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + /// Spawns both servers with the given GPU offload and waits for health. + /// Returns false on timeout; rethrows nothing — a crashed process is + /// reported through [_lastError] by the exit watcher and detected here. + Future _startAttempt( + String serverBin, + String base, { + required int gpuLayers, + required Duration timeout, + required String detailLabel, + }) async { + _qwenProcess = await _spawn( + serverBin, + [ + '-m', p.join(base, AiConstants.qwenModelFile), + '--port', '${AiConstants.chatServerPort}', + '--ctx-size', '${AiConstants.qwenContextSize}', + '-ngl', '$gpuLayers', + // Use the chat template embedded in the GGUF (required for + // correct Qwen3 formatting). + '--jinja', + ], + label: 'QWEN', + stderrTail: _qwenStderrTail, + crashMessage: 'Qwen Chat Server crashed', + ); + + _nomicProcess = await _spawn( + serverBin, + [ + '-m', + p.join(base, AiConstants.nomicModelFile), + '--port', + '${AiConstants.embeddingServerPort}', + '--ctx-size', + '${AiConstants.nomicContextSize}', + '--embedding', + ], + label: 'NOMIC', + stderrTail: _nomicStderrTail, + crashMessage: 'Nomic Embedding Server crashed', + ); + + // llama-server binds its port almost immediately but returns 503 until + // the model is fully loaded, so poll /health rather than the TCP port. + final started = DateTime.now(); + final deadline = started.add(timeout); + var qwenReady = false; + var nomicReady = false; + + while (DateTime.now().isBefore(deadline) && (!qwenReady || !nomicReady)) { + // A crashed process will never become healthy — give up on this + // attempt right away (the caller may retry on CPU). + if (_lastError != null) return false; + + await Future.delayed(AiConstants.healthPollInterval); + if (!qwenReady) { + qwenReady = await _isHealthy(AiConstants.chatHealthUrl); + } + if (!nomicReady) { + nomicReady = await _isHealthy(AiConstants.embeddingHealthUrl); + } + + final elapsed = DateTime.now().difference(started).inSeconds; + _statusDetail = + 'Loading AI model on $detailLabel… ${elapsed}s ' + '(first run can take a few minutes)'; + notifyListeners(); + } + + return qwenReady && nomicReady; + } + + Future _killProcesses() async { + // Detach before killing so the exit watchers recognise an intentional + // shutdown and don't flag it as a crash. + final qwen = _qwenProcess; + final nomic = _nomicProcess; + _qwenProcess = null; + _nomicProcess = null; + qwen?.kill(); + nomic?.kill(); + } + + Future _spawn( + String binary, + List args, { + required String label, + required List stderrTail, + required String crashMessage, + }) async { + final process = await Process.start(binary, args, runInShell: false); + + process.stdout.transform(utf8.decoder).listen((text) { + if (kDebugMode) debugPrint('[$label STDOUT] $text'); + }); + process.stderr.transform(utf8.decoder).listen((text) { + for (final line in const LineSplitter().convert(text)) { + if (line.trim().isEmpty) continue; + stderrTail.add(line); + if (stderrTail.length > _stderrTailLines) stderrTail.removeAt(0); + } + if (kDebugMode) debugPrint('[$label STDERR] $text'); + }); + + // Monitor for unexpected crash. Intentional kills (retry, shutdown) + // null out the process reference first, so they are ignored here. + process.exitCode.then((code) { + final isCurrent = + identical(process, _qwenProcess) || identical(process, _nomicProcess); + if (!isCurrent) return; + if (_status == AiServerStatus.ready || + _status == AiServerStatus.starting) { + _lastError = '$crashMessage (exit code $code).${_diagnostics()}'; + if (_status == AiServerStatus.ready) { + _updateStatus(AiServerStatus.error); + } + } + }); + + return process; + } + + Future _isHealthy(String url) async { try { - final socket = await Socket.connect( - '127.0.0.1', - port, - timeout: const Duration(seconds: 1), - ); - socket.destroy(); - return true; + final response = await http + .get(Uri.parse(url)) + .timeout(const Duration(seconds: 2)); + return response.statusCode == 200; } catch (_) { return false; } } + String _diagnostics() { + final buffer = StringBuffer(); + if (_qwenStderrTail.isNotEmpty) { + buffer.write('\n\nChat server log:\n${_qwenStderrTail.join('\n')}'); + } + if (_nomicStderrTail.isNotEmpty) { + buffer.write('\n\nEmbedding server log:\n${_nomicStderrTail.join('\n')}'); + } + return buffer.toString(); + } + void _updateStatus(AiServerStatus newStatus) { if (_status != newStatus) { _status = newStatus; diff --git a/lib/data/services/ai_settings_service.dart b/lib/data/services/ai_settings_service.dart new file mode 100644 index 0000000..f564ac5 --- /dev/null +++ b/lib/data/services/ai_settings_service.dart @@ -0,0 +1,160 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; +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 } + +extension AiProviderX on AiProvider { + String get label => switch (this) { + AiProvider.local => 'Built-in (Qwen3 4B)', + AiProvider.ollama => 'Ollama (local)', + AiProvider.lmstudio => 'LM Studio (local)', + AiProvider.anthropic => 'Anthropic (Claude)', + AiProvider.openai => 'OpenAI (GPT)', + AiProvider.gemini => 'Google (Gemini)', + }; + + String get defaultModel => switch (this) { + AiProvider.local => 'local', + AiProvider.ollama => 'qwen3:4b', + AiProvider.lmstudio => 'local-model', + AiProvider.anthropic => 'claude-opus-4-8', + AiProvider.openai => 'gpt-5.1', + AiProvider.gemini => 'gemini-2.5-flash', + }; + + /// Self-hosted OpenAI-compatible servers reachable over a base URL. + bool get isSelfHosted => + this == AiProvider.ollama || this == AiProvider.lmstudio; + + String get defaultBaseUrl => switch (this) { + AiProvider.ollama => 'http://localhost:11434', + AiProvider.lmstudio => 'http://localhost:1234', + _ => '', + }; + + bool get needsApiKey => switch (this) { + AiProvider.anthropic || AiProvider.openai || AiProvider.gemini => true, + _ => false, + }; +} + +class AiSettings { + const AiSettings({ + this.provider = AiProvider.local, + this.apiKeys = const {}, + this.models = const {}, + this.baseUrls = const {}, + }); + + final AiProvider provider; + + /// API key per provider name — kept separately so switching providers + /// doesn't lose previously entered keys. + final Map apiKeys; + + /// Model override per provider name; falls back to [AiProviderX.defaultModel]. + final Map models; + + /// Base URL override per provider name (Ollama / LM Studio); + /// falls back to [AiProviderX.defaultBaseUrl]. + final Map baseUrls; + + String? apiKeyFor(AiProvider p) { + final key = apiKeys[p.name]?.trim(); + return (key == null || key.isEmpty) ? null : key; + } + + String modelFor(AiProvider p) { + final model = models[p.name]?.trim(); + return (model == null || model.isEmpty) ? p.defaultModel : model; + } + + String baseUrlFor(AiProvider p) { + final url = baseUrls[p.name]?.trim(); + final resolved = (url == null || url.isEmpty) ? p.defaultBaseUrl : url; + // Tolerate a trailing slash pasted in by the user. + return resolved.endsWith('/') + ? resolved.substring(0, resolved.length - 1) + : resolved; + } + + /// True when [provider] can serve chat: self-hosted and built-in servers + /// need no key, cloud providers do. + bool get isProviderConfigured => + !provider.needsApiKey || apiKeyFor(provider) != null; + + AiSettings copyWith({ + AiProvider? provider, + Map? apiKeys, + Map? models, + Map? baseUrls, + }) { + return AiSettings( + provider: provider ?? this.provider, + apiKeys: apiKeys ?? this.apiKeys, + models: models ?? this.models, + baseUrls: baseUrls ?? this.baseUrls, + ); + } + + Map toJson() => { + 'provider': provider.name, + 'apiKeys': apiKeys, + 'models': models, + 'baseUrls': baseUrls, + }; + + factory AiSettings.fromJson(Map json) { + return AiSettings( + provider: AiProvider.values.firstWhere( + (p) => p.name == json['provider'], + orElse: () => AiProvider.local, + ), + apiKeys: Map.from(json['apiKeys'] as Map? ?? {}), + models: Map.from(json['models'] as Map? ?? {}), + baseUrls: Map.from(json['baseUrls'] as Map? ?? {}), + ); + } +} + +/// Loads and persists [AiSettings] as a JSON file in the app documents dir. +class AiSettingsService extends ChangeNotifier { + static const _fileName = 'trainhub_ai_settings.json'; + + AiSettings _settings = const AiSettings(); + AiSettings get settings => _settings; + + Future _file() async { + final dir = await getApplicationDocumentsDirectory(); + return File(p.join(dir.path, _fileName)); + } + + Future load() async { + try { + final file = await _file(); + if (!file.existsSync()) return; + final json = jsonDecode(await file.readAsString()); + _settings = AiSettings.fromJson(json as Map); + notifyListeners(); + } catch (e) { + if (kDebugMode) debugPrint('Failed to load AI settings: $e'); + } + } + + Future save(AiSettings settings) async { + _settings = settings; + notifyListeners(); + try { + final file = await _file(); + await file.writeAsString(jsonEncode(settings.toJson())); + } catch (e) { + if (kDebugMode) debugPrint('Failed to save AI settings: $e'); + } + } +} diff --git a/lib/data/services/combo_service.dart b/lib/data/services/combo_service.dart new file mode 100644 index 0000000..a36aad3 --- /dev/null +++ b/lib/data/services/combo_service.dart @@ -0,0 +1,204 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:uuid/uuid.dart'; + +const _uuid = Uuid(); + +/// One technique node in a combo graph. +class ComboNode { + const ComboNode({ + required this.id, + required this.technique, + this.translation = '', + required this.dx, + required this.dy, + }); + + final String id; + final String technique; + final String translation; + final double dx; + final double dy; + + ComboNode copyWith({double? dx, double? dy}) => ComboNode( + id: id, + technique: technique, + translation: translation, + dx: dx ?? this.dx, + dy: dy ?? this.dy, + ); + + Map toJson() => { + 'id': id, + 'technique': technique, + 'translation': translation, + 'dx': dx, + 'dy': dy, + }; + + factory ComboNode.fromJson(Map json) => ComboNode( + id: json['id'] as String, + technique: json['technique'] as String, + translation: (json['translation'] ?? '') as String, + dx: (json['dx'] as num).toDouble(), + dy: (json['dy'] as num).toDouble(), + ); +} + +/// A directed transition between two techniques. +class ComboEdge { + const ComboEdge(this.fromId, this.toId); + + final String fromId; + final String toId; + + Map toJson() => {'from': fromId, 'to': toId}; + + factory ComboEdge.fromJson(Map json) => + ComboEdge(json['from'] as String, json['to'] as String); +} + +/// A named technique graph: nodes are techniques, edges are the transitions +/// that make sense between them. +class Combo { + const Combo({ + required this.id, + required this.name, + this.nodes = const [], + this.edges = const [], + }); + + final String id; + final String name; + final List nodes; + final List edges; + + Combo copyWith({ + String? name, + List? nodes, + List? edges, + }) => Combo( + id: id, + name: name ?? this.name, + nodes: nodes ?? this.nodes, + edges: edges ?? this.edges, + ); + + Map toJson() => { + 'id': id, + 'name': name, + 'nodes': nodes.map((n) => n.toJson()).toList(), + 'edges': edges.map((e) => e.toJson()).toList(), + }; + + factory Combo.fromJson(Map json) => Combo( + id: json['id'] as String, + name: json['name'] as String, + nodes: [ + for (final n in (json['nodes'] as List? ?? [])) + ComboNode.fromJson(n as Map), + ], + edges: [ + for (final e in (json['edges'] as List? ?? [])) + ComboEdge.fromJson(e as Map), + ], + ); + + /// Random walk over the graph — the generated combination to drill. + /// Follows outgoing edges; if a node is a dead end the walk stops there. + List generateSequence({int length = 4, Random? random}) { + if (nodes.isEmpty) return []; + final rnd = random ?? Random(); + final byId = {for (final n in nodes) n.id: n}; + final outgoing = >{}; + for (final e in edges) { + outgoing.putIfAbsent(e.fromId, () => []).add(e.toId); + } + + // Prefer starting somewhere that actually leads on. + final starts = nodes.where((n) => outgoing.containsKey(n.id)).toList(); + var current = (starts.isEmpty + ? nodes + : starts)[rnd.nextInt((starts.isEmpty ? nodes : starts).length)]; + + final sequence = [current]; + while (sequence.length < length) { + final next = outgoing[current.id]; + if (next == null || next.isEmpty) break; + current = byId[next[rnd.nextInt(next.length)]]!; + sequence.add(current); + } + return sequence; + } +} + +/// Loads and persists combos as a JSON file in the app documents dir. +class ComboService extends ChangeNotifier { + static const _fileName = 'trainhub_combos.json'; + + List _combos = []; + bool _loaded = false; + + List get combos => _combos; + + 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()) as List; + _combos = [ + for (final c in json) Combo.fromJson(c as Map), + ]; + notifyListeners(); + } catch (e) { + if (kDebugMode) debugPrint('Failed to load combos: $e'); + } + } + + Combo create(String name) { + final combo = Combo(id: _uuid.v4(), name: name); + _combos = [..._combos, combo]; + notifyListeners(); + _persist(); + return combo; + } + + void delete(String id) { + _combos = _combos.where((c) => c.id != id).toList(); + notifyListeners(); + _persist(); + } + + /// Replaces a combo. Set [persist] false for high-frequency updates + /// (node dragging) and call [persistNow] once afterwards. + void update(Combo combo, {bool persist = true}) { + _combos = [for (final c in _combos) c.id == combo.id ? combo : c]; + notifyListeners(); + if (persist) _persist(); + } + + void persistNow() => _persist(); + + Future _persist() async { + try { + final file = await _file(); + await file.writeAsString( + jsonEncode(_combos.map((c) => c.toJson()).toList()), + ); + } catch (e) { + if (kDebugMode) debugPrint('Failed to save combos: $e'); + } + } +} diff --git a/lib/data/services/embedding_service.dart b/lib/data/services/embedding_service.dart index 718afe5..1e8f7ae 100644 --- a/lib/data/services/embedding_service.dart +++ b/lib/data/services/embedding_service.dart @@ -16,10 +16,7 @@ class EmbeddingService { Future> embed(String text) async { final response = await _dio.post>( AiConstants.embeddingApiUrl, - data: { - 'input': text, - 'model': AiConstants.nomicModelName, - }, + data: {'input': text, 'model': AiConstants.nomicModelName}, ); final raw = diff --git a/lib/data/services/llm_client.dart b/lib/data/services/llm_client.dart new file mode 100644 index 0000000..7203348 --- /dev/null +++ b/lib/data/services/llm_client.dart @@ -0,0 +1,200 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:trainhub_flutter/core/constants/ai_constants.dart'; +import 'package:trainhub_flutter/data/services/ai_settings_service.dart'; + +/// Streams chat completions from the configured AI provider. +/// +/// - Local llama.cpp, OpenAI and Gemini speak the OpenAI-compatible +/// `/chat/completions` SSE format. +/// - Anthropic uses its native `/v1/messages` SSE format. +/// +/// Owns all HTTP/SSE plumbing so that controllers only deal with a plain +/// `Stream` of content deltas. +class LlmClient { + LlmClient(this._settingsService, {Dio? dio}) + : _dio = + dio ?? + Dio( + BaseOptions( + connectTimeout: AiConstants.serverConnectTimeout, + receiveTimeout: AiConstants.serverReceiveTimeout, + ), + ); + + final AiSettingsService _settingsService; + final Dio _dio; + + AiProvider get activeProvider => _settingsService.settings.provider; + + /// Streams content deltas for a chat completion. + /// + /// [messages] must already include the system prompt and history. + /// Pass a [cancelToken] to allow aborting generation mid-stream. + /// Throws [DioException] if the server is unreachable; errors that occur + /// mid-stream are surfaced through the returned stream. + Stream streamChat( + List> messages, { + CancelToken? cancelToken, + }) { + final settings = _settingsService.settings; + return switch (settings.provider) { + AiProvider.anthropic => _streamAnthropic( + messages, + settings, + cancelToken: cancelToken, + ), + _ => _streamOpenAiCompatible( + messages, + settings, + cancelToken: cancelToken, + ), + }; + } + + // --------------------------------------------------------------------------- + // OpenAI-compatible providers: local llama.cpp, OpenAI, Gemini + // --------------------------------------------------------------------------- + + Stream _streamOpenAiCompatible( + List> messages, + AiSettings settings, { + CancelToken? cancelToken, + }) async* { + final provider = settings.provider; + final isBuiltIn = provider == AiProvider.local; + + final url = switch (provider) { + AiProvider.local => AiConstants.chatApiUrl, + AiProvider.ollama || AiProvider.lmstudio => + '${settings.baseUrlFor(provider)}/v1/chat/completions', + AiProvider.openai => 'https://api.openai.com/v1/chat/completions', + AiProvider.gemini => + 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions', + AiProvider.anthropic => throw StateError('handled separately'), + }; + + final apiKey = settings.apiKeyFor(provider); + if (provider.needsApiKey && apiKey == null) { + throw Exception('No API key configured for ${provider.label}.'); + } + + final response = await _dio.post( + url, + cancelToken: cancelToken, + options: Options( + responseType: ResponseType.stream, + headers: apiKey == null ? null : {'Authorization': 'Bearer $apiKey'}, + ), + data: { + if (!isBuiltIn) 'model': settings.modelFor(provider), + 'messages': messages, + if (isBuiltIn) 'temperature': AiConstants.chatTemperature, + 'max_tokens': AiConstants.chatMaxTokens, + // llama.cpp-specific: reuses the KV-cache of the shared prefix + // between turns — makes multi-turn conversations respond much faster. + if (isBuiltIn) 'cache_prompt': true, + 'stream': true, + }, + ); + + await for (final line in _sseLines(response)) { + if (!line.startsWith('data: ')) continue; + final dataStr = line.substring(6).trim(); + if (dataStr == '[DONE]') return; + if (dataStr.isEmpty) continue; + try { + final data = jsonDecode(dataStr); + final delta = + (data['choices']?[0]?['delta']?['content'] ?? '') as String; + if (delta.isNotEmpty) yield delta; + } catch (_) { + // Malformed line — dropped. Thanks to the line buffering this only + // happens on genuinely corrupt data, not on chunk boundaries. + } + } + } + + // --------------------------------------------------------------------------- + // Anthropic native Messages API + // --------------------------------------------------------------------------- + + Stream _streamAnthropic( + List> messages, + AiSettings settings, { + CancelToken? cancelToken, + }) async* { + final apiKey = settings.apiKeyFor(AiProvider.anthropic); + if (apiKey == null) { + throw Exception('No API key configured for Anthropic.'); + } + + // Anthropic takes the system prompt as a top-level field, not a message. + final system = messages + .where((m) => m['role'] == 'system') + .map((m) => m['content']) + .join('\n\n'); + final chat = messages.where((m) => m['role'] != 'system').toList(); + + final response = await _dio.post( + 'https://api.anthropic.com/v1/messages', + cancelToken: cancelToken, + options: Options( + responseType: ResponseType.stream, + headers: {'x-api-key': apiKey, 'anthropic-version': '2023-06-01'}, + ), + data: { + 'model': settings.modelFor(AiProvider.anthropic), + 'max_tokens': AiConstants.chatMaxTokens, + if (system.isNotEmpty) 'system': system, + 'messages': chat, + 'stream': true, + }, + ); + + await for (final line in _sseLines(response)) { + if (!line.startsWith('data: ')) continue; + final dataStr = line.substring(6).trim(); + if (dataStr.isEmpty) continue; + try { + final data = jsonDecode(dataStr); + final type = data['type'] as String?; + if (type == 'content_block_delta') { + final delta = data['delta']; + if (delta?['type'] == 'text_delta') { + final text = (delta['text'] ?? '') as String; + if (text.isNotEmpty) yield text; + } + } else if (type == 'message_stop') { + return; + } else if (type == 'error') { + throw Exception( + 'Anthropic API error: ${data['error']?['message'] ?? 'unknown'}', + ); + } + } on FormatException { + // Malformed line — dropped. + } + } + } + + // --------------------------------------------------------------------------- + // SSE line splitting with chunk-boundary buffering + // --------------------------------------------------------------------------- + + /// SSE lines can be split across TCP chunks, so carry the unfinished tail + /// of each chunk over to the next one instead of dropping it. + Stream _sseLines(Response response) async* { + var pending = ''; + await for (final chunk in response.data!.stream) { + pending += utf8.decode(chunk, allowMalformed: true); + final lines = pending.split('\n'); + pending = lines.removeLast(); + for (final line in lines) { + yield line; + } + } + if (pending.isNotEmpty) yield pending; + } +} diff --git a/lib/injection.dart b/lib/injection.dart index d487243..9f6aa2b 100644 --- a/lib/injection.dart +++ b/lib/injection.dart @@ -18,7 +18,10 @@ import 'package:trainhub_flutter/data/repositories/chat_repository_impl.dart'; import 'package:trainhub_flutter/data/repositories/note_repository_impl.dart'; 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/embedding_service.dart'; +import 'package:trainhub_flutter/data/services/llm_client.dart'; import 'package:trainhub_flutter/data/database/daos/knowledge_chunk_dao.dart'; final GetIt getIt = GetIt.instance; @@ -45,6 +48,9 @@ void init() { // Services getIt.registerSingleton(EmbeddingService()); + getIt.registerSingleton(AiSettingsService()); + getIt.registerSingleton(ComboService()); + getIt.registerSingleton(LlmClient(getIt())); // Repositories getIt.registerLazySingleton( diff --git a/lib/main.dart b/lib/main.dart index ee9ff16..3caddd7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,6 +5,7 @@ import 'package:window_manager/window_manager.dart'; import 'package:trainhub_flutter/core/router/app_router.dart'; import 'package:trainhub_flutter/core/theme/app_theme.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/injection.dart' as di; void main() async { @@ -13,6 +14,7 @@ void main() async { await windowManager.ensureInitialized(); di.init(); + await di.getIt().load(); const windowOptions = WindowOptions( size: Size(1280, 800), diff --git a/lib/presentation/chat/chat_controller.dart b/lib/presentation/chat/chat_controller.dart index f3f85f8..045dff9 100644 --- a/lib/presentation/chat/chat_controller.dart +++ b/lib/presentation/chat/chat_controller.dart @@ -1,10 +1,13 @@ -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/repositories/chat_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/training_plan_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/llm_client.dart'; import 'package:trainhub_flutter/injection.dart'; import 'package:trainhub_flutter/presentation/chat/chat_state.dart'; import 'package:uuid/uuid.dart'; @@ -18,22 +21,27 @@ AiProcessManager aiProcessManager(AiProcessManagerRef ref) { return manager; } +@riverpod +AiSettingsService aiSettingsService(AiSettingsServiceRef ref) { + final service = getIt(); + service.addListener(() => ref.notifyListeners()); + return service; +} + @riverpod class ChatController extends _$ChatController { late ChatRepository _repo; late NoteRepository _noteRepo; - - final _dio = Dio( - BaseOptions( - connectTimeout: AiConstants.serverConnectTimeout, - receiveTimeout: AiConstants.serverReceiveTimeout, - ), - ); + late LlmClient _llm; + CancelToken? _cancelToken; @override Future build() async { _repo = getIt(); _noteRepo = getIt(); + _llm = getIt(); + // Abort any in-flight generation when the user leaves the chat page. + ref.onDispose(() => _cancelToken?.cancel()); final aiManager = ref.read(aiProcessManagerProvider); if (aiManager.status == AiServerStatus.offline) { aiManager.startServers(); @@ -67,8 +75,9 @@ class ChatController extends _$ChatController { state = AsyncValue.data( current.copyWith( sessions: sessions, - activeSession: - current.activeSession?.id == id ? null : current.activeSession, + activeSession: current.activeSession?.id == id + ? null + : current.activeSession, messages: current.activeSession?.id == id ? [] : current.messages, ), ); @@ -80,12 +89,44 @@ class ChatController extends _$ChatController { final sessionId = await _resolveSession(current, content); await _persistUserMessage(sessionId, content); final contextChunks = await _searchKnowledgeBase(content); - final systemPrompt = _buildSystemPrompt(contextChunks); + final trainingContext = await _buildTrainingContext(); + final systemPrompt = _buildSystemPrompt(contextChunks, trainingContext); final history = _buildHistory(); final fullAiResponse = await _streamResponse(systemPrompt, history); await _persistAssistantResponse(sessionId, content, fullAiResponse); } + /// Summarizes the user's exercise library and training plans so the model + /// can reference and plan around real data. Only attached when a cloud + /// provider is active — the local 4B model's context is too small for it. + Future _buildTrainingContext() async { + if (getIt().activeProvider == AiProvider.local) return ''; + try { + final exercises = await getIt().getAll(); + final plans = await getIt().getAll(); + + final buffer = StringBuffer(); + if (exercises.isNotEmpty) { + buffer.writeln("### The trainer's exercise library:"); + for (final e in exercises.take(150)) { + buffer.write('- ${e.name}'); + final tags = e.tags; + if (tags != null && tags.isNotEmpty) buffer.write(' [$tags]'); + buffer.writeln(); + } + } + if (plans.isNotEmpty) { + buffer.writeln("\n### The trainer's training plans:"); + for (final plan in plans.take(30)) { + buffer.writeln('- ${plan.name}'); + } + } + return buffer.toString(); + } catch (_) { + return ''; + } + } + Future _resolveSession(ChatState current, String content) async { if (current.activeSession != null) return current.activeSession!.id; final session = await _repo.createSession(); @@ -144,61 +185,51 @@ class ChatController extends _$ChatController { return contextChunks; } + /// Most recent messages only — an unbounded history would eventually + /// overflow the model context and slow every request down. List> _buildHistory() { final messages = state.valueOrNull?.messages ?? []; - return messages - .map((m) => { - 'role': m.isUser ? 'user' : 'assistant', - 'content': m.content, - }) + final recent = messages.length > AiConstants.chatHistoryLimit + ? messages.sublist(messages.length - AiConstants.chatHistoryLimit) + : messages; + return recent + .map( + (m) => { + 'role': m.isUser ? 'user' : 'assistant', + 'content': m.content, + }, + ) .toList(); } + /// Stops an in-flight generation. The partial response streamed so far is + /// kept and persisted like a normal reply. + void stopGeneration() => _cancelToken?.cancel(); + Future _streamResponse( String systemPrompt, List> history, ) async { final generateStep = _createStep('Generating response...'); String fullAiResponse = ''; + _cancelToken = CancelToken(); try { - final response = await _dio.post( - AiConstants.chatApiUrl, - options: Options(responseType: ResponseType.stream), - data: { - 'messages': [ - {'role': 'system', 'content': systemPrompt}, - ...history, - ], - 'temperature': AiConstants.chatTemperature, - 'stream': true, - }, - ); + final stream = _llm.streamChat([ + {'role': 'system', 'content': systemPrompt}, + ...history, + ], cancelToken: _cancelToken); _updateStep( generateStep.id, status: ThinkingStepStatus.running, title: 'Writing...', ); - final stream = response.data!.stream; - await for (final chunk in stream) { - final textChunk = utf8.decode(chunk); - for (final line in textChunk.split('\n')) { - if (!line.startsWith('data: ')) continue; - final dataStr = line.substring(6).trim(); - if (dataStr == '[DONE]') break; - if (dataStr.isEmpty) continue; - try { - final data = jsonDecode(dataStr); - final delta = data['choices']?[0]?['delta']?['content'] ?? ''; - if (delta.isNotEmpty) { - fullAiResponse += delta; - final updatedState = state.valueOrNull; - if (updatedState != null) { - state = AsyncValue.data( - updatedState.copyWith(streamingContent: fullAiResponse), - ); - } - } - } catch (_) {} + await for (final delta in stream) { + fullAiResponse += delta; + final updatedState = state.valueOrNull; + if (updatedState != null) { + state = AsyncValue.data( + updatedState.copyWith(streamingContent: fullAiResponse), + ); } } _updateStep( @@ -207,21 +238,29 @@ class ChatController extends _$ChatController { title: 'Response generated', ); } on DioException catch (e) { - fullAiResponse += '\n\n[AI model communication error]'; - _updateStep( - generateStep.id, - status: ThinkingStepStatus.error, - title: 'Generation failed', - details: '${e.message}', - ); + if (CancelToken.isCancel(e)) { + _updateStep( + generateStep.id, + status: ThinkingStepStatus.completed, + title: 'Stopped by user', + ); + } else { + _updateStep( + generateStep.id, + status: ThinkingStepStatus.error, + title: 'Generation failed', + details: e.message ?? e.toString(), + ); + } } catch (e) { - fullAiResponse += '\n\n[Unexpected error]'; _updateStep( generateStep.id, status: ThinkingStepStatus.error, title: 'Generation failed', details: e.toString(), ); + } finally { + _cancelToken = null; } return fullAiResponse; } @@ -231,6 +270,17 @@ class ChatController extends _$ChatController { String userContent, String aiResponse, ) async { + // A failed generation yields an empty response — leave the error visible + // in the thinking steps instead of saving an empty assistant message. + if (aiResponse.trim().isEmpty) { + final current = state.valueOrNull; + if (current != null) { + state = AsyncValue.data( + current.copyWith(isTyping: false, streamingContent: null), + ); + } + return; + } await _repo.addMessage( sessionId: sessionId, role: 'assistant', @@ -289,18 +339,32 @@ class ChatController extends _$ChatController { state = AsyncValue.data(current.copyWith(thinkingSteps: updatedSteps)); } - static String _buildSystemPrompt(List chunks) { - if (chunks.isEmpty) return AiConstants.baseSystemPrompt; - final contextBlock = chunks - .asMap() - .entries - .map((e) => '[${e.key + 1}] ${e.value}') - .join('\n\n'); - return '${AiConstants.baseSystemPrompt}\n\n' - '### Relevant notes from the trainer\'s knowledge base:\n' + static String _buildSystemPrompt( + List chunks, + String trainingContext, + ) { + final buffer = StringBuffer(AiConstants.baseSystemPrompt); + if (trainingContext.isNotEmpty) { + buffer.write('\n\n$trainingContext'); + buffer.write( + '\nWhen designing or discussing training plans, prefer exercises ' + 'from the library above and reference existing plans by name.', + ); + } + if (chunks.isNotEmpty) { + final contextBlock = chunks + .asMap() + .entries + .map((e) => '[${e.key + 1}] ${e.value}') + .join('\n\n'); + buffer.write( + '\n\n### Relevant notes from the trainer\'s knowledge base:\n' '$contextBlock\n\n' 'Use the above context to inform your response when relevant. ' 'If the context is not directly applicable, rely on your general ' - 'fitness knowledge.'; + 'fitness knowledge.', + ); + } + return buffer.toString(); } } diff --git a/lib/presentation/chat/chat_controller.g.dart b/lib/presentation/chat/chat_controller.g.dart index cdcbba7..725dad5 100644 --- a/lib/presentation/chat/chat_controller.g.dart +++ b/lib/presentation/chat/chat_controller.g.dart @@ -23,7 +23,25 @@ final aiProcessManagerProvider = AutoDisposeProvider.internal( @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef AiProcessManagerRef = AutoDisposeProviderRef; -String _$chatControllerHash() => r'266d8a5ac91cbe6c112f85f15adf5a8046e85682'; +String _$aiSettingsServiceHash() => r'3d16401bd92a2502e16cc9bc55558e0813682f4b'; + +/// See also [aiSettingsService]. +@ProviderFor(aiSettingsService) +final aiSettingsServiceProvider = + AutoDisposeProvider.internal( + aiSettingsService, + name: r'aiSettingsServiceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$aiSettingsServiceHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef AiSettingsServiceRef = AutoDisposeProviderRef; +String _$chatControllerHash() => r'227ef80f7bcc8787d85a726f151d878a3ba954d6'; /// See also [ChatController]. @ProviderFor(ChatController) diff --git a/lib/presentation/chat/chat_page.dart b/lib/presentation/chat/chat_page.dart index 5751bc9..d04ea4b 100644 --- a/lib/presentation/chat/chat_page.dart +++ b/lib/presentation/chat/chat_page.dart @@ -5,6 +5,7 @@ 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/ai_process_manager.dart'; +import 'package:trainhub_flutter/data/services/ai_settings_service.dart'; import 'package:trainhub_flutter/domain/entities/chat_session.dart'; import 'package:trainhub_flutter/presentation/chat/chat_controller.dart'; import 'package:trainhub_flutter/presentation/chat/chat_state.dart'; @@ -63,9 +64,7 @@ class _ChatPageState extends ConsumerState { final now = DateTime.now(); final hour = dt.hour.toString().padLeft(2, '0'); final minute = dt.minute.toString().padLeft(2, '0'); - if (dt.year == now.year && - dt.month == now.month && - dt.day == now.day) { + if (dt.year == now.year && dt.month == now.month && dt.day == now.day) { return '$hour:$minute'; } return '${dt.day}/${dt.month} $hour:$minute'; @@ -76,14 +75,19 @@ class _ChatPageState extends ConsumerState { @override Widget build(BuildContext context) { - final modelsValidated = - ref.watch(aiModelSettingsControllerProvider).areModelsValidated; + final modelsValidated = ref + .watch(aiModelSettingsControllerProvider) + .areModelsValidated; + // A configured external provider (cloud, Ollama, LM Studio) makes chat + // usable without the built-in local models. + final settings = ref.watch(aiSettingsServiceProvider).settings; + final cloudConfigured = + settings.provider != AiProvider.local && settings.isProviderConfigured; final state = ref.watch(chatControllerProvider); final controller = ref.read(chatControllerProvider.notifier); ref.listen(chatControllerProvider, (prev, next) { if (next.hasValue && - (prev?.value?.messages.length ?? 0) < - next.value!.messages.length) { + (prev?.value?.messages.length ?? 0) < next.value!.messages.length) { _scrollToBottom(); } if (next.hasValue && @@ -92,7 +96,7 @@ class _ChatPageState extends ConsumerState { _scrollToBottom(); } }); - if (!modelsValidated) { + if (!modelsValidated && !cloudConfigured) { return const Scaffold( backgroundColor: AppColors.surface, body: MissingModelsState(), @@ -211,14 +215,11 @@ class _ChatPageState extends ConsumerState { color: isActive ? AppColors.zinc700.withValues(alpha: 0.7) : isHovered - ? AppColors.zinc800.withValues(alpha: 0.6) - : Colors.transparent, - borderRadius: - BorderRadius.circular(UIConstants.smallBorderRadius), + ? AppColors.zinc800.withValues(alpha: 0.6) + : Colors.transparent, + borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), border: isActive - ? Border.all( - color: AppColors.accent.withValues(alpha: 0.3), - ) + ? Border.all(color: AppColors.accent.withValues(alpha: 0.3)) : null, ), child: Row( @@ -239,8 +240,7 @@ class _ChatPageState extends ConsumerState { ? AppColors.textPrimary : AppColors.textSecondary, fontSize: 13, - fontWeight: - isActive ? FontWeight.w500 : FontWeight.normal, + fontWeight: isActive ? FontWeight.w500 : FontWeight.normal, ), ), ), @@ -260,8 +260,7 @@ class _ChatPageState extends ConsumerState { Icons.delete_outline_rounded, color: AppColors.textMuted, ), - onPressed: () => - controller.deleteSession(session.id), + onPressed: () => controller.deleteSession(session.id), tooltip: 'Delete', ), ), @@ -290,8 +289,7 @@ class _ChatPageState extends ConsumerState { horizontal: UIConstants.spacing24, vertical: UIConstants.spacing16, ), - itemCount: - data.messages.length + (data.isTyping ? 1 : 0), + itemCount: data.messages.length + (data.isTyping ? 1 : 0), itemBuilder: (context, index) { if (index == data.messages.length) { if (data.thinkingSteps.isNotEmpty || @@ -391,21 +389,46 @@ class _ChatPageState extends ConsumerState { ChatController controller, ) { final aiManager = ref.watch(aiProcessManagerProvider); + final aiSettings = ref.watch(aiSettingsServiceProvider).settings; final isTyping = asyncState.valueOrNull?.isTyping ?? false; final isStarting = aiManager.status == AiServerStatus.starting; final isError = aiManager.status == AiServerStatus.error; - final isReady = aiManager.status == AiServerStatus.ready; + + // External providers (cloud, Ollama, LM Studio) don't need the built-in + // llama server for chat. The local embedding server is still used for + // the knowledge base when available, but its absence shouldn't block. + final isCloud = aiSettings.provider != AiProvider.local; + final cloudReady = isCloud && aiSettings.isProviderConfigured; + final isReady = cloudReady || aiManager.status == AiServerStatus.ready; + String? statusMessage; Color statusColor = AppColors.textMuted; - if (isStarting) { + if (isCloud) { + if (!cloudReady) { + statusMessage = + 'No API key for ${aiSettings.provider.label} — add one in ' + 'Settings, or switch back to the local model.'; + statusColor = AppColors.warning; + } else { + statusMessage = + '${aiSettings.provider.label} · ' + '${aiSettings.modelFor(aiSettings.provider)}'; + } + } else if (isStarting) { statusMessage = + aiManager.statusDetail ?? 'Starting AI inference server (this may take a moment)...'; statusColor = AppColors.info; } else if (isError) { - statusMessage = - 'AI Server Error: ${aiManager.errorMessage ?? "Unknown error"}'; + // The full error contains multi-line server logs — show only the + // first line here; details land in the debug console. + final firstLine = (aiManager.errorMessage ?? 'Unknown error') + .split('\n') + .first + .trim(); + statusMessage = 'AI Server Error: $firstLine'; statusColor = AppColors.destructive; - } else if (!isReady) { + } else if (aiManager.status != AiServerStatus.ready) { statusMessage = 'AI Server offline. Reconnecting...'; } return Container( @@ -424,7 +447,7 @@ class _ChatPageState extends ConsumerState { padding: const EdgeInsets.only(bottom: UIConstants.spacing8), child: Row( children: [ - if (isStarting) + if (!isCloud && isStarting) Container( margin: const EdgeInsets.only(right: 8), width: 12, @@ -434,7 +457,7 @@ class _ChatPageState extends ConsumerState { color: AppColors.info, ), ) - else if (isError) + else if (!isCloud && isError) const Padding( padding: EdgeInsets.only(right: 8), child: Icon( @@ -453,7 +476,7 @@ class _ChatPageState extends ConsumerState { ), ), ), - if (isError) + if (!isCloud && isError) TextButton( style: TextButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 8), @@ -479,10 +502,10 @@ class _ChatPageState extends ConsumerState { child: Container( decoration: BoxDecoration( color: AppColors.surfaceContainer, - borderRadius: - BorderRadius.circular(UIConstants.borderRadius), - border: - Border.all(color: AppColors.border, width: 1), + borderRadius: BorderRadius.circular( + UIConstants.borderRadius, + ), + border: Border.all(color: AppColors.border, width: 1), ), child: TextField( controller: _inputController, @@ -525,22 +548,23 @@ class _ChatPageState extends ConsumerState { width: 40, height: 40, child: Material( - color: (isTyping || !isReady) - ? AppColors.zinc700 - : AppColors.accent, - borderRadius: - BorderRadius.circular(UIConstants.borderRadius), + // While generating, the button becomes a stop control. + color: !isReady ? AppColors.zinc700 : AppColors.accent, + borderRadius: BorderRadius.circular(UIConstants.borderRadius), child: InkWell( - borderRadius: - BorderRadius.circular(UIConstants.borderRadius), - onTap: (isTyping || !isReady) + borderRadius: BorderRadius.circular( + UIConstants.borderRadius, + ), + onTap: !isReady ? null + : isTyping + ? controller.stopGeneration : () => _sendMessage(controller), child: Icon( - Icons.arrow_upward_rounded, - color: (isTyping || !isReady) - ? AppColors.textMuted - : AppColors.zinc950, + isTyping + ? Icons.stop_rounded + : Icons.arrow_upward_rounded, + color: !isReady ? AppColors.textMuted : AppColors.zinc950, size: 20, ), ), diff --git a/lib/presentation/common/widgets/app_stat_card.dart b/lib/presentation/common/widgets/app_stat_card.dart index 511afff..6e12018 100644 --- a/lib/presentation/common/widgets/app_stat_card.dart +++ b/lib/presentation/common/widgets/app_stat_card.dart @@ -29,11 +29,7 @@ class AppStatCard extends StatelessWidget { borderRadius: UIConstants.cardBorderRadius, child: Row( children: [ - Container( - width: 4, - height: 72, - color: accentColor, - ), + Container(width: 4, height: 72, color: accentColor), Expanded( child: Padding( padding: const EdgeInsets.symmetric( @@ -48,17 +44,18 @@ class AppStatCard extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Text( - title, - style: GoogleFonts.inter( - fontSize: 12, - fontWeight: FontWeight.w500, + title.toUpperCase(), + style: GoogleFonts.jetBrainsMono( + fontSize: 10, + fontWeight: FontWeight.w600, + letterSpacing: 1.2, color: AppColors.textMuted, ), ), const SizedBox(height: UIConstants.spacing4), Text( value, - style: GoogleFonts.inter( + style: GoogleFonts.jetBrainsMono( fontSize: 24, fontWeight: FontWeight.w700, color: AppColors.textPrimary, @@ -67,12 +64,7 @@ class AppStatCard extends StatelessWidget { ], ), ), - if (icon != null) - Icon( - icon, - size: 20, - color: accentColor, - ), + if (icon != null) Icon(icon, size: 20, color: accentColor), ], ), ), diff --git a/lib/presentation/common/widgets/node_graph_canvas.dart b/lib/presentation/common/widgets/node_graph_canvas.dart new file mode 100644 index 0000000..1d4ee3c --- /dev/null +++ b/lib/presentation/common/widgets/node_graph_canvas.dart @@ -0,0 +1,405 @@ +import 'dart:ui' show PointMode; + +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:trainhub_flutter/core/theme/app_colors.dart'; + +/// A node placed on a [NodeGraphCanvas]. +class GraphNodeData { + const GraphNodeData({ + required this.id, + required this.title, + this.subtitle, + this.accent = AppColors.accent, + required this.position, + }); + + final String id; + final String title; + final String? subtitle; + final Color accent; + final Offset position; +} + +/// A directed edge between two nodes. +class GraphEdgeData { + const GraphEdgeData(this.fromId, this.toId); + + final String fromId; + final String toId; +} + +/// A pannable / zoomable node-graph editor in the dojang style. +/// +/// Interactions: +/// - drag empty space to pan, pinch / scroll to zoom +/// - drag a node to move it ([onNodeMoved] per frame, [onNodeDragEnd] once) +/// - 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 +/// - long-press a node for the caller's context action ([onNodeLongPress]) +class NodeGraphCanvas extends StatefulWidget { + const NodeGraphCanvas({ + super.key, + required this.nodes, + required this.edges, + this.onNodeMoved, + this.onNodeDragEnd, + this.onConnect, + this.onNodeTap, + this.onNodeLongPress, + this.canvasSize = const Size(3000, 2000), + }); + + final List nodes; + final List edges; + 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; + final Size canvasSize; + + static const double nodeWidth = 172; + static const double nodeHeight = 56; + + @override + State createState() => _NodeGraphCanvasState(); +} + +class _NodeGraphCanvasState extends State { + final _transform = TransformationController(); + String? _connectingFrom; + + /// Node currently being dragged. While set, the InteractiveViewer's own + /// pan/zoom is disabled so the two gestures never fight for the pointer — + /// this is what makes node dragging feel 1:1 with the mouse. + String? _draggingId; + + @override + void dispose() { + _transform.dispose(); + super.dispose(); + } + + double get _scale => _transform.value.getMaxScaleOnAxis(); + + void _handleNodeBodyTap(String id) { + final from = _connectingFrom; + if (from != null) { + setState(() => _connectingFrom = null); + if (from != id) widget.onConnect?.call(from, id); + return; + } + widget.onNodeTap?.call(id); + } + + @override + Widget build(BuildContext context) { + final nodesById = {for (final n in widget.nodes) n.id: n}; + + return Stack( + children: [ + InteractiveViewer( + transformationController: _transform, + constrained: false, + minScale: 0.35, + maxScale: 2.0, + panEnabled: _draggingId == null, + scaleEnabled: _draggingId == null, + boundaryMargin: const EdgeInsets.all(600), + child: GestureDetector( + // Tap on empty canvas cancels a pending connection. + onTap: () => setState(() => _connectingFrom = null), + child: SizedBox( + width: widget.canvasSize.width, + height: widget.canvasSize.height, + child: Stack( + children: [ + Positioned.fill( + child: CustomPaint( + painter: _GraphPainter( + nodesById: nodesById, + edges: widget.edges, + ), + ), + ), + for (final node in widget.nodes) + Positioned( + left: node.position.dx, + top: 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. + child: Listener( + onPointerDown: widget.onNodeMoved == null + ? null + : (_) => setState(() => _draggingId = node.id), + onPointerMove: widget.onNodeMoved == null + ? null + : (event) { + if (_draggingId != node.id) return; + widget.onNodeMoved!( + node.id, + node.position + event.delta / _scale, + ); + }, + onPointerUp: (_) { + if (_draggingId == node.id) { + setState(() => _draggingId = null); + widget.onNodeDragEnd?.call(node.id); + } + }, + onPointerCancel: (_) { + if (_draggingId == node.id) { + 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 + ? null + : node.id; + }), + ), + ), + ), + ], + ), + ), + ), + ), + if (_connectingFrom != null) + Positioned( + top: 12, + left: 0, + right: 0, + child: Center( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 7, + ), + decoration: BoxDecoration( + color: AppColors.accentMuted, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: AppColors.accentBorder), + ), + child: Text( + 'TAP A NODE TO CONNECT · TAP CANVAS TO CANCEL', + style: GoogleFonts.jetBrainsMono( + fontSize: 10, + fontWeight: FontWeight.w600, + letterSpacing: 1, + color: AppColors.accent, + ), + ), + ), + ), + ), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// Node card +// --------------------------------------------------------------------------- + +class _NodeCard extends StatelessWidget { + const _NodeCard({ + required this.node, + required this.isConnectSource, + required this.connectMode, + required this.showLinkButton, + required this.onTap, + this.onLongPress, + required this.onStartConnect, + }); + + final GraphNodeData node; + final bool isConnectSource; + final bool connectMode; + final bool showLinkButton; + final VoidCallback onTap; + final VoidCallback? onLongPress; + final VoidCallback onStartConnect; + + @override + Widget build(BuildContext context) { + final borderColor = isConnectSource + ? node.accent + : connectMode + ? node.accent.withValues(alpha: 0.4) + : AppColors.border; + + return GestureDetector( + onTap: onTap, + onLongPress: onLongPress, + child: Container( + width: NodeGraphCanvas.nodeWidth, + height: NodeGraphCanvas.nodeHeight, + decoration: BoxDecoration( + color: AppColors.surfaceContainer, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: borderColor, + width: isConnectSource ? 2 : 1, + ), + boxShadow: isConnectSource + ? [ + BoxShadow( + color: node.accent.withValues(alpha: 0.35), + blurRadius: 16, + ), + ] + : null, + ), + child: Row( + children: [ + Container( + width: 4, + margin: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: node.accent, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + node.title.toUpperCase(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.chakraPetch( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + color: AppColors.textPrimary, + ), + ), + if (node.subtitle != null && node.subtitle!.isNotEmpty) + Text( + node.subtitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.jetBrainsMono( + fontSize: 9, + color: AppColors.textMuted, + ), + ), + ], + ), + ), + if (showLinkButton) + GestureDetector( + onTap: onStartConnect, + child: Container( + width: 26, + height: double.infinity, + color: Colors.transparent, + child: Icon( + Icons.trending_flat_rounded, + size: 16, + color: isConnectSource ? node.accent : AppColors.textMuted, + ), + ), + ), + const SizedBox(width: 2), + ], + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Edges + dot grid painter +// --------------------------------------------------------------------------- + +class _GraphPainter extends CustomPainter { + _GraphPainter({required this.nodesById, required this.edges}); + + final Map nodesById; + final List edges; + + @override + void paint(Canvas canvas, Size size) { + _paintDotGrid(canvas, size); + + final edgePaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + + for (final edge in edges) { + final from = nodesById[edge.fromId]; + final to = nodesById[edge.toId]; + if (from == null || to == null) continue; + + final start = + from.position + + const Offset( + NodeGraphCanvas.nodeWidth, + NodeGraphCanvas.nodeHeight / 2, + ); + final end = to.position + const Offset(0, NodeGraphCanvas.nodeHeight / 2); + + edgePaint.color = from.accent.withValues(alpha: 0.75); + + final dx = ((end.dx - start.dx).abs() * 0.5).clamp(40.0, 160.0); + final path = Path() + ..moveTo(start.dx, start.dy) + ..cubicTo( + start.dx + dx, + start.dy, + end.dx - dx, + end.dy, + end.dx - 7, + end.dy, + ); + canvas.drawPath(path, edgePaint); + + // Arrowhead + final arrow = Path() + ..moveTo(end.dx, end.dy) + ..lineTo(end.dx - 9, end.dy - 5) + ..lineTo(end.dx - 9, end.dy + 5) + ..close(); + canvas.drawPath( + arrow, + Paint()..color = from.accent.withValues(alpha: 0.9), + ); + } + } + + 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; +} diff --git a/lib/presentation/home/widgets/next_workout_banner.dart b/lib/presentation/home/widgets/next_workout_banner.dart index f669e40..78e6b35 100644 --- a/lib/presentation/home/widgets/next_workout_banner.dart +++ b/lib/presentation/home/widgets/next_workout_banner.dart @@ -16,7 +16,7 @@ class NextWorkoutBanner extends StatelessWidget { decoration: BoxDecoration( color: AppColors.surfaceContainer, borderRadius: UIConstants.cardBorderRadius, - border: Border.all(color: AppColors.border), + border: Border.all(color: AppColors.accentBorder), ), child: Row( children: [ @@ -24,12 +24,13 @@ class NextWorkoutBanner extends StatelessWidget { width: 40, height: 40, decoration: BoxDecoration( - color: AppColors.accentMuted, + color: AppColors.accent, borderRadius: UIConstants.smallCardBorderRadius, + boxShadow: UIConstants.accentGlow, ), child: const Icon( Icons.play_arrow_rounded, - color: AppColors.accent, + color: AppColors.zinc950, size: 22, ), ), @@ -39,30 +40,28 @@ class NextWorkoutBanner extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Up Next', - style: GoogleFonts.inter( - fontSize: 12, - fontWeight: FontWeight.w500, - color: AppColors.textMuted, + 'UP NEXT', + style: GoogleFonts.jetBrainsMono( + fontSize: 10, + fontWeight: FontWeight.w600, + letterSpacing: 1.5, + color: AppColors.accent, ), ), const SizedBox(height: 2), Text( - workoutName, - style: GoogleFonts.inter( + workoutName.toUpperCase(), + style: GoogleFonts.chakraPetch( fontSize: 15, fontWeight: FontWeight.w600, + letterSpacing: 0.5, color: AppColors.textPrimary, ), ), ], ), ), - const Icon( - Icons.chevron_right, - color: AppColors.textMuted, - size: 20, - ), + const Icon(Icons.chevron_right, color: AppColors.textMuted, size: 20), ], ), ); diff --git a/lib/presentation/home/widgets/welcome_header.dart b/lib/presentation/home/widgets/welcome_header.dart index 1eec441..f29a4cd 100644 --- a/lib/presentation/home/widgets/welcome_header.dart +++ b/lib/presentation/home/widgets/welcome_header.dart @@ -14,11 +14,12 @@ class WelcomeHeader extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Welcome back', - style: GoogleFonts.inter( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppColors.textMuted, + 'WELCOME BACK', + style: GoogleFonts.jetBrainsMono( + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 2, + color: AppColors.accent, ), ), const SizedBox(height: UIConstants.spacing4), @@ -26,10 +27,11 @@ class WelcomeHeader extends StatelessWidget { children: [ Expanded( child: Text( - programName, - style: GoogleFonts.inter( + programName.toUpperCase(), + style: GoogleFonts.chakraPetch( fontSize: 28, fontWeight: FontWeight.w700, + letterSpacing: 0.5, color: AppColors.textPrimary, ), ), @@ -42,21 +44,23 @@ class WelcomeHeader extends StatelessWidget { decoration: BoxDecoration( color: AppColors.accentMuted, borderRadius: UIConstants.smallCardBorderRadius, + border: Border.all(color: AppColors.accentBorder), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon( - Icons.fitness_center, + Icons.sports_martial_arts, size: 14, color: AppColors.accent, ), const SizedBox(width: 6), Text( - 'Active Program', - style: GoogleFonts.inter( - fontSize: 12, + 'ACTIVE PROGRAM', + style: GoogleFonts.jetBrainsMono( + fontSize: 10, fontWeight: FontWeight.w600, + letterSpacing: 1.5, color: AppColors.accent, ), ), diff --git a/lib/presentation/plan_editor/plan_editor_page.dart b/lib/presentation/plan_editor/plan_editor_page.dart index 7a6ad2c..33a0de6 100644 --- a/lib/presentation/plan_editor/plan_editor_page.dart +++ b/lib/presentation/plan_editor/plan_editor_page.dart @@ -5,18 +5,28 @@ 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/presentation/plan_editor/plan_editor_controller.dart'; +import 'package:trainhub_flutter/presentation/plan_editor/widgets/plan_flow_graph.dart'; import 'package:trainhub_flutter/presentation/plan_editor/widgets/plan_section_card.dart'; @RoutePage() -class PlanEditorPage extends ConsumerWidget { +class PlanEditorPage extends ConsumerStatefulWidget { final String planId; const PlanEditorPage({super.key, @PathParam('planId') required this.planId}); @override - Widget build(BuildContext context, WidgetRef ref) { - final state = ref.watch(planEditorControllerProvider(planId)); - final controller = ref.read(planEditorControllerProvider(planId).notifier); + ConsumerState createState() => _PlanEditorPageState(); +} + +class _PlanEditorPageState extends ConsumerState { + bool _graphView = false; + + @override + Widget build(BuildContext context) { + final state = ref.watch(planEditorControllerProvider(widget.planId)); + final controller = ref.read( + planEditorControllerProvider(widget.planId).notifier, + ); return Scaffold( backgroundColor: AppColors.surface, @@ -104,6 +114,13 @@ class PlanEditorPage extends ConsumerWidget { ), ), + // List / graph view toggle + _ViewToggle( + graphView: _graphView, + onChanged: (v) => setState(() => _graphView = v), + ), + const SizedBox(width: UIConstants.spacing16), + // Unsaved changes badge + save button state.maybeWhen( data: (data) => data.isDirty @@ -115,7 +132,9 @@ class PlanEditorPage extends ConsumerWidget { vertical: 4, ), decoration: BoxDecoration( - color: AppColors.warning.withValues(alpha: 0.12), + color: AppColors.warning.withValues( + alpha: 0.12, + ), borderRadius: BorderRadius.circular(20), border: Border.all( color: AppColors.warning.withValues( @@ -150,41 +169,50 @@ class PlanEditorPage extends ConsumerWidget { // --- Body --- Expanded( child: state.when( - data: (data) => ReorderableListView.builder( - padding: const EdgeInsets.all(UIConstants.spacing24), - onReorder: controller.reorderSection, - footer: Center( - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: UIConstants.spacing16, - ), - child: OutlinedButton.icon( - onPressed: controller.addSection, - icon: const Icon(Icons.add, size: 16), - label: const Text('Add Section'), - style: OutlinedButton.styleFrom( - foregroundColor: AppColors.textSecondary, - side: const BorderSide(color: AppColors.border), - padding: const EdgeInsets.symmetric( - horizontal: UIConstants.spacing24, - vertical: UIConstants.spacing12, + data: (data) => _graphView + ? PlanFlowGraph( + // Rebuild the graph when the plan structure changes. + key: ValueKey( + '${data.plan.id}-${data.plan.totalExercises}-' + '${data.plan.sections.length}', + ), + plan: data.plan, + ) + : ReorderableListView.builder( + padding: const EdgeInsets.all(UIConstants.spacing24), + onReorder: controller.reorderSection, + footer: Center( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: UIConstants.spacing16, + ), + child: OutlinedButton.icon( + onPressed: controller.addSection, + icon: const Icon(Icons.add, size: 16), + label: const Text('Add Section'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.textSecondary, + side: const BorderSide(color: AppColors.border), + padding: const EdgeInsets.symmetric( + horizontal: UIConstants.spacing24, + vertical: UIConstants.spacing12, + ), + ), + ), ), ), + itemCount: data.plan.sections.length, + itemBuilder: (context, index) { + final section = data.plan.sections[index]; + return PlanSectionCard( + key: ValueKey(section.id), + section: section, + sectionIndex: index, + plan: data.plan, + availableExercises: data.availableExercises, + ); + }, ), - ), - ), - itemCount: data.plan.sections.length, - itemBuilder: (context, index) { - final section = data.plan.sections[index]; - return PlanSectionCard( - key: ValueKey(section.id), - section: section, - sectionIndex: index, - plan: data.plan, - availableExercises: data.availableExercises, - ); - }, - ), error: (e, s) => Center( child: Text( 'Error: $e', @@ -199,3 +227,77 @@ class PlanEditorPage extends ConsumerWidget { ); } } + +// --------------------------------------------------------------------------- +// List / graph segmented toggle +// --------------------------------------------------------------------------- +class _ViewToggle extends StatelessWidget { + const _ViewToggle({required this.graphView, required this.onChanged}); + + final bool graphView; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _ToggleButton( + icon: Icons.view_list_rounded, + tooltip: 'List view', + selected: !graphView, + onTap: () => onChanged(false), + ), + _ToggleButton( + icon: Icons.account_tree_outlined, + tooltip: 'Graph view', + selected: graphView, + onTap: () => onChanged(true), + ), + ], + ), + ); + } +} + +class _ToggleButton extends StatelessWidget { + const _ToggleButton({ + required this.icon, + required this.tooltip, + required this.selected, + required this.onTap, + }); + + final IconData icon; + final String tooltip; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Tooltip( + message: tooltip, + child: GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + decoration: BoxDecoration( + color: selected ? AppColors.accentMuted : Colors.transparent, + borderRadius: BorderRadius.circular(7), + ), + child: Icon( + icon, + size: 16, + color: selected ? AppColors.accent : AppColors.textMuted, + ), + ), + ), + ); + } +} diff --git a/lib/presentation/plan_editor/widgets/plan_flow_graph.dart b/lib/presentation/plan_editor/widgets/plan_flow_graph.dart new file mode 100644 index 0000000..96e0da1 --- /dev/null +++ b/lib/presentation/plan_editor/widgets/plan_flow_graph.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:trainhub_flutter/core/theme/app_colors.dart'; +import 'package:trainhub_flutter/domain/entities/training_plan.dart'; +import 'package:trainhub_flutter/presentation/common/widgets/node_graph_canvas.dart'; + +/// Graph view of a training plan: one column per section, exercises chained +/// top-to-bottom, sections chained left-to-right — the session flow at a +/// glance. Nodes can be dragged to rearrange the picture; the plan structure +/// itself is edited in the list view. +class PlanFlowGraph extends StatefulWidget { + const PlanFlowGraph({super.key, required this.plan}); + + final TrainingPlanEntity plan; + + @override + State createState() => _PlanFlowGraphState(); +} + +class _PlanFlowGraphState extends State { + /// User-dragged overrides on top of the auto layout, per node id. + final Map _overrides = {}; + + static const double _colWidth = 250; + static const double _rowHeight = 84; + + @override + Widget build(BuildContext context) { + final nodes = []; + final edges = []; + + Offset pos(String id, Offset fallback) => _overrides[id] ?? fallback; + + nodes.add( + GraphNodeData( + id: 'start', + title: 'Start Session', + subtitle: widget.plan.name, + accent: AppColors.success, + position: pos('start', const Offset(40, 40)), + ), + ); + + var previousTail = 'start'; + for (var s = 0; s < widget.plan.sections.length; s++) { + final section = widget.plan.sections[s]; + final x = 40.0 + (s + 1) * _colWidth; + final sectionId = 'sec-${section.id}'; + + nodes.add( + GraphNodeData( + id: sectionId, + title: section.name, + subtitle: '${section.exercises.length} drills', + accent: AppColors.accent, + position: pos(sectionId, Offset(x, 40)), + ), + ); + edges.add(GraphEdgeData(previousTail, sectionId)); + + var tail = sectionId; + for (var e = 0; e < section.exercises.length; e++) { + final exercise = section.exercises[e]; + final id = 'ex-${exercise.instanceId}'; + final detail = exercise.isTime + ? '${exercise.sets} × ${exercise.value}s · rest ${exercise.rest}s' + : '${exercise.sets} × ${exercise.value} · rest ${exercise.rest}s'; + + nodes.add( + GraphNodeData( + id: id, + title: exercise.name, + subtitle: detail, + accent: AppColors.info, + position: pos(id, Offset(x + 24, 40 + (e + 1) * _rowHeight)), + ), + ); + edges.add(GraphEdgeData(tail, id)); + tail = id; + } + previousTail = tail; + } + + return Stack( + children: [ + ClipRect( + child: NodeGraphCanvas( + nodes: nodes, + edges: edges, + canvasSize: Size( + (widget.plan.sections.length + 2) * _colWidth + 600, + 2000, + ), + onNodeMoved: (id, position) => + setState(() => _overrides[id] = position), + ), + ), + Positioned( + left: 16, + bottom: 16, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: AppColors.surfaceContainer.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Text( + 'SESSION FLOW · DRAG TO REARRANGE · EDIT IN LIST VIEW', + style: GoogleFonts.jetBrainsMono( + fontSize: 9, + letterSpacing: 0.8, + color: AppColors.textMuted, + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/presentation/settings/ai_model_settings_controller.dart b/lib/presentation/settings/ai_model_settings_controller.dart index c8058e0..09616e7 100644 --- a/lib/presentation/settings/ai_model_settings_controller.dart +++ b/lib/presentation/settings/ai_model_settings_controller.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:archive/archive_io.dart'; @@ -27,12 +28,24 @@ Future _llamaArchiveUrl() async { throw UnsupportedError('Unsupported platform: ${Platform.operatingSystem}'); } -@riverpod +// keepAlive: a download must survive navigating away from the settings / +// welcome screen — otherwise leaving the page silently drops its progress. +@Riverpod(keepAlive: true) class AiModelSettingsController extends _$AiModelSettingsController { final _dio = Dio(); @override - AiModelSettingsState build() => const AiModelSettingsState(); + AiModelSettingsState build() { + // Check installed files right away so every screen (settings, welcome, + // chat) sees the real state without a manual "Validate" click. + Future.microtask(validateModels); + return const AiModelSettingsState(); + } + + /// A file that exists but is suspiciously small is a leftover from an + /// interrupted download — treat it as missing. + static bool _isComplete(File file, int minBytes) => + file.existsSync() && file.lengthSync() >= minBytes; Future validateModels() async { state = state.copyWith( @@ -42,13 +55,19 @@ class AiModelSettingsController extends _$AiModelSettingsController { try { final dir = await getApplicationDocumentsDirectory(); final base = dir.path; - final serverBin = File(p.join(base, AiConstants.serverBinaryName)); - final nomicModel = File(p.join(base, AiConstants.nomicModelFile)); - final qwenModel = File(p.join(base, AiConstants.qwenModelFile)); final validated = - serverBin.existsSync() && - nomicModel.existsSync() && - qwenModel.existsSync(); + _isComplete( + File(p.join(base, AiConstants.serverBinaryName)), + AiConstants.serverBinaryMinBytes, + ) && + _isComplete( + File(p.join(base, AiConstants.nomicModelFile)), + AiConstants.nomicModelMinBytes, + ) && + _isComplete( + File(p.join(base, AiConstants.qwenModelFile)), + AiConstants.qwenModelMinBytes, + ); state = state.copyWith( areModelsValidated: validated, currentTask: validated ? 'All files present.' : 'Files missing.', @@ -97,14 +116,16 @@ class AiModelSettingsController extends _$AiModelSettingsController { savePath: p.join(dir.path, AiConstants.nomicModelFile), taskLabel: 'Downloading Nomic embedding model…', overallStart: 0.2, - overallEnd: 0.55, + overallEnd: 0.35, + minBytes: AiConstants.nomicModelMinBytes, ); await _downloadFile( url: AiConstants.qwenModelUrl, savePath: p.join(dir.path, AiConstants.qwenModelFile), - taskLabel: 'Downloading Qwen 2.5 7B model…', - overallStart: 0.55, + taskLabel: 'Downloading Qwen3 4B model…', + overallStart: 0.35, overallEnd: 1.0, + minBytes: AiConstants.qwenModelMinBytes, ); state = state.copyWith( isDownloading: false, @@ -112,6 +133,11 @@ class AiModelSettingsController extends _$AiModelSettingsController { currentTask: 'Download complete.', ); await validateModels(); + // Bring the AI online immediately — the user shouldn't have to visit + // the chat page or click anything for the servers to start loading. + if (state.areModelsValidated) { + unawaited(di.getIt().startServers()); + } } on DioException catch (e) { state = state.copyWith( isDownloading: false, @@ -133,11 +159,23 @@ class AiModelSettingsController extends _$AiModelSettingsController { required String taskLabel, required double overallStart, required double overallEnd, + int minBytes = 0, }) async { + // Already downloaded (e.g. a retry after a failure further down the + // list) — don't pull gigabytes again. + if (_isComplete(File(savePath), minBytes) && minBytes > 0) { + state = state.copyWith(progress: overallEnd); + return; + } + state = state.copyWith(currentTask: taskLabel, progress: overallStart); + + // Download to a temp file and rename at the end, so an interrupted + // download can never masquerade as a complete model file. + final partPath = '$savePath.part'; await _dio.download( url, - savePath, + partPath, onReceiveProgress: (received, total) { if (total <= 0) return; final fileProgress = received / total; @@ -151,6 +189,10 @@ class AiModelSettingsController extends _$AiModelSettingsController { receiveTimeout: const Duration(hours: 2), ), ); + + final target = File(savePath); + if (target.existsSync()) target.deleteSync(); + File(partPath).renameSync(savePath); } Future _extractBinary(String archivePath, String destDir) async { diff --git a/lib/presentation/settings/ai_model_settings_controller.g.dart b/lib/presentation/settings/ai_model_settings_controller.g.dart index d6c8cd5..cce0d41 100644 --- a/lib/presentation/settings/ai_model_settings_controller.g.dart +++ b/lib/presentation/settings/ai_model_settings_controller.g.dart @@ -7,15 +7,12 @@ part of 'ai_model_settings_controller.dart'; // ************************************************************************** String _$aiModelSettingsControllerHash() => - r'27a37c3fafb21b93a8b5523718f1537419bd382a'; + r'41d0842f57085bab80f0de18d8ab0eb72cdd5440'; /// See also [AiModelSettingsController]. @ProviderFor(AiModelSettingsController) final aiModelSettingsControllerProvider = - AutoDisposeNotifierProvider< - AiModelSettingsController, - AiModelSettingsState - >.internal( + NotifierProvider.internal( AiModelSettingsController.new, name: r'aiModelSettingsControllerProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') @@ -25,6 +22,6 @@ final aiModelSettingsControllerProvider = allTransitiveDependencies: null, ); -typedef _$AiModelSettingsController = AutoDisposeNotifier; +typedef _$AiModelSettingsController = Notifier; // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/presentation/settings/knowledge_base_controller.dart b/lib/presentation/settings/knowledge_base_controller.dart index 0a316cb..45127c2 100644 --- a/lib/presentation/settings/knowledge_base_controller.dart +++ b/lib/presentation/settings/knowledge_base_controller.dart @@ -53,10 +53,7 @@ class KnowledgeBaseController extends _$KnowledgeBaseController { successMessage: 'Saved! Knowledge base now has $count chunks.', ); } catch (e) { - state = state.copyWith( - isLoading: false, - errorMessage: _friendlyError(e), - ); + state = state.copyWith(isLoading: false, errorMessage: _friendlyError(e)); } } @@ -78,10 +75,7 @@ class KnowledgeBaseController extends _$KnowledgeBaseController { successMessage: 'Knowledge base cleared.', ); } catch (e) { - state = state.copyWith( - isLoading: false, - errorMessage: _friendlyError(e), - ); + state = state.copyWith(isLoading: false, errorMessage: _friendlyError(e)); } } @@ -91,8 +85,7 @@ class KnowledgeBaseController extends _$KnowledgeBaseController { String _friendlyError(Object e) { final msg = e.toString(); - if (msg.contains('Connection refused') || - msg.contains('SocketException')) { + if (msg.contains('Connection refused') || msg.contains('SocketException')) { return 'Cannot reach the embedding server. ' 'Make sure AI models are downloaded and the app has had time to ' 'start the inference servers.'; diff --git a/lib/presentation/settings/knowledge_base_page.dart b/lib/presentation/settings/knowledge_base_page.dart index c0556bc..2ce0f75 100644 --- a/lib/presentation/settings/knowledge_base_page.dart +++ b/lib/presentation/settings/knowledge_base_page.dart @@ -91,8 +91,10 @@ class _KnowledgeBasePageState extends ConsumerState { final controller = ref.read(knowledgeBaseControllerProvider.notifier); // Show success SnackBar when a note is saved successfully. - ref.listen(knowledgeBaseControllerProvider, - (prev, next) { + ref.listen(knowledgeBaseControllerProvider, ( + prev, + next, + ) { if (next.successMessage != null && next.successMessage != prev?.successMessage) { ScaffoldMessenger.of(context).showSnackBar( @@ -107,12 +109,10 @@ class _KnowledgeBasePageState extends ConsumerState { backgroundColor: AppColors.surfaceContainerHigh, behavior: SnackBarBehavior.floating, shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(UIConstants.smallBorderRadius), - side: const BorderSide( - color: AppColors.success, - width: 1, + borderRadius: BorderRadius.circular( + UIConstants.smallBorderRadius, ), + side: const BorderSide(color: AppColors.success, width: 1), ), duration: const Duration(seconds: 3), ), @@ -139,12 +139,12 @@ class _KnowledgeBasePageState extends ConsumerState { children: [ // Heading Text( - 'Knowledge Base', - style: GoogleFonts.inter( + 'KNOWLEDGE BASE', + style: GoogleFonts.chakraPetch( fontSize: 22, fontWeight: FontWeight.w700, color: AppColors.textPrimary, - letterSpacing: -0.3, + letterSpacing: 1, ), ), const SizedBox(height: UIConstants.spacing8), @@ -229,9 +229,7 @@ class _KnowledgeBasePageState extends ConsumerState { ), if (kbState.chunkCount > 0) ...[ const SizedBox(width: UIConstants.spacing12), - _ClearButton( - onPressed: () => _clear(controller), - ), + _ClearButton(onPressed: () => _clear(controller)), ], ], ), @@ -327,9 +325,9 @@ class _StatusCard extends StatelessWidget { child: Text( hasChunks ? '$chunkCount chunk${chunkCount == 1 ? '' : 's'} stored — ' - 'AI chat will use these as context.' + 'AI chat will use these as context.' : 'No notes added yet. The AI chat will use only its base ' - 'training knowledge.', + 'training knowledge.', style: GoogleFonts.inter( fontSize: 13, color: hasChunks ? AppColors.success : AppColors.textMuted, @@ -407,8 +405,7 @@ class _SaveButtonState extends State<_SaveButton> { child: Material( color: Colors.transparent, child: InkWell( - borderRadius: - BorderRadius.circular(UIConstants.smallBorderRadius), + borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), onTap: widget.onPressed, child: Row( mainAxisAlignment: MainAxisAlignment.center, @@ -471,8 +468,7 @@ class _ClearButtonState extends State<_ClearButton> { child: Material( color: Colors.transparent, child: InkWell( - borderRadius: - BorderRadius.circular(UIConstants.smallBorderRadius), + borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), onTap: widget.onPressed, child: Padding( padding: const EdgeInsets.symmetric( @@ -484,8 +480,9 @@ class _ClearButtonState extends State<_ClearButton> { Icon( Icons.delete_outline_rounded, size: 15, - color: - _hovered ? AppColors.destructive : AppColors.textMuted, + color: _hovered + ? AppColors.destructive + : AppColors.textMuted, ), const SizedBox(width: UIConstants.spacing8), Text( @@ -523,9 +520,7 @@ class _ErrorBanner extends StatelessWidget { decoration: BoxDecoration( color: AppColors.destructiveMuted, borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), - border: Border.all( - color: AppColors.destructive.withValues(alpha: 0.4), - ), + border: Border.all(color: AppColors.destructive.withValues(alpha: 0.4)), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -589,7 +584,8 @@ class _HowItWorksCard extends StatelessWidget { const SizedBox(height: UIConstants.spacing12), _Step( n: '1', - text: 'Your text is split into ~500-character chunks at paragraph ' + text: + 'Your text is split into ~500-character chunks at paragraph ' 'and sentence boundaries.', ), const SizedBox(height: UIConstants.spacing8), diff --git a/lib/presentation/settings/settings_page.dart b/lib/presentation/settings/settings_page.dart index a99cbe5..bf9831e 100644 --- a/lib/presentation/settings/settings_page.dart +++ b/lib/presentation/settings/settings_page.dart @@ -7,6 +7,7 @@ import 'package:trainhub_flutter/core/router/app_router.dart'; import 'package:trainhub_flutter/core/theme/app_colors.dart'; import 'package:trainhub_flutter/presentation/settings/ai_model_settings_controller.dart'; import 'package:trainhub_flutter/presentation/settings/widgets/ai_models_section.dart'; +import 'package:trainhub_flutter/presentation/settings/widgets/ai_provider_section.dart'; import 'package:trainhub_flutter/presentation/settings/widgets/knowledge_base_section.dart'; import 'package:trainhub_flutter/presentation/settings/widgets/settings_top_bar.dart'; @@ -34,15 +35,17 @@ class SettingsPage extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Settings', - style: GoogleFonts.inter( + 'SETTINGS', + style: GoogleFonts.chakraPetch( fontSize: 22, fontWeight: FontWeight.w700, color: AppColors.textPrimary, - letterSpacing: -0.3, + letterSpacing: 1, ), ), const SizedBox(height: UIConstants.spacing32), + const AiProviderSection(), + const SizedBox(height: UIConstants.spacing32), AiModelsSection( modelState: modelState, onDownload: controller.downloadAll, diff --git a/lib/presentation/settings/widgets/ai_models_section.dart b/lib/presentation/settings/widgets/ai_models_section.dart index 75a79ab..8b7d59c 100644 --- a/lib/presentation/settings/widgets/ai_models_section.dart +++ b/lib/presentation/settings/widgets/ai_models_section.dart @@ -49,13 +49,13 @@ class AiModelsSection extends StatelessWidget { const Divider(height: 1, color: AppColors.border), const _ModelRow( name: 'Nomic Embed v1.5 Q4_K_M', - description: 'Text embedding model (~300 MB)', + description: 'Text embedding model (~84 MB)', icon: Icons.hub_outlined, ), const Divider(height: 1, color: AppColors.border), const _ModelRow( - name: 'Qwen 2.5 7B Instruct Q4_K_M', - description: 'Chat / reasoning model (~4.7 GB)', + name: 'Qwen3 4B Instruct 2507 Q4_K_M', + description: 'Chat / reasoning model (~2.4 GB)', icon: Icons.psychology_outlined, ), const Divider(height: 1, color: AppColors.border), @@ -162,7 +162,7 @@ class _StatusAndActions extends StatelessWidget { const SizedBox(height: UIConstants.spacing16), if (!modelState.areModelsValidated) SettingsActionButton( - label: 'Download AI Models (~5 GB)', + label: 'Download AI Models (~2.7 GB)', icon: Icons.download_rounded, color: AppColors.accent, textColor: AppColors.zinc950, @@ -190,11 +190,13 @@ class _StatusBadge extends StatelessWidget { @override Widget build(BuildContext context) { final color = validated ? AppColors.success : AppColors.textMuted; - final bgColor = - validated ? AppColors.successMuted : AppColors.surfaceContainerHigh; + final bgColor = validated + ? AppColors.successMuted + : AppColors.surfaceContainerHigh; final label = validated ? 'Ready' : 'Missing'; - final icon = - validated ? Icons.check_circle_outline : Icons.radio_button_unchecked; + final icon = validated + ? Icons.check_circle_outline + : Icons.radio_button_unchecked; return Row( mainAxisSize: MainAxisSize.min, @@ -277,8 +279,7 @@ class _DownloadingView extends StatelessWidget { value: modelState.progress, minHeight: 6, backgroundColor: AppColors.zinc800, - valueColor: - const AlwaysStoppedAnimation(AppColors.accent), + valueColor: const AlwaysStoppedAnimation(AppColors.accent), ), ), if (modelState.errorMessage != null) ...[ diff --git a/lib/presentation/settings/widgets/ai_provider_section.dart b/lib/presentation/settings/widgets/ai_provider_section.dart new file mode 100644 index 0000000..c08f2a6 --- /dev/null +++ b/lib/presentation/settings/widgets/ai_provider_section.dart @@ -0,0 +1,242 @@ +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/ai_settings_service.dart'; +import 'package:trainhub_flutter/injection.dart'; + +/// Lets the user pick which AI backend answers chat: the bundled local model +/// or a cloud API (Anthropic / OpenAI / Gemini) with their own key. +class AiProviderSection extends StatefulWidget { + const AiProviderSection({super.key}); + + @override + State createState() => _AiProviderSectionState(); +} + +class _AiProviderSectionState extends State { + late final AiSettingsService _service = getIt(); + late AiProvider _provider; + late final TextEditingController _apiKeyController; + late final TextEditingController _modelController; + late final TextEditingController _baseUrlController; + bool _obscureKey = true; + bool _saved = false; + + @override + void initState() { + super.initState(); + _provider = _service.settings.provider; + _apiKeyController = TextEditingController( + text: _service.settings.apiKeys[_provider.name] ?? '', + ); + _modelController = TextEditingController( + text: _service.settings.modelFor(_provider), + ); + _baseUrlController = TextEditingController( + text: _service.settings.baseUrlFor(_provider), + ); + } + + @override + void dispose() { + _apiKeyController.dispose(); + _modelController.dispose(); + _baseUrlController.dispose(); + super.dispose(); + } + + void _onProviderChanged(AiProvider? provider) { + if (provider == null) return; + setState(() { + _provider = provider; + _apiKeyController.text = _service.settings.apiKeys[provider.name] ?? ''; + _modelController.text = _service.settings.modelFor(provider); + _baseUrlController.text = _service.settings.baseUrlFor(provider); + _saved = false; + }); + } + + Future _save() async { + final settings = _service.settings; + await _service.save( + settings.copyWith( + provider: _provider, + apiKeys: { + ...settings.apiKeys, + _provider.name: _apiKeyController.text.trim(), + }, + models: { + ...settings.models, + _provider.name: _modelController.text.trim(), + }, + baseUrls: { + ...settings.baseUrls, + _provider.name: _baseUrlController.text.trim(), + }, + ), + ); + if (mounted) setState(() => _saved = true); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceContainer, + borderRadius: UIConstants.cardBorderRadius, + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(UIConstants.spacing16), + child: Row( + children: [ + const Icon( + Icons.cloud_outlined, + size: 18, + color: AppColors.accent, + ), + const SizedBox(width: 10), + Text( + 'AI PROVIDER', + style: GoogleFonts.chakraPetch( + fontSize: 14, + fontWeight: FontWeight.w600, + letterSpacing: 1, + color: AppColors.textPrimary, + ), + ), + ], + ), + ), + const Divider(height: 1, color: AppColors.border), + Padding( + padding: const EdgeInsets.all(UIConstants.spacing16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Chat backend. The knowledge base always uses the local ' + 'embedding model — your notes never leave this machine. ' + 'With a cloud provider, chat messages plus your exercise ' + 'and plan names are sent to that provider.', + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textSecondary, + height: 1.5, + ), + ), + const SizedBox(height: UIConstants.spacing16), + DropdownButtonFormField( + initialValue: _provider, + onChanged: _onProviderChanged, + dropdownColor: AppColors.surfaceContainerHigh, + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textPrimary, + ), + decoration: const InputDecoration(labelText: 'Provider'), + items: [ + for (final p in AiProvider.values) + DropdownMenuItem(value: p, child: Text(p.label)), + ], + ), + if (_provider.isSelfHosted) ...[ + const SizedBox(height: UIConstants.spacing12), + TextField( + controller: _baseUrlController, + onChanged: (_) => setState(() => _saved = false), + style: GoogleFonts.jetBrainsMono( + fontSize: 12, + color: AppColors.textPrimary, + ), + decoration: InputDecoration( + labelText: 'Server URL', + helperText: 'Default: ${_provider.defaultBaseUrl}', + helperStyle: GoogleFonts.inter( + fontSize: 11, + color: AppColors.textMuted, + ), + ), + ), + ], + if (_provider.needsApiKey) ...[ + const SizedBox(height: UIConstants.spacing12), + TextField( + controller: _apiKeyController, + obscureText: _obscureKey, + onChanged: (_) => setState(() => _saved = false), + style: GoogleFonts.jetBrainsMono( + fontSize: 12, + color: AppColors.textPrimary, + ), + decoration: InputDecoration( + labelText: 'API key', + suffixIcon: IconButton( + icon: Icon( + _obscureKey + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + size: 16, + ), + onPressed: () => + setState(() => _obscureKey = !_obscureKey), + ), + ), + ), + ], + if (_provider != AiProvider.local) ...[ + const SizedBox(height: UIConstants.spacing12), + TextField( + controller: _modelController, + onChanged: (_) => setState(() => _saved = false), + style: GoogleFonts.jetBrainsMono( + fontSize: 12, + color: AppColors.textPrimary, + ), + decoration: InputDecoration( + labelText: 'Model', + helperText: _provider == AiProvider.ollama + ? 'Name of a model pulled in Ollama, ' + 'e.g. qwen3:4b, llama3.1' + : 'Default: ${_provider.defaultModel}', + helperStyle: GoogleFonts.inter( + fontSize: 11, + color: AppColors.textMuted, + ), + ), + ), + ], + const SizedBox(height: UIConstants.spacing16), + Row( + children: [ + FilledButton(onPressed: _save, child: const Text('SAVE')), + if (_saved) ...[ + const SizedBox(width: UIConstants.spacing12), + const Icon( + Icons.check_circle_outline, + size: 16, + color: AppColors.success, + ), + const SizedBox(width: 6), + Text( + 'Saved', + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.success, + ), + ), + ], + ], + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/presentation/settings/widgets/settings_action_button.dart b/lib/presentation/settings/widgets/settings_action_button.dart index 4ed9f12..975e790 100644 --- a/lib/presentation/settings/widgets/settings_action_button.dart +++ b/lib/presentation/settings/widgets/settings_action_button.dart @@ -42,16 +42,15 @@ class _SettingsActionButtonState extends State { color: hasBorder ? (_hovered ? AppColors.zinc800 : Colors.transparent) : (_hovered - ? widget.color.withValues(alpha: 0.85) - : widget.color), + ? widget.color.withValues(alpha: 0.85) + : widget.color), borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), border: hasBorder ? Border.all(color: widget.borderColor!) : null, ), child: Material( color: Colors.transparent, child: InkWell( - borderRadius: - BorderRadius.circular(UIConstants.smallBorderRadius), + borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), onTap: widget.onPressed, child: Padding( padding: const EdgeInsets.symmetric( diff --git a/lib/presentation/settings/widgets/settings_top_bar.dart b/lib/presentation/settings/widgets/settings_top_bar.dart index 347cec3..b86df2f 100644 --- a/lib/presentation/settings/widgets/settings_top_bar.dart +++ b/lib/presentation/settings/widgets/settings_top_bar.dart @@ -78,8 +78,7 @@ class _SettingsIconButtonState extends State { child: Icon( widget.icon, size: 18, - color: - _hovered ? AppColors.textPrimary : AppColors.textSecondary, + color: _hovered ? AppColors.textPrimary : AppColors.textSecondary, ), ), ), diff --git a/lib/presentation/shell/shell_page.dart b/lib/presentation/shell/shell_page.dart index b73a64f..9e76063 100644 --- a/lib/presentation/shell/shell_page.dart +++ b/lib/presentation/shell/shell_page.dart @@ -1,10 +1,12 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:trainhub_flutter/core/constants/app_constants.dart'; import 'package:trainhub_flutter/core/constants/ui_constants.dart'; import 'package:trainhub_flutter/core/router/app_router.dart'; import 'package:trainhub_flutter/core/theme/app_colors.dart'; +import 'package:trainhub_flutter/presentation/settings/ai_model_settings_controller.dart'; @RoutePage() class ShellPage extends StatelessWidget { @@ -113,21 +115,22 @@ class _Sidebar extends StatelessWidget { decoration: BoxDecoration( color: AppColors.accentMuted, borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.accentBorder), ), child: const Icon( - Icons.fitness_center, + Icons.sports_martial_arts, color: AppColors.accent, size: 18, ), ), const SizedBox(width: 10), Text( - 'TrainHub', - style: GoogleFonts.inter( + 'TRAINHUB', + style: GoogleFonts.chakraPetch( fontSize: 15, fontWeight: FontWeight.w700, color: AppColors.textPrimary, - letterSpacing: -0.3, + letterSpacing: 1.5, ), ), ], @@ -150,6 +153,9 @@ class _Sidebar extends StatelessWidget { const Spacer(), + // --- AI model download progress (runs in the background) --- + const _DownloadIndicator(), + // --- Footer --- Padding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), @@ -206,10 +212,10 @@ class _NavItemState extends State<_NavItem> { margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 1), decoration: BoxDecoration( color: active - ? AppColors.zinc800 + ? AppColors.accentMuted : _isHovered - ? AppColors.zinc900 - : Colors.transparent, + ? AppColors.surfaceContainerHigh + : Colors.transparent, borderRadius: BorderRadius.circular(6), ), child: Row( @@ -229,19 +235,20 @@ class _NavItemState extends State<_NavItem> { Icon( active ? widget.data.activeIcon : widget.data.icon, size: 17, - color: active ? AppColors.textPrimary : AppColors.textMuted, + color: active ? AppColors.accent : AppColors.textMuted, ), const SizedBox(width: 9), Text( - widget.data.label, - style: GoogleFonts.inter( - fontSize: 13, - fontWeight: active ? FontWeight.w600 : FontWeight.w400, + widget.data.label.toUpperCase(), + style: GoogleFonts.chakraPetch( + fontSize: 12, + fontWeight: active ? FontWeight.w600 : FontWeight.w500, + letterSpacing: 1.2, color: active ? AppColors.textPrimary : _isHovered - ? AppColors.textSecondary - : AppColors.textMuted, + ? AppColors.textSecondary + : AppColors.textMuted, ), ), ], @@ -252,6 +259,61 @@ class _NavItemState extends State<_NavItem> { } } +// --------------------------------------------------------------------------- +// AI model download progress — visible from anywhere while a download runs +// --------------------------------------------------------------------------- +class _DownloadIndicator extends ConsumerWidget { + const _DownloadIndicator(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final modelState = ref.watch(aiModelSettingsControllerProvider); + if (!modelState.isDownloading) return const SizedBox.shrink(); + + return Container( + margin: const EdgeInsets.fromLTRB(12, 0, 12, 8), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.accentMuted, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.accentBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'DOWNLOADING AI', + style: GoogleFonts.jetBrainsMono( + fontSize: 9, + fontWeight: FontWeight.w600, + letterSpacing: 1.2, + color: AppColors.accent, + ), + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: modelState.progress, + minHeight: 4, + backgroundColor: AppColors.surfaceContainerHigh, + valueColor: const AlwaysStoppedAnimation(AppColors.accent), + ), + ), + const SizedBox(height: 4), + Text( + '${(modelState.progress * 100).toStringAsFixed(0)}%', + style: GoogleFonts.jetBrainsMono( + fontSize: 10, + color: AppColors.textSecondary, + ), + ), + ], + ), + ); + } +} + // --------------------------------------------------------------------------- // Settings icon button in sidebar footer // --------------------------------------------------------------------------- diff --git a/lib/presentation/trainings/trainings_page.dart b/lib/presentation/trainings/trainings_page.dart index 90f4606..a0ae2d9 100644 --- a/lib/presentation/trainings/trainings_page.dart +++ b/lib/presentation/trainings/trainings_page.dart @@ -14,6 +14,7 @@ import 'package:trainhub_flutter/presentation/trainings/trainings_controller.dar import 'package:trainhub_flutter/presentation/common/widgets/app_empty_state.dart'; import 'package:trainhub_flutter/presentation/common/dialogs/text_input_dialog.dart'; import 'package:trainhub_flutter/presentation/common/dialogs/confirm_dialog.dart'; +import 'package:trainhub_flutter/presentation/trainings/widgets/combo_builder_tab.dart'; @RoutePage() class TrainingsPage extends ConsumerWidget { @@ -24,7 +25,7 @@ class TrainingsPage extends ConsumerWidget { final asyncState = ref.watch(trainingsControllerProvider); return DefaultTabController( - length: 2, + length: 3, child: Column( children: [ Container( @@ -34,8 +35,9 @@ class TrainingsPage extends ConsumerWidget { ), child: const TabBar( tabs: [ - Tab(text: 'Training Plans'), - Tab(text: 'Exercises'), + Tab(text: 'TRAINING PLANS'), + Tab(text: 'EXERCISES'), + Tab(text: 'COMBOS'), ], ), ), @@ -47,6 +49,7 @@ class TrainingsPage extends ConsumerWidget { children: [ _PlansTab(plans: state.plans, ref: ref), _ExercisesTab(exercises: state.exercises, ref: ref), + const ComboBuilderTab(), ], ), ), @@ -401,7 +404,8 @@ class _ExerciseListItemState extends State<_ExerciseListItem> { @override Widget build(BuildContext context) { - final hasVideo = widget.exercise.videoUrl != null && + final hasVideo = + widget.exercise.videoUrl != null && widget.exercise.videoUrl!.isNotEmpty; return MouseRegion( @@ -542,8 +546,7 @@ class _ExercisePreviewDialog extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (hasVideo) - _ExerciseVideoPreview(videoPath: exercise.videoUrl!), + if (hasVideo) _ExerciseVideoPreview(videoPath: exercise.videoUrl!), Flexible( child: SingleChildScrollView( padding: const EdgeInsets.all(UIConstants.spacing24), @@ -578,8 +581,7 @@ class _ExercisePreviewDialog extends StatelessWidget { ], ), ], - if (exercise.tags != null && - exercise.tags!.isNotEmpty) ...[ + if (exercise.tags != null && exercise.tags!.isNotEmpty) ...[ const SizedBox(height: UIConstants.spacing12), Wrap( spacing: 6, @@ -740,8 +742,8 @@ class _ExerciseVideoPreviewState extends State<_ExerciseVideoPreview> { _player .seek(Duration(milliseconds: (_clipStart * 1000).round())) .then((_) { - if (mounted) setState(() => _isInitialized = true); - }); + if (mounted) setState(() => _isInitialized = true); + }); } else { if (mounted) setState(() => _isInitialized = true); } @@ -827,7 +829,9 @@ class _ExerciseVideoPreviewState extends State<_ExerciseVideoPreview> { final hasClip = _clipEnd != double.infinity; final clipDuration = hasClip ? (_clipEnd - _clipStart) : 0.0; final clipPosition = (_position - _clipStart).clamp(0.0, clipDuration); - final progress = (hasClip && clipDuration > 0) ? clipPosition / clipDuration : 0.0; + final progress = (hasClip && clipDuration > 0) + ? clipPosition / clipDuration + : 0.0; return Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/presentation/trainings/widgets/combo_builder_tab.dart b/lib/presentation/trainings/widgets/combo_builder_tab.dart new file mode 100644 index 0000000..68947d2 --- /dev/null +++ b/lib/presentation/trainings/widgets/combo_builder_tab.dart @@ -0,0 +1,805 @@ +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/combo_service.dart'; +import 'package:trainhub_flutter/domain/entities/exercise.dart'; +import 'package:trainhub_flutter/domain/entities/training_exercise.dart'; +import 'package:trainhub_flutter/domain/entities/training_plan.dart'; +import 'package:trainhub_flutter/domain/entities/training_section.dart'; +import 'package:trainhub_flutter/domain/repositories/exercise_repository.dart'; +import 'package:trainhub_flutter/domain/repositories/training_plan_repository.dart'; +import 'package:trainhub_flutter/injection.dart'; +import 'package:trainhub_flutter/presentation/common/widgets/node_graph_canvas.dart'; +import 'package:trainhub_flutter/presentation/common/dialogs/text_input_dialog.dart'; +import 'package:uuid/uuid.dart'; + +const _uuid = Uuid(); + +/// Node-graph editor for technique combinations: nodes are techniques, +/// edges are the transitions that flow well between them. A random walk +/// over the graph generates combos to drill. +class ComboBuilderTab extends StatefulWidget { + const ComboBuilderTab({super.key}); + + @override + State createState() => _ComboBuilderTabState(); +} + +class _ComboBuilderTabState extends State { + late final ComboService _service = getIt(); + String? _selectedId; + + @override + void initState() { + super.initState(); + _service.addListener(_onServiceChanged); + _service.ensureLoaded(); + } + + @override + void dispose() { + _service.removeListener(_onServiceChanged); + super.dispose(); + } + + void _onServiceChanged() { + if (mounted) setState(() {}); + } + + Combo? get _selected { + for (final c in _service.combos) { + if (c.id == _selectedId) return c; + } + return _service.combos.isEmpty ? null : _service.combos.first; + } + + // --------------------------------------------------------------------------- + // Actions + // --------------------------------------------------------------------------- + + Future _createCombo() async { + final name = await TextInputDialog.show( + context, + title: 'New Combo Graph', + hintText: 'e.g. Sparring counters', + confirmLabel: 'Create', + ); + if (name == null || name.trim().isEmpty) return; + final combo = _service.create(name.trim()); + setState(() => _selectedId = combo.id); + } + + Future _addTechnique(Combo combo) async { + final technique = await _showTechniquePicker(); + if (technique == null) return; + // Drop new nodes in a loose diagonal so they don't stack exactly. + final i = combo.nodes.length; + final node = ComboNode( + id: _uuid.v4(), + technique: technique.$1, + translation: technique.$2, + dx: 80.0 + (i % 5) * 210.0, + dy: 80.0 + (i ~/ 5) * 110.0 + (i % 3) * 14.0, + ); + _service.update(combo.copyWith(nodes: [...combo.nodes, node])); + } + + void _toggleEdge(Combo combo, String fromId, String toId) { + final exists = combo.edges.any((e) => e.fromId == fromId && e.toId == toId); + final edges = exists + ? combo.edges + .where((e) => !(e.fromId == fromId && e.toId == toId)) + .toList() + : [...combo.edges, ComboEdge(fromId, toId)]; + _service.update(combo.copyWith(edges: edges)); + } + + void _moveNode(Combo combo, String id, Offset position) { + _service.update( + combo.copyWith( + nodes: [ + for (final n in combo.nodes) + n.id == id ? n.copyWith(dx: position.dx, dy: position.dy) : n, + ], + ), + persist: false, + ); + } + + Future _deleteNode(Combo combo, String id) async { + final node = combo.nodes.where((n) => n.id == id).firstOrNull; + if (node == null) return; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text( + 'Remove "${node.technique}"?', + style: GoogleFonts.inter(fontSize: 15, fontWeight: FontWeight.w600), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text( + 'Remove', + style: GoogleFonts.inter(color: AppColors.destructive), + ), + ), + ], + ), + ); + if (confirmed != true) return; + _service.update( + combo.copyWith( + nodes: combo.nodes.where((n) => n.id != id).toList(), + edges: combo.edges + .where((e) => e.fromId != id && e.toId != id) + .toList(), + ), + ); + } + + void _generate(Combo combo) { + showDialog( + context: context, + builder: (ctx) => _GeneratedComboDialog(combo: combo), + ); + } + + Future<(String, String)?> _showTechniquePicker() { + return showDialog<(String, String)>( + context: context, + builder: (ctx) => const _TechniquePickerDialog(), + ); + } + + // --------------------------------------------------------------------------- + // Build + // --------------------------------------------------------------------------- + + @override + Widget build(BuildContext context) { + final combo = _selected; + + return Row( + children: [ + _ComboListPanel( + combos: _service.combos, + selectedId: combo?.id, + onSelect: (id) => setState(() => _selectedId = id), + onCreate: _createCombo, + onDelete: (id) => _service.delete(id), + ), + const VerticalDivider(width: 1, color: AppColors.border), + Expanded( + child: combo == null + ? _EmptyState(onCreate: _createCombo) + : Column( + children: [ + _Toolbar( + combo: combo, + onAddTechnique: () => _addTechnique(combo), + onGenerate: combo.nodes.isEmpty + ? null + : () => _generate(combo), + ), + Expanded( + child: ClipRect( + child: NodeGraphCanvas( + nodes: [ + for (final n in combo.nodes) + GraphNodeData( + id: n.id, + title: n.technique, + subtitle: n.translation, + accent: _accentFor(n.technique), + position: Offset(n.dx, n.dy), + ), + ], + edges: [ + for (final e in combo.edges) + 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), + ), + ), + ), + ], + ), + ), + ], + ); + } + + static Color _accentFor(String technique) { + final t = technique.toLowerCase(); + if (t.contains('chagi')) return AppColors.accent; + if (t.contains('jirugi') || t.contains('taerigi')) return AppColors.info; + if (t.contains('makgi')) return AppColors.purple; + return AppColors.zinc500; + } +} + +// --------------------------------------------------------------------------- +// Left panel — combo list +// --------------------------------------------------------------------------- + +class _ComboListPanel extends StatelessWidget { + const _ComboListPanel({ + required this.combos, + required this.selectedId, + required this.onSelect, + required this.onCreate, + required this.onDelete, + }); + + final List combos; + final String? selectedId; + final ValueChanged onSelect; + final VoidCallback onCreate; + final ValueChanged onDelete; + + @override + Widget build(BuildContext context) { + return Container( + width: 230, + color: AppColors.surfaceContainer, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.all(UIConstants.spacing12), + child: OutlinedButton.icon( + onPressed: onCreate, + icon: const Icon(Icons.add, size: 15), + label: const Text('New Combo'), + ), + ), + Expanded( + child: ListView.builder( + itemCount: combos.length, + itemBuilder: (context, index) { + final combo = combos[index]; + final selected = combo.id == selectedId; + return InkWell( + onTap: () => onSelect(combo.id), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: UIConstants.spacing12, + vertical: UIConstants.spacing8, + ), + color: selected + ? AppColors.accentMuted + : Colors.transparent, + child: Row( + children: [ + Icon( + Icons.account_tree_outlined, + size: 14, + color: selected + ? AppColors.accent + : AppColors.textMuted, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + combo.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: selected + ? FontWeight.w600 + : FontWeight.w400, + color: selected + ? AppColors.textPrimary + : AppColors.textSecondary, + ), + ), + Text( + '${combo.nodes.length} techniques · ' + '${combo.edges.length} links', + style: GoogleFonts.jetBrainsMono( + fontSize: 9, + color: AppColors.textMuted, + ), + ), + ], + ), + ), + IconButton( + onPressed: () => onDelete(combo.id), + icon: const Icon(Icons.delete_outline, size: 14), + color: AppColors.textMuted, + visualDensity: VisualDensity.compact, + tooltip: 'Delete combo', + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Toolbar above the canvas +// --------------------------------------------------------------------------- + +class _Toolbar extends StatelessWidget { + const _Toolbar({ + required this.combo, + required this.onAddTechnique, + required this.onGenerate, + }); + + final Combo combo; + final VoidCallback onAddTechnique; + final VoidCallback? onGenerate; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: UIConstants.spacing16, + vertical: UIConstants.spacing8, + ), + decoration: const BoxDecoration( + color: AppColors.surfaceContainer, + border: Border(bottom: BorderSide(color: AppColors.border)), + ), + child: Row( + children: [ + OutlinedButton.icon( + onPressed: onAddTechnique, + icon: const Icon(Icons.add, size: 15), + label: const Text('Technique'), + ), + const SizedBox(width: UIConstants.spacing8), + FilledButton.icon( + onPressed: onGenerate, + icon: const Icon(Icons.casino_outlined, size: 15), + label: const Text('GENERATE COMBO'), + ), + const Spacer(), + Text( + 'DRAG NODES · LINK WITH → · LONG-PRESS TO DELETE', + style: GoogleFonts.jetBrainsMono( + fontSize: 9, + letterSpacing: 0.8, + color: AppColors.textMuted, + ), + ), + ], + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Technique picker dialog +// --------------------------------------------------------------------------- + +class _TechniquePickerDialog extends StatefulWidget { + const _TechniquePickerDialog(); + + @override + State<_TechniquePickerDialog> createState() => _TechniquePickerDialogState(); +} + +class _TechniquePickerDialogState extends State<_TechniquePickerDialog> { + final _customController = TextEditingController(); + final _searchController = TextEditingController(); + List _exercises = []; + bool _loading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final exercises = await getIt().getAll(); + if (mounted) { + setState(() { + _exercises = exercises; + _loading = false; + }); + } + } + + @override + void dispose() { + _customController.dispose(); + _searchController.dispose(); + super.dispose(); + } + + List get _filtered { + final query = _searchController.text.trim().toLowerCase(); + if (query.isEmpty) return _exercises; + return _exercises + .where( + (e) => + e.name.toLowerCase().contains(query) || + (e.tags ?? '').toLowerCase().contains(query), + ) + .toList(); + } + + @override + Widget build(BuildContext context) { + final filtered = _filtered; + + return AlertDialog( + title: Text( + 'ADD TECHNIQUE', + style: GoogleFonts.chakraPetch( + fontSize: 15, + fontWeight: FontWeight.w700, + letterSpacing: 1, + color: AppColors.textPrimary, + ), + ), + content: SizedBox( + width: 420, + height: 460, + child: Column( + children: [ + TextField( + controller: _searchController, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration( + hintText: 'Search your exercise library…', + prefixIcon: Icon(Icons.search, size: 16), + ), + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: UIConstants.spacing8), + Expanded( + child: _loading + ? const Center(child: CircularProgressIndicator()) + : filtered.isEmpty + ? Center( + child: Text( + _exercises.isEmpty + ? 'No exercises in your library yet.\n' + 'Add them in the EXERCISES tab, or type ' + 'a custom technique below.' + : 'No match — try the custom field below.', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textMuted, + height: 1.6, + ), + ), + ) + : ListView.builder( + itemCount: filtered.length, + itemBuilder: (context, index) { + final exercise = filtered[index]; + return ListTile( + dense: true, + onTap: () => Navigator.pop(context, ( + exercise.name, + exercise.tags ?? '', + )), + title: Text( + exercise.name, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + ), + ), + subtitle: (exercise.tags ?? '').isEmpty + ? null + : Text( + exercise.tags!, + style: GoogleFonts.inter( + fontSize: 11, + color: AppColors.textMuted, + ), + ), + ); + }, + ), + ), + const Divider(height: 1, color: AppColors.border), + Padding( + padding: const EdgeInsets.only(top: UIConstants.spacing12), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _customController, + decoration: const InputDecoration( + hintText: 'Custom technique…', + ), + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textPrimary, + ), + onSubmitted: (v) { + if (v.trim().isNotEmpty) { + Navigator.pop(context, (v.trim(), '')); + } + }, + ), + ), + const SizedBox(width: UIConstants.spacing8), + FilledButton( + onPressed: () { + final v = _customController.text.trim(); + if (v.isNotEmpty) Navigator.pop(context, (v, '')); + }, + child: const Text('Add'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Generated combo dialog +// --------------------------------------------------------------------------- + +class _GeneratedComboDialog extends StatefulWidget { + const _GeneratedComboDialog({required this.combo}); + + final Combo combo; + + @override + State<_GeneratedComboDialog> createState() => _GeneratedComboDialogState(); +} + +class _GeneratedComboDialogState extends State<_GeneratedComboDialog> { + late List _sequence = widget.combo.generateSequence(length: 4); + bool _adding = false; + + /// Appends the generated sequence to a chosen training plan as a new + /// section — one exercise per technique, editable later in the plan editor. + Future _addToPlan() async { + final repo = getIt(); + final plans = await repo.getAll(); + if (!mounted) return; + + if (plans.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('No training plans yet — create one first.'), + ), + ); + return; + } + + final plan = await showDialog( + context: context, + builder: (ctx) => SimpleDialog( + title: Text( + 'ADD TO PLAN', + style: GoogleFonts.chakraPetch( + fontSize: 14, + fontWeight: FontWeight.w700, + letterSpacing: 1, + color: AppColors.textPrimary, + ), + ), + children: [ + for (final p in plans) + SimpleDialogOption( + onPressed: () => Navigator.pop(ctx, p), + child: Text( + p.name, + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textPrimary, + ), + ), + ), + ], + ), + ); + if (plan == null || !mounted) return; + + setState(() => _adding = true); + final section = TrainingSectionEntity( + id: _uuid.v4(), + name: 'Combo: ${widget.combo.name}', + exercises: [ + for (final node in _sequence) + TrainingExerciseEntity( + instanceId: _uuid.v4(), + exerciseId: node.id, + name: node.technique, + sets: 2, + value: 10, + isTime: false, + rest: 30, + ), + ], + ); + await repo.update(plan.copyWith(sections: [...plan.sections, section])); + + if (!mounted) return; + final messenger = ScaffoldMessenger.of(context); + Navigator.pop(context); + messenger.showSnackBar( + SnackBar(content: Text('Combo added to "${plan.name}"')), + ); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text( + 'YOUR COMBO', + style: GoogleFonts.chakraPetch( + fontSize: 15, + fontWeight: FontWeight.w700, + letterSpacing: 1, + color: AppColors.accent, + ), + ), + content: SizedBox( + width: 420, + child: _sequence.isEmpty + ? Text( + 'Add techniques and link them first.', + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + ), + ) + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < _sequence.length; i++) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 10, + ), + decoration: BoxDecoration( + color: AppColors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Row( + children: [ + Text( + '${i + 1}', + style: GoogleFonts.jetBrainsMono( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.accent, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + _sequence[i].technique.toUpperCase(), + style: GoogleFonts.chakraPetch( + fontSize: 13, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + color: AppColors.textPrimary, + ), + ), + ), + ], + ), + ), + if (i < _sequence.length - 1) + const Padding( + padding: EdgeInsets.symmetric(vertical: 2), + child: Icon( + Icons.arrow_downward_rounded, + size: 14, + color: AppColors.textMuted, + ), + ), + ], + ], + ), + ), + actions: [ + TextButton( + onPressed: () => setState( + () => _sequence = widget.combo.generateSequence(length: 4), + ), + child: const Text('REROLL'), + ), + OutlinedButton.icon( + onPressed: _sequence.isEmpty || _adding ? null : _addToPlan, + icon: const Icon(Icons.playlist_add_rounded, size: 15), + label: const Text('ADD TO PLAN'), + ), + FilledButton( + onPressed: () => Navigator.pop(context), + child: const Text('DONE'), + ), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// Empty state +// --------------------------------------------------------------------------- + +class _EmptyState extends StatelessWidget { + const _EmptyState({required this.onCreate}); + + final VoidCallback onCreate; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.account_tree_outlined, + size: 48, + color: AppColors.textMuted, + ), + const SizedBox(height: UIConstants.spacing16), + Text( + 'COMBO BUILDER', + style: GoogleFonts.chakraPetch( + fontSize: 18, + fontWeight: FontWeight.w700, + letterSpacing: 1, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: UIConstants.spacing8), + Text( + 'Map your techniques as a graph — nodes are techniques,\n' + 'links are transitions that flow well. Then generate\n' + 'random combos to drill on the timer.', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + height: 1.6, + ), + ), + const SizedBox(height: UIConstants.spacing24), + FilledButton.icon( + onPressed: onCreate, + icon: const Icon(Icons.add, size: 16), + label: const Text('CREATE FIRST COMBO'), + ), + ], + ), + ); + } +} diff --git a/lib/presentation/welcome/welcome_screen.dart b/lib/presentation/welcome/welcome_screen.dart index 3b28924..95ac009 100644 --- a/lib/presentation/welcome/welcome_screen.dart +++ b/lib/presentation/welcome/welcome_screen.dart @@ -28,12 +28,12 @@ class _WelcomeScreenState extends ConsumerState { .read(aiModelSettingsControllerProvider.notifier) .validateModels() .then((_) { - if (!mounted) return; - final validated = ref - .read(aiModelSettingsControllerProvider) - .areModelsValidated; - if (validated) _navigateToApp(); - }); + if (!mounted) return; + final validated = ref + .read(aiModelSettingsControllerProvider) + .areModelsValidated; + if (validated) _navigateToApp(); + }); }); } @@ -47,11 +47,11 @@ class _WelcomeScreenState extends ConsumerState { Widget build(BuildContext context) { final modelState = ref.watch(aiModelSettingsControllerProvider); - ref.listen(aiModelSettingsControllerProvider, - (prev, next) { - if (!_hasNavigated && - next.areModelsValidated && - !next.isDownloading) { + ref.listen(aiModelSettingsControllerProvider, ( + prev, + next, + ) { + if (!_hasNavigated && next.areModelsValidated && !next.isDownloading) { _navigateToApp(); } }); diff --git a/lib/presentation/welcome/widgets/download_progress.dart b/lib/presentation/welcome/widgets/download_progress.dart index 8331199..658db86 100644 --- a/lib/presentation/welcome/widgets/download_progress.dart +++ b/lib/presentation/welcome/widgets/download_progress.dart @@ -32,12 +32,12 @@ class DownloadProgress extends StatelessWidget { ), const SizedBox(height: UIConstants.spacing24), Text( - 'Setting up AI models\u2026', - style: GoogleFonts.inter( + 'SETTING UP AI MODELS\u2026', + style: GoogleFonts.chakraPetch( fontSize: 22, fontWeight: FontWeight.w700, color: AppColors.textPrimary, - letterSpacing: -0.3, + letterSpacing: 1, ), ), const SizedBox(height: UIConstants.spacing8), @@ -55,8 +55,7 @@ class DownloadProgress extends StatelessWidget { value: modelState.progress, minHeight: 6, backgroundColor: AppColors.zinc800, - valueColor: - const AlwaysStoppedAnimation(AppColors.accent), + valueColor: const AlwaysStoppedAnimation(AppColors.accent), ), ), const SizedBox(height: UIConstants.spacing12), @@ -105,9 +104,7 @@ class ErrorBanner extends StatelessWidget { decoration: BoxDecoration( color: AppColors.destructiveMuted, borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), - border: Border.all( - color: AppColors.destructive.withValues(alpha: 0.4), - ), + border: Border.all(color: AppColors.destructive.withValues(alpha: 0.4)), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/presentation/welcome/widgets/initial_prompt.dart b/lib/presentation/welcome/widgets/initial_prompt.dart index 87340ab..f88877b 100644 --- a/lib/presentation/welcome/widgets/initial_prompt.dart +++ b/lib/presentation/welcome/widgets/initial_prompt.dart @@ -46,19 +46,19 @@ class InitialPrompt extends StatelessWidget { borderRadius: BorderRadius.circular(12), ), child: const Icon( - Icons.fitness_center, + Icons.sports_martial_arts, color: AppColors.accent, size: 24, ), ), const SizedBox(width: UIConstants.spacing12), Text( - 'TrainHub', - style: GoogleFonts.inter( + 'TRAINHUB', + style: GoogleFonts.chakraPetch( fontSize: 26, fontWeight: FontWeight.w700, color: AppColors.textPrimary, - letterSpacing: -0.5, + letterSpacing: 2, ), ), ], @@ -67,13 +67,13 @@ class InitialPrompt extends StatelessWidget { Widget _buildHeadline() { return Text( - 'AI-powered coaching,\nright on your device.', - style: GoogleFonts.inter( + 'AI-POWERED COACHING,\nRIGHT ON YOUR DEVICE.', + style: GoogleFonts.chakraPetch( fontSize: 28, fontWeight: FontWeight.w700, color: AppColors.textPrimary, height: 1.25, - letterSpacing: -0.5, + letterSpacing: 0.5, ), ); } @@ -101,7 +101,7 @@ class InitialPrompt extends StatelessWidget { SizedBox(height: UIConstants.spacing12), FeatureRow( icon: Icons.psychology_outlined, - label: 'Qwen 2.5 7B chat model for training advice.', + label: 'Qwen3 4B chat model for training advice.', ), SizedBox(height: UIConstants.spacing12), FeatureRow( @@ -121,9 +121,7 @@ class InitialPrompt extends StatelessWidget { decoration: BoxDecoration( color: AppColors.accentMuted, borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), - border: Border.all( - color: AppColors.accent.withValues(alpha: 0.3), - ), + border: Border.all(color: AppColors.accent.withValues(alpha: 0.3)), ), child: Row( children: [ @@ -135,7 +133,7 @@ class InitialPrompt extends StatelessWidget { const SizedBox(width: UIConstants.spacing12), Expanded( child: Text( - 'The download is ~5 GB and only needs to happen once. ' + 'The download is ~2.7 GB and only needs to happen once. ' 'You can skip now and download later from Settings.', style: GoogleFonts.inter( fontSize: 13, diff --git a/lib/presentation/welcome/widgets/welcome_buttons.dart b/lib/presentation/welcome/widgets/welcome_buttons.dart index 9c56b7a..4440923 100644 --- a/lib/presentation/welcome/widgets/welcome_buttons.dart +++ b/lib/presentation/welcome/widgets/welcome_buttons.dart @@ -39,8 +39,7 @@ class _WelcomePrimaryButtonState extends State { child: Material( color: Colors.transparent, child: InkWell( - borderRadius: - BorderRadius.circular(UIConstants.smallBorderRadius), + borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), onTap: widget.onPressed, child: Row( mainAxisAlignment: MainAxisAlignment.center, @@ -75,8 +74,7 @@ class WelcomeSecondaryButton extends StatefulWidget { final VoidCallback onPressed; @override - State createState() => - _WelcomeSecondaryButtonState(); + State createState() => _WelcomeSecondaryButtonState(); } class _WelcomeSecondaryButtonState extends State { @@ -98,8 +96,7 @@ class _WelcomeSecondaryButtonState extends State { child: Material( color: Colors.transparent, child: InkWell( - borderRadius: - BorderRadius.circular(UIConstants.smallBorderRadius), + borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), onTap: widget.onPressed, child: Center( child: Text( diff --git a/lib/presentation/workout_session/widgets/activity_card.dart b/lib/presentation/workout_session/widgets/activity_card.dart index c212faa..a3ecae5 100644 --- a/lib/presentation/workout_session/widgets/activity_card.dart +++ b/lib/presentation/workout_session/widgets/activity_card.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; import 'package:trainhub_flutter/core/theme/app_colors.dart'; import 'package:trainhub_flutter/core/constants/ui_constants.dart'; import 'package:trainhub_flutter/domain/entities/workout_activity.dart'; @@ -21,9 +22,7 @@ class ActivityCard extends StatelessWidget { decoration: BoxDecoration( color: AppColors.surfaceContainer.withValues(alpha: 0.6), borderRadius: UIConstants.cardBorderRadius, - border: Border.all( - color: accentColor.withValues(alpha: 0.2), - ), + border: Border.all(color: accentColor.withValues(alpha: 0.2)), ), child: Column( children: [ @@ -47,11 +46,12 @@ class ActivityCard extends StatelessWidget { const SizedBox(height: UIConstants.spacing12), // Activity name Text( - activity.name, - style: const TextStyle( + activity.name.toUpperCase(), + style: GoogleFonts.chakraPetch( color: AppColors.textPrimary, fontSize: 22, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, ), textAlign: TextAlign.center, ), @@ -59,10 +59,7 @@ class ActivityCard extends StatelessWidget { const SizedBox(height: UIConstants.spacing8), Text( '${activity.sectionName ?? ''} \u00B7 Set ${activity.setIndex}/${activity.totalSets}', - style: const TextStyle( - color: AppColors.textMuted, - fontSize: 13, - ), + style: const TextStyle(color: AppColors.textMuted, fontSize: 13), ), if (activity.originalExercise != null) ...[ const SizedBox(height: UIConstants.spacing16), @@ -86,10 +83,7 @@ class ActivityCard extends StatelessWidget { padding: const EdgeInsets.only(top: UIConstants.spacing8), child: Text( 'Take a break', - style: TextStyle( - color: AppColors.textMuted, - fontSize: 14, - ), + style: TextStyle(color: AppColors.textMuted, fontSize: 14), ), ), ], @@ -124,10 +118,7 @@ class _InfoChip extends StatelessWidget { ), Text( label, - style: const TextStyle( - color: AppColors.textMuted, - fontSize: 11, - ), + style: const TextStyle(color: AppColors.textMuted, fontSize: 11), ), ], ), diff --git a/lib/presentation/workout_session/widgets/session_controls.dart b/lib/presentation/workout_session/widgets/session_controls.dart index d343ddc..331fcb5 100644 --- a/lib/presentation/workout_session/widgets/session_controls.dart +++ b/lib/presentation/workout_session/widgets/session_controls.dart @@ -46,11 +46,7 @@ class SessionControls extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ if (isTimeBased) ...[ - _ControlButton( - icon: Icons.replay_10, - onTap: onRewind, - size: 24, - ), + _ControlButton(icon: Icons.replay_10, onTap: onRewind, size: 24), const SizedBox(width: UIConstants.spacing8), ], _ControlButton( diff --git a/lib/presentation/workout_session/workout_session_controller.dart b/lib/presentation/workout_session/workout_session_controller.dart index 4d508db..260de55 100644 --- a/lib/presentation/workout_session/workout_session_controller.dart +++ b/lib/presentation/workout_session/workout_session_controller.dart @@ -101,9 +101,7 @@ class WorkoutSessionController extends _$WorkoutSessionController { 0, maxDuration, ); - state = AsyncValue.data( - currentState.copyWith(timeRemaining: newRemaining), - ); + state = AsyncValue.data(currentState.copyWith(timeRemaining: newRemaining)); } void _tick(Timer timer) { diff --git a/lib/presentation/workout_session/workout_session_page.dart b/lib/presentation/workout_session/workout_session_page.dart index df34e5d..18a6a66 100644 --- a/lib/presentation/workout_session/workout_session_page.dart +++ b/lib/presentation/workout_session/workout_session_page.dart @@ -2,6 +2,7 @@ import 'dart:math' as math; import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.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/workout_activity.dart'; @@ -25,7 +26,7 @@ class WorkoutSessionPage extends ConsumerWidget { final asyncState = ref.watch(workoutSessionControllerProvider(planId)); return Scaffold( - backgroundColor: AppColors.zinc950, + backgroundColor: AppColors.surface, body: asyncState.when( loading: () => const Center( child: CircularProgressIndicator( @@ -71,15 +72,10 @@ class WorkoutSessionPage extends ConsumerWidget { ); if (state.isFinished) { - return _CompletionScreen( - totalTimeElapsed: state.totalTimeElapsed, - ); + return _CompletionScreen(totalTimeElapsed: state.totalTimeElapsed); } - return _ActiveSessionView( - state: state, - controller: controller, - ); + return _ActiveSessionView(state: state, controller: controller); }, ), ); @@ -90,10 +86,7 @@ class _ActiveSessionView extends StatefulWidget { final WorkoutSessionState state; final WorkoutSessionController controller; - const _ActiveSessionView({ - required this.state, - required this.controller, - }); + const _ActiveSessionView({required this.state, required this.controller}); @override State<_ActiveSessionView> createState() => _ActiveSessionViewState(); @@ -110,8 +103,7 @@ class _ActiveSessionViewState extends State<_ActiveSessionView> { final double timeProgress; if (activity != null && activity.duration > 0) { - timeProgress = - 1.0 - (widget.state.timeRemaining / activity.duration); + timeProgress = 1.0 - (widget.state.timeRemaining / activity.duration); } else { timeProgress = 0.0; } @@ -128,11 +120,7 @@ class _ActiveSessionViewState extends State<_ActiveSessionView> { gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [ - AppColors.zinc950, - accentTint, - AppColors.zinc950, - ], + colors: [AppColors.surface, accentTint, AppColors.surface], stops: const [0.0, 0.5, 1.0], ), ), @@ -149,8 +137,7 @@ class _ActiveSessionViewState extends State<_ActiveSessionView> { child: Column( mainAxisSize: MainAxisSize.min, children: [ - if (activity != null) - ActivityCard(activity: activity), + if (activity != null) ActivityCard(activity: activity), const SizedBox(height: UIConstants.spacing32), if (isTimeBased) _CircularTimerDisplay( @@ -249,26 +236,20 @@ class _ActiveSessionViewState extends State<_ActiveSessionView> { decoration: BoxDecoration( color: AppColors.zinc800.withValues(alpha: 0.6), borderRadius: BorderRadius.circular(20), - border: Border.all( - color: AppColors.border.withValues(alpha: 0.5), - ), + border: Border.all(color: AppColors.border.withValues(alpha: 0.5)), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon( - Icons.timer_outlined, - size: 14, - color: AppColors.textMuted, - ), + Icon(Icons.timer_outlined, size: 14, color: AppColors.textMuted), const SizedBox(width: 4), Text( _formatDuration(widget.state.totalTimeElapsed), - style: const TextStyle( + style: GoogleFonts.jetBrainsMono( color: AppColors.textSecondary, fontSize: 13, fontWeight: FontWeight.w500, - fontFeatures: [FontFeature.tabularFigures()], + fontFeatures: const [FontFeature.tabularFigures()], ), ), ], @@ -352,11 +333,10 @@ class _RepsDisplay extends StatelessWidget { children: [ Text( '$reps', - style: TextStyle( + style: GoogleFonts.jetBrainsMono( color: AppColors.textPrimary, fontSize: 72, - fontWeight: FontWeight.w200, - letterSpacing: -2, + fontWeight: FontWeight.w700, shadows: [ Shadow( color: AppColors.accent.withValues(alpha: 0.3), @@ -462,13 +442,11 @@ class _CircularTimerDisplay extends StatelessWidget { children: [ Text( _formatTime(timeRemaining), - style: TextStyle( + style: GoogleFonts.jetBrainsMono( color: AppColors.textPrimary, fontSize: 64, - fontWeight: FontWeight.w200, - letterSpacing: -1, + fontWeight: FontWeight.w700, fontFeatures: const [FontFeature.tabularFigures()], - fontFamily: 'monospace', shadows: [ Shadow( color: ringColor.withValues(alpha: 0.3), @@ -525,10 +503,7 @@ class TimerRingPainter extends CustomPainter { final double progress; final Color ringColor; - TimerRingPainter({ - required this.progress, - required this.ringColor, - }); + TimerRingPainter({required this.progress, required this.ringColor}); @override void paint(Canvas canvas, Size size) { @@ -551,10 +526,10 @@ class TimerRingPainter extends CustomPainter { ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth * 2 ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 16); - + final sweepAngle = 2 * math.pi * progress; final startAngle = -math.pi / 2; - + canvas.drawArc( Rect.fromCircle(center: center, radius: radius), startAngle, @@ -576,7 +551,9 @@ class TimerRingPainter extends CustomPainter { ); final progressPaint = Paint() - ..shader = gradient.createShader(Rect.fromCircle(center: center, radius: radius)) + ..shader = gradient.createShader( + Rect.fromCircle(center: center, radius: radius), + ) ..style = PaintingStyle.stroke ..strokeCap = StrokeCap.round ..strokeWidth = strokeWidth; @@ -593,7 +570,7 @@ class TimerRingPainter extends CustomPainter { @override bool shouldRepaint(TimerRingPainter oldDelegate) { return oldDelegate.progress != progress || - oldDelegate.ringColor != ringColor; + oldDelegate.ringColor != ringColor; } } @@ -601,10 +578,7 @@ class _UpNextPill extends StatelessWidget { final String nextActivityName; final bool isNextRest; - const _UpNextPill({ - required this.nextActivityName, - required this.isNextRest, - }); + const _UpNextPill({required this.nextActivityName, required this.isNextRest}); @override Widget build(BuildContext context) { @@ -693,9 +667,7 @@ class _ActivitiesListPanel extends StatelessWidget { width: 320, decoration: const BoxDecoration( color: AppColors.surfaceContainer, - border: Border( - left: BorderSide(color: AppColors.border), - ), + border: Border(left: BorderSide(color: AppColors.border)), ), child: Column( children: [ @@ -763,8 +735,8 @@ class _ActivitiesListPanel extends StatelessWidget { color: isCurrent ? AppColors.accent : isRest - ? AppColors.info - : AppColors.zinc600, + ? AppColors.info + : AppColors.zinc600, ), ), const SizedBox(width: UIConstants.spacing12), @@ -785,7 +757,8 @@ class _ActivitiesListPanel extends StatelessWidget { : FontWeight.normal, ), ), - if (!isRest && activity.setIndex != null) + if (!isRest && + activity.setIndex != null) Text( 'Set ${activity.setIndex}/${activity.totalSets} · ${activity.sectionName ?? ''}', style: const TextStyle( @@ -888,22 +861,19 @@ class _CompletionScreen extends StatelessWidget { ), ), const SizedBox(height: UIConstants.spacing24), - const Text( - 'Workout Complete', - style: TextStyle( + Text( + 'WORKOUT COMPLETE', + style: GoogleFonts.chakraPetch( color: AppColors.textPrimary, fontSize: 28, - fontWeight: FontWeight.w600, - letterSpacing: -0.5, + fontWeight: FontWeight.w700, + letterSpacing: 1, ), ), const SizedBox(height: UIConstants.spacing8), Text( 'Great job! You crushed it.', - style: TextStyle( - color: AppColors.textSecondary, - fontSize: 15, - ), + style: TextStyle(color: AppColors.textSecondary, fontSize: 15), ), const SizedBox(height: UIConstants.spacing32), Container( @@ -932,12 +902,11 @@ class _CompletionScreen extends StatelessWidget { const SizedBox(height: UIConstants.spacing4), Text( _formatDuration(totalTimeElapsed), - style: TextStyle( + style: GoogleFonts.jetBrainsMono( color: AppColors.textPrimary, fontSize: 36, - fontWeight: FontWeight.w300, + fontWeight: FontWeight.w600, fontFeatures: const [FontFeature.tabularFigures()], - fontFamily: 'monospace', ), ), ], diff --git a/lib/presentation/workout_session/workout_session_state.dart b/lib/presentation/workout_session/workout_session_state.dart index d83a928..73590f8 100644 --- a/lib/presentation/workout_session/workout_session_state.dart +++ b/lib/presentation/workout_session/workout_session_state.dart @@ -21,8 +21,8 @@ class WorkoutSessionState with _$WorkoutSessionState { WorkoutActivityEntity? get nextActivity => currentIndex + 1 < activities.length - ? activities[currentIndex + 1] - : null; + ? activities[currentIndex + 1] + : null; double get progress => activities.isEmpty ? 0.0 : currentIndex / activities.length; diff --git a/pubspec.lock b/pubspec.lock index d1f2a0b..df27c23 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -149,10 +149,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" charcode: dependency: transitive description: @@ -617,18 +617,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" media_kit: dependency: "direct main" description: @@ -697,10 +697,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -1070,10 +1070,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.11" timing: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 80ec483..8b68b26 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: trainhub_flutter description: "TrainHub - Training Program Management" publish_to: 'none' -version: 2.0.0+1 +version: 2.1.0+2 environment: sdk: ^3.10.7 diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico index c04e20c..30b5ac8 100644 Binary files a/windows/runner/resources/app_icon.ico and b/windows/runner/resources/app_icon.ico differ