From 9dcc4b87dee649f11aa59d11259453c1033074e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kazimierz=20Cio=C5=82ek?= Date: Tue, 24 Feb 2026 02:19:28 +0100 Subject: [PATCH] Next refactors --- .claude/settings.local.json | 3 +- README.md | 143 +++- lib/core/constants/ai_constants.dart | 49 ++ lib/core/constants/app_constants.dart | 1 + .../repositories/note_repository_impl.dart | 2 +- lib/data/services/ai_process_manager.dart | 174 +++++ .../services/embedding_service.dart | 13 +- lib/domain/services/ai_process_manager.dart | 88 --- lib/injection.dart | 4 +- lib/main.dart | 22 +- lib/presentation/chat/chat_controller.dart | 250 +++++-- lib/presentation/chat/chat_controller.g.dart | 19 +- lib/presentation/chat/chat_page.dart | 683 +++++------------ lib/presentation/chat/chat_state.dart | 14 + lib/presentation/chat/chat_state.freezed.dart | 265 ++++++- .../chat/widgets/message_bubble.dart | 121 +++ .../chat/widgets/missing_models_state.dart | 115 +++ .../chat/widgets/new_chat_button.dart | 67 ++ .../chat/widgets/thinking_block.dart | 180 +++++ .../chat/widgets/typing_bubble.dart | 89 +++ .../chat/widgets/typing_indicator.dart | 111 +++ lib/presentation/home/home_page.dart | 704 ++---------------- .../home/widgets/next_workout_banner.dart | 70 ++ .../home/widgets/quick_actions_row.dart | 124 +++ .../home/widgets/recent_activity_section.dart | 272 +++++++ .../home/widgets/stat_cards_row.dart | 51 ++ .../home/widgets/welcome_header.dart | 71 ++ .../ai_model_settings_controller.dart | 169 ++--- .../ai_model_settings_controller.g.dart | 2 +- lib/presentation/settings/settings_page.dart | 651 +--------------- .../settings/widgets/ai_models_section.dart | 322 ++++++++ .../widgets/knowledge_base_section.dart | 118 +++ .../widgets/settings_action_button.dart | 81 ++ .../settings/widgets/settings_top_bar.dart | 89 +++ lib/presentation/shell/shell_page.dart | 3 +- lib/presentation/welcome/welcome_screen.dart | 472 +----------- .../welcome/widgets/download_progress.dart | 135 ++++ .../welcome/widgets/initial_prompt.dart | 208 ++++++ .../welcome/widgets/welcome_buttons.dart | 119 +++ pubspec.lock | 16 +- 40 files changed, 3515 insertions(+), 2575 deletions(-) create mode 100644 lib/core/constants/ai_constants.dart create mode 100644 lib/data/services/ai_process_manager.dart rename lib/{domain => data}/services/embedding_service.dart (66%) delete mode 100644 lib/domain/services/ai_process_manager.dart create mode 100644 lib/presentation/chat/widgets/message_bubble.dart create mode 100644 lib/presentation/chat/widgets/missing_models_state.dart create mode 100644 lib/presentation/chat/widgets/new_chat_button.dart create mode 100644 lib/presentation/chat/widgets/thinking_block.dart create mode 100644 lib/presentation/chat/widgets/typing_bubble.dart create mode 100644 lib/presentation/chat/widgets/typing_indicator.dart create mode 100644 lib/presentation/home/widgets/next_workout_banner.dart create mode 100644 lib/presentation/home/widgets/quick_actions_row.dart create mode 100644 lib/presentation/home/widgets/recent_activity_section.dart create mode 100644 lib/presentation/home/widgets/stat_cards_row.dart create mode 100644 lib/presentation/home/widgets/welcome_header.dart create mode 100644 lib/presentation/settings/widgets/ai_models_section.dart create mode 100644 lib/presentation/settings/widgets/knowledge_base_section.dart create mode 100644 lib/presentation/settings/widgets/settings_action_button.dart create mode 100644 lib/presentation/settings/widgets/settings_top_bar.dart create mode 100644 lib/presentation/welcome/widgets/download_progress.dart create mode 100644 lib/presentation/welcome/widgets/initial_prompt.dart create mode 100644 lib/presentation/welcome/widgets/welcome_buttons.dart diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 728efc7..1d0744c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -19,7 +19,8 @@ "Bash(echo:*)", "Bash(mpv:*)", "Bash(rpm -qa:*)", - "Bash(ldconfig:*)" + "Bash(ldconfig:*)", + "Bash(dart analyze:*)" ] } } diff --git a/README.md b/README.md index e4c5001..27b8f51 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,141 @@ -# trainhub_flutter +# TrainHub -A new Flutter project. +AI-powered training management desktop application for personal trainers. Features on-device AI chat with RAG, video analysis, program scheduling, and exercise library management. + +## Architecture + +``` +lib/ +├── core/ # Shared constants, extensions, router, theme, utils +├── domain/ # Business entities and repository interfaces +│ ├── entities/ # Freezed immutable models +│ └── repositories/ # Abstract repository contracts +├── data/ # Infrastructure and data access +│ ├── database/ # Drift ORM schema, DAOs +│ ├── mappers/ # Entity <-> DTO conversion +│ ├── repositories/ # Repository implementations +│ └── services/ # External service integrations (AI, embeddings) +└── presentation/ # UI layer + ├── analysis/ # Video analysis feature + ├── calendar/ # Program calendar + ├── chat/ # AI chat with RAG + ├── common/ # Shared widgets and dialogs + ├── home/ # Dashboard + ├── plan_editor/ # Training plan builder + ├── settings/ # App settings and model management + ├── shell/ # Main app layout (sidebar + tabs) + ├── trainings/ # Training list management + ├── welcome/ # First-launch onboarding + └── workout_session/ # Active workout tracking +``` + +**Layers:** +- **Domain** -- Pure Dart. Entities (Freezed), abstract repository interfaces. No framework imports. +- **Data** -- Drift database, DAOs, mappers, repository implementations, AI services (llama.cpp, Nomic). +- **Presentation** -- Flutter widgets, Riverpod controllers, Freezed UI states. + +## Tech Stack + +| Layer | Technology | +|---|---| +| UI | Flutter 3.x, Material Design 3, shadcn_ui | +| State | Riverpod 2.6 with code generation | +| Routing | AutoRoute 9.2 | +| Database | Drift 2.14 (SQLite) | +| DI | get_it 8.0 | +| Immutability | Freezed 2.5 | +| Local AI | llama.cpp (Qwen 2.5 7B + Nomic Embed v1.5) | +| Video | media_kit | ## Getting Started -This project is a starting point for a Flutter application. +### Prerequisites -A few resources to get you started if this is your first Flutter project: +- Flutter SDK (stable channel) +- Dart SDK >= 3.10.7 +- Desktop platform toolchain (Visual Studio for Windows, Xcode for macOS) -- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) +### Setup -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. +```bash +# Install dependencies +flutter pub get + +# Run code generation +dart run build_runner build --delete-conflicting-outputs + +# Launch app +flutter run -d windows # or macos, linux +``` + +### AI Models + +The app uses local AI inference via llama.cpp. Models are downloaded automatically on first launch or from **Settings > AI Models**: + +- **llama-server** binary (llama.cpp build b8130) +- **Qwen 2.5 7B Instruct Q4_K_M** (~4.7 GB) -- chat/reasoning +- **Nomic Embed Text v1.5 Q4_K_M** (~300 MB) -- text embeddings for RAG + +Models are stored in the system documents directory. Total download: ~5 GB. + +AI configuration constants are centralized in `lib/core/constants/ai_constants.dart`. + +## Code Generation + +This project relies on code generation for several packages. After modifying any annotated files, run: + +```bash +dart run build_runner build --delete-conflicting-outputs +``` + +Generated files (do not edit manually): +- `*.freezed.dart` -- Freezed immutable models +- `*.g.dart` -- Drift DAOs, Riverpod providers, JSON serialization +- `app_router.gr.dart` -- AutoRoute generated routes + +## Key Conventions + +### State Management + +Each feature follows the controller + state pattern: + +``` +feature/ +├── feature_page.dart # @RoutePage widget +├── feature_controller.dart # @riverpod controller +├── feature_state.dart # @freezed state model +└── widgets/ # Extracted sub-widgets +``` + +Controllers are `@riverpod` classes that manage async state. States are `@freezed` classes. + +### Dependency Injection + +Services and repositories are registered in `lib/injection.dart` using get_it: +- **Singletons** -- database, DAOs, AI services +- **Lazy singletons** -- repositories + +Controllers access dependencies via `getIt()`. + +### Naming + +- **Files/directories:** `snake_case` +- **Classes:** `PascalCase` +- **Variables/methods:** `camelCase` +- **Constants:** `UPPER_CASE` for environment variables, `camelCase` for class constants +- **Booleans:** prefix with `is`, `has`, `can` (e.g., `isLoading`, `hasError`) +- **Functions:** start with a verb (e.g., `loadSession`, `createProgram`) + +### Widget Extraction + +Keep page files under 200 lines. Extract standalone widget classes into a `widgets/` subdirectory within the feature folder. + +## Project Configuration + +| File | Purpose | +|---|---| +| `lib/core/constants/app_constants.dart` | App name, version, window sizes | +| `lib/core/constants/ai_constants.dart` | AI model config, ports, URLs | +| `lib/core/constants/ui_constants.dart` | Spacing, border radius, animation duration | +| `lib/core/theme/app_colors.dart` | Zinc palette, semantic colors | +| `lib/core/theme/app_theme.dart` | Material 3 dark theme | diff --git a/lib/core/constants/ai_constants.dart b/lib/core/constants/ai_constants.dart new file mode 100644 index 0000000..5aec6f0 --- /dev/null +++ b/lib/core/constants/ai_constants.dart @@ -0,0 +1,49 @@ +import 'dart:io'; + +class AiConstants { + AiConstants._(); + + // Server ports + static const int chatServerPort = 8080; + static const int embeddingServerPort = 8081; + + // API endpoints + static String get chatApiUrl => + 'http://localhost:$chatServerPort/v1/chat/completions'; + static String get embeddingApiUrl => + 'http://localhost:$embeddingServerPort/v1/embeddings'; + + // Model files + static const String qwenModelFile = 'qwen2.5-7b-instruct-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'; + + // llama.cpp binary + static const String llamaBuild = 'b8130'; + static String get serverBinaryName => + Platform.isWindows ? 'llama-server.exe' : 'llama-server'; + + // Model download URLs + 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'; + + // Server configuration + static const int qwenContextSize = 4096; + static const int nomicContextSize = 8192; + static const int gpuLayerOffload = 99; + static const double chatTemperature = 0.7; + + // Timeouts + static const Duration serverConnectTimeout = Duration(seconds: 30); + static const Duration serverReceiveTimeout = Duration(minutes: 5); + static const Duration embeddingConnectTimeout = Duration(seconds: 10); + static const Duration embeddingReceiveTimeout = Duration(seconds: 60); + + // 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.'; +} diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart index f296e60..f4fbe05 100644 --- a/lib/core/constants/app_constants.dart +++ b/lib/core/constants/app_constants.dart @@ -2,6 +2,7 @@ class AppConstants { AppConstants._(); static const String appName = 'TrainHub'; + static const String appVersion = '2.0.0'; static const double windowWidth = 1280; static const double windowHeight = 800; static const double minWindowWidth = 800; diff --git a/lib/data/repositories/note_repository_impl.dart b/lib/data/repositories/note_repository_impl.dart index a9772a4..e18ba96 100644 --- a/lib/data/repositories/note_repository_impl.dart +++ b/lib/data/repositories/note_repository_impl.dart @@ -5,7 +5,7 @@ import 'package:drift/drift.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'; -import 'package:trainhub_flutter/domain/services/embedding_service.dart'; +import 'package:trainhub_flutter/data/services/embedding_service.dart'; import 'package:uuid/uuid.dart'; const _uuid = Uuid(); diff --git a/lib/data/services/ai_process_manager.dart b/lib/data/services/ai_process_manager.dart new file mode 100644 index 0000000..6230d9d --- /dev/null +++ b/lib/data/services/ai_process_manager.dart @@ -0,0 +1,174 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:trainhub_flutter/core/constants/ai_constants.dart'; + +enum AiServerStatus { offline, starting, ready, error } + +/// Manages the two llama.cpp server processes that provide AI features. +/// +/// 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) +class AiProcessManager extends ChangeNotifier { + Process? _qwenProcess; + Process? _nomicProcess; + AiServerStatus _status = AiServerStatus.offline; + String? _lastError; + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + AiServerStatus get status => _status; + bool get isRunning => _status == AiServerStatus.ready; + String? get errorMessage => _lastError; + + /// Starts both inference servers. No-ops if already running or starting. + Future startServers() async { + if (_status == AiServerStatus.starting || _status == AiServerStatus.ready) { + return; + } + + _updateStatus(AiServerStatus.starting); + _lastError = null; + + final dir = await getApplicationDocumentsDirectory(); + final base = dir.path; + final serverBin = p.join(base, AiConstants.serverBinaryName); + + if (!File(serverBin).existsSync()) { + _lastError = 'llama-server executable not found.'; + _updateStatus(AiServerStatus.error); + return; + } + + 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++; + } + + if (!qwenReady || !nomicReady) { + throw Exception('Servers failed to start within 10 seconds.'); + } + + _updateStatus(AiServerStatus.ready); + } catch (e) { + // Clean up any partially-started processes before returning error. + _qwenProcess?.kill(); + _nomicProcess?.kill(); + _qwenProcess = null; + _nomicProcess = null; + _lastError = e.toString(); + _updateStatus(AiServerStatus.error); + } + } + + /// Kills both processes and resets the running flag. + /// Safe to call even if servers were never started. + Future stopServers() async { + _qwenProcess?.kill(); + _nomicProcess?.kill(); + + if (Platform.isWindows) { + try { + await Process.run('taskkill', ['/F', '/IM', AiConstants.serverBinaryName]); + } catch (_) {} + } else if (Platform.isMacOS || Platform.isLinux) { + try { + await Process.run('pkill', ['-f', 'llama-server']); + } catch (_) {} + } + + _qwenProcess = null; + _nomicProcess = null; + _updateStatus(AiServerStatus.offline); + } + + Future _isPortReady(int port) async { + try { + final socket = await Socket.connect( + '127.0.0.1', + port, + timeout: const Duration(seconds: 1), + ); + socket.destroy(); + return true; + } catch (_) { + return false; + } + } + + void _updateStatus(AiServerStatus newStatus) { + if (_status != newStatus) { + _status = newStatus; + notifyListeners(); + } + } +} diff --git a/lib/domain/services/embedding_service.dart b/lib/data/services/embedding_service.dart similarity index 66% rename from lib/domain/services/embedding_service.dart rename to lib/data/services/embedding_service.dart index aad1879..718afe5 100644 --- a/lib/domain/services/embedding_service.dart +++ b/lib/data/services/embedding_service.dart @@ -1,25 +1,24 @@ import 'package:dio/dio.dart'; +import 'package:trainhub_flutter/core/constants/ai_constants.dart'; -/// Wraps the Nomic embedding server (llama.cpp, port 8081). +/// Wraps the Nomic embedding server (llama.cpp). /// Returns a 768-dimensional float vector for any input text. class EmbeddingService { final _dio = Dio( BaseOptions( - connectTimeout: const Duration(seconds: 10), - receiveTimeout: const Duration(seconds: 60), + connectTimeout: AiConstants.embeddingConnectTimeout, + receiveTimeout: AiConstants.embeddingReceiveTimeout, ), ); - static const _url = 'http://localhost:8081/v1/embeddings'; - /// Returns the embedding vector for [text]. /// Throws a [DioException] if the Nomic server is unreachable. Future> embed(String text) async { final response = await _dio.post>( - _url, + AiConstants.embeddingApiUrl, data: { 'input': text, - 'model': 'nomic-embed-text-v1.5.Q4_K_M', + 'model': AiConstants.nomicModelName, }, ); diff --git a/lib/domain/services/ai_process_manager.dart b/lib/domain/services/ai_process_manager.dart deleted file mode 100644 index 1cdfdf3..0000000 --- a/lib/domain/services/ai_process_manager.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'dart:io'; - -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; - -/// Manages the two llama.cpp server processes that provide AI features. -/// -/// 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) -class AiProcessManager { - Process? _qwenProcess; - Process? _nomicProcess; - bool _running = false; - - // ------------------------------------------------------------------------- - // Public API - // ------------------------------------------------------------------------- - - bool get isRunning => _running; - - /// Starts both inference servers. No-ops if already running or if the - /// llama-server binary is not present on disk. - Future startServers() async { - if (_running) return; - - final dir = await getApplicationDocumentsDirectory(); - final base = dir.path; - final serverBin = p.join( - base, - Platform.isWindows ? 'llama-server.exe' : 'llama-server', - ); - - if (!File(serverBin).existsSync()) return; - - try { - // ── Qwen 2.5 7B chat server ────────────────────────────────────────── - _qwenProcess = await Process.start( - serverBin, - [ - '-m', p.join(base, 'qwen2.5-7b-instruct-q4_k_m.gguf'), - '--port', '8080', - '--ctx-size', '4096', - '-ngl', '99', // offload all layers to GPU - ], - runInShell: false, - ); - // Drain pipes so the process is never blocked by a full buffer. - _qwenProcess!.stdout.drain>(); - _qwenProcess!.stderr.drain>(); - - // ── Nomic embedding server ─────────────────────────────────────────── - _nomicProcess = await Process.start( - serverBin, - [ - '-m', p.join(base, 'nomic-embed-text-v1.5.Q4_K_M.gguf'), - '--port', '8081', - '--ctx-size', '8192', - '--embedding', - ], - runInShell: false, - ); - _nomicProcess!.stdout.drain>(); - _nomicProcess!.stderr.drain>(); - - _running = true; - } catch (_) { - // Clean up any partially-started processes before rethrowing. - _qwenProcess?.kill(); - _nomicProcess?.kill(); - _qwenProcess = null; - _nomicProcess = null; - rethrow; - } - } - - /// Kills both processes and resets the running flag. - /// Safe to call even if servers were never started. - Future stopServers() async { - _qwenProcess?.kill(); - _nomicProcess?.kill(); - _qwenProcess = null; - _nomicProcess = null; - _running = false; - } -} diff --git a/lib/injection.dart b/lib/injection.dart index 52ac610..d487243 100644 --- a/lib/injection.dart +++ b/lib/injection.dart @@ -17,8 +17,8 @@ import 'package:trainhub_flutter/data/repositories/analysis_repository_impl.dart 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/domain/services/ai_process_manager.dart'; -import 'package:trainhub_flutter/domain/services/embedding_service.dart'; +import 'package:trainhub_flutter/data/services/ai_process_manager.dart'; +import 'package:trainhub_flutter/data/services/embedding_service.dart'; import 'package:trainhub_flutter/data/database/daos/knowledge_chunk_dao.dart'; final GetIt getIt = GetIt.instance; diff --git a/lib/main.dart b/lib/main.dart index 6000369..ee9ff16 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,10 +4,8 @@ import 'package:media_kit/media_kit.dart'; 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/domain/services/ai_process_manager.dart'; +import 'package:trainhub_flutter/data/services/ai_process_manager.dart'; import 'package:trainhub_flutter/injection.dart' as di; -import 'package:trainhub_flutter/presentation/settings/ai_model_settings_controller.dart'; -import 'package:trainhub_flutter/presentation/settings/ai_model_settings_state.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -44,14 +42,10 @@ class TrainHubApp extends ConsumerStatefulWidget { ConsumerState createState() => _TrainHubAppState(); } -class _TrainHubAppState extends ConsumerState - with WindowListener { +class _TrainHubAppState extends ConsumerState with WindowListener { // Create the router once and reuse it across rebuilds. final _appRouter = AppRouter(); - // Guard flag so we never start the servers more than once per app session. - bool _serversStarted = false; - // ------------------------------------------------------------------------- // Lifecycle // ------------------------------------------------------------------------- @@ -87,18 +81,6 @@ class _TrainHubAppState extends ConsumerState @override Widget build(BuildContext context) { - // Watch the model-settings state and start servers exactly once, the - // first time models are confirmed to be present on disk. - ref.listen( - aiModelSettingsControllerProvider, - (prev, next) { - if (!_serversStarted && next.areModelsValidated) { - _serversStarted = true; - di.getIt().startServers(); - } - }, - ); - return MaterialApp.router( title: 'TrainHub', theme: AppTheme.dark, diff --git a/lib/presentation/chat/chat_controller.dart b/lib/presentation/chat/chat_controller.dart index 25e75eb..f3f85f8 100644 --- a/lib/presentation/chat/chat_controller.dart +++ b/lib/presentation/chat/chat_controller.dart @@ -1,30 +1,32 @@ +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/note_repository.dart'; +import 'package:trainhub_flutter/data/services/ai_process_manager.dart'; import 'package:trainhub_flutter/injection.dart'; import 'package:trainhub_flutter/presentation/chat/chat_state.dart'; +import 'package:uuid/uuid.dart'; part 'chat_controller.g.dart'; -const _chatApiUrl = 'http://localhost:8080/v1/chat/completions'; - -/// Base system prompt that is always included. -const _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.'; +@riverpod +AiProcessManager aiProcessManager(AiProcessManagerRef ref) { + final manager = getIt(); + manager.addListener(() => ref.notifyListeners()); + return manager; +} @riverpod class ChatController extends _$ChatController { late ChatRepository _repo; late NoteRepository _noteRepo; - // Shared Dio client — generous timeout for 7B models running on CPU. final _dio = Dio( BaseOptions( - connectTimeout: const Duration(seconds: 30), - receiveTimeout: const Duration(minutes: 5), + connectTimeout: AiConstants.serverConnectTimeout, + receiveTimeout: AiConstants.serverReceiveTimeout, ), ); @@ -32,14 +34,14 @@ class ChatController extends _$ChatController { Future build() async { _repo = getIt(); _noteRepo = getIt(); + final aiManager = ref.read(aiProcessManagerProvider); + if (aiManager.status == AiServerStatus.offline) { + aiManager.startServers(); + } final sessions = await _repo.getAllSessions(); return ChatState(sessions: sessions); } - // ------------------------------------------------------------------------- - // Session management (unchanged) - // ------------------------------------------------------------------------- - Future createSession() async { final session = await _repo.createSession(); final sessions = await _repo.getAllSessions(); @@ -72,28 +74,29 @@ class ChatController extends _$ChatController { ); } - // ------------------------------------------------------------------------- - // Send message (RAG + Step D) - // ------------------------------------------------------------------------- - Future sendMessage(String content) async { final current = state.valueOrNull; if (current == null) return; + final sessionId = await _resolveSession(current, content); + await _persistUserMessage(sessionId, content); + final contextChunks = await _searchKnowledgeBase(content); + final systemPrompt = _buildSystemPrompt(contextChunks); + final history = _buildHistory(); + final fullAiResponse = await _streamResponse(systemPrompt, history); + await _persistAssistantResponse(sessionId, content, fullAiResponse); + } - // ── 1. Resolve / create a session ───────────────────────────────────── - String sessionId; - if (current.activeSession == null) { - final session = await _repo.createSession(); - sessionId = session.id; - final sessions = await _repo.getAllSessions(); - state = AsyncValue.data( - current.copyWith(sessions: sessions, activeSession: session), - ); - } else { - sessionId = current.activeSession!.id; - } + Future _resolveSession(ChatState current, String content) async { + if (current.activeSession != null) return current.activeSession!.id; + final session = await _repo.createSession(); + final sessions = await _repo.getAllSessions(); + state = AsyncValue.data( + current.copyWith(sessions: sessions, activeSession: session), + ); + return session.id; + } - // ── 2. Persist user message & show typing indicator ─────────────────── + Future _persistUserMessage(String sessionId, String content) async { await _repo.addMessage( sessionId: sessionId, role: 'user', @@ -104,95 +107,196 @@ class ChatController extends _$ChatController { state.valueOrNull!.copyWith( messages: messagesAfterUser, isTyping: true, + thinkingSteps: [], + streamingContent: '', ), ); + } - // ── 3. RAG: retrieve relevant chunks from the knowledge base ────────── - // Gracefully degrades — if Nomic server is unavailable or no chunks - // exist, the chat still works with the base system prompt alone. + Future> _searchKnowledgeBase(String query) async { + final searchStep = _createStep('Searching knowledge base...'); List contextChunks = []; try { - contextChunks = await _noteRepo.searchSimilar(content, topK: 3); - } catch (_) { - // Nomic server not running or no chunks stored — continue without RAG. + contextChunks = await _noteRepo.searchSimilar(query, topK: 3); + if (contextChunks.isNotEmpty) { + _updateStep( + searchStep.id, + status: ThinkingStepStatus.completed, + title: 'Found ${contextChunks.length} documents', + details: 'Context added for assistant.', + ); + } else { + _updateStep( + searchStep.id, + status: ThinkingStepStatus.completed, + title: 'No matching documents in knowledge base', + details: 'Responding based on general knowledge.', + ); + } + } catch (e) { + _updateStep( + searchStep.id, + status: ThinkingStepStatus.error, + title: 'Knowledge base search error', + details: e.toString(), + ); } + return contextChunks; + } - // ── 4. Build enriched system prompt (Step D) ────────────────────────── - final systemPrompt = _buildSystemPrompt(contextChunks); - - // Build the full conversation history so the model maintains context. - final history = messagesAfterUser - .map( - (m) => { - 'role': m.isUser ? 'user' : 'assistant', - 'content': m.content, - }, - ) + List> _buildHistory() { + final messages = state.valueOrNull?.messages ?? []; + return messages + .map((m) => { + 'role': m.isUser ? 'user' : 'assistant', + 'content': m.content, + }) .toList(); + } - // ── 5. POST to Qwen (http://localhost:8080/v1/chat/completions) ──────── - String aiResponse; + Future _streamResponse( + String systemPrompt, + List> history, + ) async { + final generateStep = _createStep('Generating response...'); + String fullAiResponse = ''; try { - final response = await _dio.post>( - _chatApiUrl, + final response = await _dio.post( + AiConstants.chatApiUrl, + options: Options(responseType: ResponseType.stream), data: { 'messages': [ {'role': 'system', 'content': systemPrompt}, ...history, ], - 'temperature': 0.7, + 'temperature': AiConstants.chatTemperature, + 'stream': true, }, ); - aiResponse = - response.data!['choices'][0]['message']['content'] as String; + _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 (_) {} + } + } + _updateStep( + generateStep.id, + status: ThinkingStepStatus.completed, + title: 'Response generated', + ); } on DioException catch (e) { - aiResponse = - 'Could not reach the AI server (${e.message}). ' - 'Make sure AI models are downloaded and the inference servers have ' - 'had time to start.'; + fullAiResponse += '\n\n[AI model communication error]'; + _updateStep( + generateStep.id, + status: ThinkingStepStatus.error, + title: 'Generation failed', + details: '${e.message}', + ); } catch (e) { - aiResponse = 'An unexpected error occurred: $e'; + fullAiResponse += '\n\n[Unexpected error]'; + _updateStep( + generateStep.id, + status: ThinkingStepStatus.error, + title: 'Generation failed', + details: e.toString(), + ); } + return fullAiResponse; + } - // ── 6. Persist response & update session title on first exchange ─────── + Future _persistAssistantResponse( + String sessionId, + String userContent, + String aiResponse, + ) async { await _repo.addMessage( sessionId: sessionId, role: 'assistant', content: aiResponse, ); - final messagesAfterAi = await _repo.getMessages(sessionId); if (messagesAfterAi.length <= 2) { - final title = - content.length > 30 ? '${content.substring(0, 30)}…' : content; + final title = userContent.length > 30 + ? '${userContent.substring(0, 30)}…' + : userContent; await _repo.updateSessionTitle(sessionId, title); } - final sessions = await _repo.getAllSessions(); state = AsyncValue.data( state.valueOrNull!.copyWith( messages: messagesAfterAi, isTyping: false, + streamingContent: null, + thinkingSteps: [], sessions: sessions, ), ); } - // ------------------------------------------------------------------------- - // Helpers - // ------------------------------------------------------------------------- + ThinkingStep _createStep(String title) { + final step = ThinkingStep( + id: const Uuid().v4(), + title: title, + status: ThinkingStepStatus.pending, + ); + final current = state.valueOrNull; + if (current != null) { + state = AsyncValue.data( + current.copyWith(thinkingSteps: [...current.thinkingSteps, step]), + ); + } + return step; + } + + void _updateStep( + String id, { + ThinkingStepStatus? status, + String? title, + String? details, + }) { + final current = state.valueOrNull; + if (current == null) return; + final updatedSteps = current.thinkingSteps.map((s) { + if (s.id != id) return s; + return s.copyWith( + status: status ?? s.status, + title: title ?? s.title, + details: details ?? s.details, + ); + }).toList(); + state = AsyncValue.data(current.copyWith(thinkingSteps: updatedSteps)); + } - /// Builds the system prompt, injecting RAG context when available. static String _buildSystemPrompt(List chunks) { - if (chunks.isEmpty) return _baseSystemPrompt; - + if (chunks.isEmpty) return AiConstants.baseSystemPrompt; final contextBlock = chunks .asMap() .entries .map((e) => '[${e.key + 1}] ${e.value}') .join('\n\n'); - - return '$_baseSystemPrompt\n\n' + return '${AiConstants.baseSystemPrompt}\n\n' '### Relevant notes from the trainer\'s knowledge base:\n' '$contextBlock\n\n' 'Use the above context to inform your response when relevant. ' diff --git a/lib/presentation/chat/chat_controller.g.dart b/lib/presentation/chat/chat_controller.g.dart index d85f627..cdcbba7 100644 --- a/lib/presentation/chat/chat_controller.g.dart +++ b/lib/presentation/chat/chat_controller.g.dart @@ -6,7 +6,24 @@ part of 'chat_controller.dart'; // RiverpodGenerator // ************************************************************************** -String _$chatControllerHash() => r'06ffc6b53c1d878ffc0a758da4f7ee1261ae1340'; +String _$aiProcessManagerHash() => r'ae77b1e18c06f4192092e1489744626fc8516776'; + +/// See also [aiProcessManager]. +@ProviderFor(aiProcessManager) +final aiProcessManagerProvider = AutoDisposeProvider.internal( + aiProcessManager, + name: r'aiProcessManagerProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$aiProcessManagerHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef AiProcessManagerRef = AutoDisposeProviderRef; +String _$chatControllerHash() => r'266d8a5ac91cbe6c112f85f15adf5a8046e85682'; /// See also [ChatController]. @ProviderFor(ChatController) diff --git a/lib/presentation/chat/chat_page.dart b/lib/presentation/chat/chat_page.dart index dfd46bc..5751bc9 100644 --- a/lib/presentation/chat/chat_page.dart +++ b/lib/presentation/chat/chat_page.dart @@ -1,16 +1,18 @@ -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/router/app_router.dart'; import 'package:trainhub_flutter/core/theme/app_colors.dart'; -import 'package:trainhub_flutter/domain/entities/chat_message.dart'; +import 'package:trainhub_flutter/data/services/ai_process_manager.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'; +import 'package:trainhub_flutter/presentation/chat/widgets/message_bubble.dart'; +import 'package:trainhub_flutter/presentation/chat/widgets/missing_models_state.dart'; +import 'package:trainhub_flutter/presentation/chat/widgets/new_chat_button.dart'; +import 'package:trainhub_flutter/presentation/chat/widgets/typing_bubble.dart'; +import 'package:trainhub_flutter/presentation/chat/widgets/typing_indicator.dart'; import 'package:trainhub_flutter/presentation/settings/ai_model_settings_controller.dart'; @RoutePage() @@ -49,11 +51,10 @@ class _ChatPageState extends ConsumerState { void _sendMessage(ChatController controller) { final text = _inputController.text.trim(); - if (text.isNotEmpty) { - controller.sendMessage(text); - _inputController.clear(); - _inputFocusNode.requestFocus(); - } + if (text.isEmpty) return; + controller.sendMessage(text); + _inputController.clear(); + _inputFocusNode.requestFocus(); } String _formatTimestamp(String timestamp) { @@ -75,16 +76,10 @@ class _ChatPageState extends ConsumerState { @override Widget build(BuildContext context) { - // ── Gate: check whether AI models are present on disk ───────────────── - final modelsValidated = ref - .watch(aiModelSettingsControllerProvider) - .areModelsValidated; - - // Watch chat state regardless of gate so Riverpod keeps the provider - // alive and the scroll listener is always registered. + final modelsValidated = + ref.watch(aiModelSettingsControllerProvider).areModelsValidated; final state = ref.watch(chatControllerProvider); final controller = ref.read(chatControllerProvider.notifier); - ref.listen(chatControllerProvider, (prev, next) { if (next.hasValue && (prev?.value?.messages.length ?? 0) < @@ -97,16 +92,12 @@ class _ChatPageState extends ConsumerState { _scrollToBottom(); } }); - - // ── Show "models missing" placeholder ───────────────────────────────── if (!modelsValidated) { return const Scaffold( backgroundColor: AppColors.surface, - body: _MissingModelsState(), + body: MissingModelsState(), ); } - - // ── Normal chat UI ───────────────────────────────────────────────────── return Scaffold( backgroundColor: AppColors.surface, body: Row( @@ -118,9 +109,6 @@ class _ChatPageState extends ConsumerState { ); } - // --------------------------------------------------------------------------- - // Side Panel - // --------------------------------------------------------------------------- Widget _buildSidePanel( AsyncValue asyncState, ChatController controller, @@ -129,9 +117,7 @@ class _ChatPageState extends ConsumerState { width: 250, decoration: const BoxDecoration( color: AppColors.surfaceContainer, - border: Border( - right: BorderSide(color: AppColors.border, width: 1), - ), + border: Border(right: BorderSide(color: AppColors.border, width: 1)), ), child: Column( children: [ @@ -139,19 +125,17 @@ class _ChatPageState extends ConsumerState { padding: const EdgeInsets.all(UIConstants.spacing12), child: SizedBox( width: double.infinity, - child: _NewChatButton(onPressed: controller.createSession), + child: NewChatButton(onPressed: controller.createSession), ), ), - const Divider(height: 1, color: AppColors.border), - Expanded( child: asyncState.when( data: (data) { if (data.sessions.isEmpty) { - return Center( + return const Center( child: Padding( - padding: const EdgeInsets.all(UIConstants.spacing24), + padding: EdgeInsets.all(UIConstants.spacing24), child: Text( 'No conversations yet', style: TextStyle( @@ -169,23 +153,18 @@ class _ChatPageState extends ConsumerState { itemCount: data.sessions.length, itemBuilder: (context, index) { final session = data.sessions[index]; - final isActive = - session.id == data.activeSession?.id; return _buildSessionTile( session: session, - isActive: isActive, + isActive: session.id == data.activeSession?.id, controller: controller, ); }, ); }, - error: (_, __) => Center( + error: (_, __) => const Center( child: Text( 'Error loading sessions', - style: TextStyle( - color: AppColors.textMuted, - fontSize: 13, - ), + style: TextStyle(color: AppColors.textMuted, fontSize: 13), ), ), loading: () => const Center( @@ -211,7 +190,6 @@ class _ChatPageState extends ConsumerState { required ChatController controller, }) { final isHovered = _hoveredSessionId == session.id; - return MouseRegion( onEnter: (_) => setState(() => _hoveredSessionId = session.id), onExit: (_) => setState(() { @@ -240,7 +218,6 @@ class _ChatPageState extends ConsumerState { border: isActive ? Border.all( color: AppColors.accent.withValues(alpha: 0.3), - width: 1, ) : null, ), @@ -283,7 +260,8 @@ class _ChatPageState extends ConsumerState { Icons.delete_outline_rounded, color: AppColors.textMuted, ), - onPressed: () => controller.deleteSession(session.id), + onPressed: () => + controller.deleteSession(session.id), tooltip: 'Delete', ), ), @@ -296,9 +274,6 @@ class _ChatPageState extends ConsumerState { ); } - // --------------------------------------------------------------------------- - // Chat Area - // --------------------------------------------------------------------------- Widget _buildChatArea( AsyncValue asyncState, ChatController controller, @@ -315,29 +290,38 @@ 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) { - return const _TypingIndicator(); + if (data.thinkingSteps.isNotEmpty || + (data.streamingContent != null && + data.streamingContent!.isNotEmpty)) { + return TypingBubble( + thinkingSteps: data.thinkingSteps, + streamingContent: data.streamingContent, + ); + } + return const TypingIndicator(); } final msg = data.messages[index]; - return _MessageBubble( + return MessageBubble( message: msg, formattedTime: _formatTimestamp(msg.createdAt), ); }, ); }, - error: (e, _) => Center( + error: (e, _) => const Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon( + Icon( Icons.error_outline_rounded, color: AppColors.destructive, size: 40, ), - const SizedBox(height: UIConstants.spacing12), + SizedBox(height: UIConstants.spacing12), Text( 'Something went wrong', style: TextStyle( @@ -402,15 +386,28 @@ class _ChatPageState extends ConsumerState { ); } - // --------------------------------------------------------------------------- - // Input Bar - // --------------------------------------------------------------------------- Widget _buildInputBar( AsyncValue asyncState, ChatController controller, ) { + final aiManager = ref.watch(aiProcessManagerProvider); final isTyping = asyncState.valueOrNull?.isTyping ?? false; - + final isStarting = aiManager.status == AiServerStatus.starting; + final isError = aiManager.status == AiServerStatus.error; + final isReady = aiManager.status == AiServerStatus.ready; + String? statusMessage; + Color statusColor = AppColors.textMuted; + if (isStarting) { + statusMessage = + 'Starting AI inference server (this may take a moment)...'; + statusColor = AppColors.info; + } else if (isError) { + statusMessage = + 'AI Server Error: ${aiManager.errorMessage ?? "Unknown error"}'; + statusColor = AppColors.destructive; + } else if (!isReady) { + statusMessage = 'AI Server offline. Reconnecting...'; + } return Container( decoration: const BoxDecoration( color: AppColors.surface, @@ -420,466 +417,136 @@ class _ChatPageState extends ConsumerState { horizontal: UIConstants.spacing16, vertical: UIConstants.spacing12, ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: Container( - decoration: BoxDecoration( - color: AppColors.surfaceContainer, - borderRadius: - BorderRadius.circular(UIConstants.borderRadius), - border: Border.all(color: AppColors.border, width: 1), - ), - child: TextField( - controller: _inputController, - focusNode: _inputFocusNode, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 14, - ), - maxLines: 4, - minLines: 1, - decoration: InputDecoration( - hintText: 'Type a message...', - hintStyle: TextStyle( - color: AppColors.textMuted, - fontSize: 14, - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: UIConstants.spacing16, - vertical: UIConstants.spacing12, - ), - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - ), - onSubmitted: (_) => _sendMessage(controller), - textInputAction: TextInputAction.send, - ), - ), - ), - const SizedBox(width: UIConstants.spacing8), - SizedBox( - width: 40, - height: 40, - child: Material( - color: isTyping ? AppColors.zinc700 : AppColors.accent, - borderRadius: - BorderRadius.circular(UIConstants.borderRadius), - child: InkWell( - borderRadius: - BorderRadius.circular(UIConstants.borderRadius), - onTap: isTyping ? null : () => _sendMessage(controller), - child: Icon( - Icons.arrow_upward_rounded, - color: - isTyping ? AppColors.textMuted : AppColors.zinc950, - size: 20, - ), - ), - ), - ), - ], - ), - ); - } -} - -// ============================================================================= -// Missing Models State — shown when AI files have not been downloaded yet -// ============================================================================= -class _MissingModelsState extends StatelessWidget { - const _MissingModelsState(); - - @override - Widget build(BuildContext context) { - return Center( child: Column( - mainAxisAlignment: MainAxisAlignment.center, children: [ - // Icon - Container( - width: 72, - height: 72, - decoration: BoxDecoration( - color: AppColors.surfaceContainerHigh, - borderRadius: BorderRadius.circular(18), - ), - child: const Icon( - Icons.cloud_download_outlined, - size: 36, - color: AppColors.textMuted, - ), - ), - - const SizedBox(height: UIConstants.spacing24), - - Text( - 'AI models are missing.', - style: GoogleFonts.inter( - fontSize: 18, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary, - letterSpacing: -0.2, - ), - ), - - const SizedBox(height: UIConstants.spacing8), - - Text( - 'Please download them to use the AI chat.', - style: GoogleFonts.inter( - fontSize: 14, - color: AppColors.textSecondary, - ), - ), - - const SizedBox(height: UIConstants.spacing32), - - // "Go to Settings" button - _GoToSettingsButton(), - ], - ), - ); - } -} - -class _GoToSettingsButton extends StatefulWidget { - @override - State<_GoToSettingsButton> createState() => _GoToSettingsButtonState(); -} - -class _GoToSettingsButtonState extends State<_GoToSettingsButton> { - bool _hovered = false; - - @override - Widget build(BuildContext context) { - return MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: AnimatedContainer( - duration: UIConstants.animationDuration, - height: 44, - decoration: BoxDecoration( - color: _hovered - ? AppColors.accent.withValues(alpha: 0.85) - : AppColors.accent, - borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), - ), - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: - BorderRadius.circular(UIConstants.smallBorderRadius), - onTap: () => context.router.push(const SettingsRoute()), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: UIConstants.spacing24, - ), + if (statusMessage != null) + Padding( + padding: const EdgeInsets.only(bottom: UIConstants.spacing8), child: Row( - mainAxisSize: MainAxisSize.min, children: [ - const Icon( - Icons.settings_outlined, - size: 16, - color: AppColors.zinc950, - ), - const SizedBox(width: UIConstants.spacing8), - Text( - 'Go to Settings', - style: GoogleFonts.inter( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppColors.zinc950, + if (isStarting) + Container( + margin: const EdgeInsets.only(right: 8), + width: 12, + height: 12, + child: const CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.info, + ), + ) + else if (isError) + const Padding( + padding: EdgeInsets.only(right: 8), + child: Icon( + Icons.error_outline, + size: 14, + color: AppColors.destructive, + ), + ), + Expanded( + child: Text( + statusMessage, + style: GoogleFonts.inter( + fontSize: 12, + color: statusColor, + fontWeight: FontWeight.w500, + ), ), ), - ], - ), - ), - ), - ), - ), - ); - } -} - -// ============================================================================= -// New Chat Button -// ============================================================================= -class _NewChatButton extends StatefulWidget { - const _NewChatButton({required this.onPressed}); - - final VoidCallback onPressed; - - @override - State<_NewChatButton> createState() => _NewChatButtonState(); -} - -class _NewChatButtonState extends State<_NewChatButton> { - bool _isHovered = false; - - @override - Widget build(BuildContext context) { - return MouseRegion( - onEnter: (_) => setState(() => _isHovered = true), - onExit: (_) => setState(() => _isHovered = false), - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - decoration: BoxDecoration( - color: _isHovered - ? AppColors.zinc700 - : AppColors.surfaceContainerHigh, - borderRadius: - BorderRadius.circular(UIConstants.smallBorderRadius), - border: Border.all(color: AppColors.border, width: 1), - ), - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: - BorderRadius.circular(UIConstants.smallBorderRadius), - onTap: widget.onPressed, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: UIConstants.spacing12, - vertical: UIConstants.spacing8, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: const [ - Icon( - Icons.add_rounded, - size: 16, - color: AppColors.textSecondary, - ), - SizedBox(width: UIConstants.spacing8), - Text( - 'New Chat', - style: TextStyle( - color: AppColors.textSecondary, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ), - ), - ), - ); - } -} - -// ============================================================================= -// Message Bubble -// ============================================================================= -class _MessageBubble extends StatelessWidget { - const _MessageBubble({ - required this.message, - required this.formattedTime, - }); - - final ChatMessageEntity message; - final String formattedTime; - - @override - Widget build(BuildContext context) { - final isUser = message.isUser; - final maxWidth = MediaQuery.of(context).size.width * 0.55; - - return Padding( - padding: const EdgeInsets.only(bottom: UIConstants.spacing12), - child: Row( - mainAxisAlignment: - isUser ? MainAxisAlignment.end : MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!isUser) ...[ - Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: AppColors.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.auto_awesome_rounded, - size: 14, - color: AppColors.accent, - ), - ), - const SizedBox(width: UIConstants.spacing8), - ], - Flexible( - child: Column( - crossAxisAlignment: isUser - ? CrossAxisAlignment.end - : CrossAxisAlignment.start, - children: [ - Container( - constraints: BoxConstraints(maxWidth: maxWidth), - padding: const EdgeInsets.symmetric( - horizontal: UIConstants.spacing16, - vertical: UIConstants.spacing12, - ), - decoration: BoxDecoration( - color: isUser - ? AppColors.zinc700 - : AppColors.surfaceContainer, - borderRadius: BorderRadius.only( - topLeft: const Radius.circular(16), - topRight: const Radius.circular(16), - bottomLeft: isUser - ? const Radius.circular(16) - : const Radius.circular(4), - bottomRight: isUser - ? const Radius.circular(4) - : const Radius.circular(16), - ), - border: isUser - ? null - : Border.all(color: AppColors.border, width: 1), - ), - child: SelectableText( - message.content, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 14, - height: 1.5, - ), - ), - ), - const SizedBox(height: 4), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Text( - formattedTime, - style: const TextStyle( - color: AppColors.textMuted, - fontSize: 11, - ), - ), - ), - ], - ), - ), - if (isUser) ...[ - const SizedBox(width: UIConstants.spacing8), - Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: AppColors.accent.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.person_rounded, - size: 14, - color: AppColors.accent, - ), - ), - ], - ], - ), - ); - } -} - -// ============================================================================= -// Typing Indicator (3 animated bouncing dots) -// ============================================================================= -class _TypingIndicator extends StatefulWidget { - const _TypingIndicator(); - - @override - State<_TypingIndicator> createState() => _TypingIndicatorState(); -} - -class _TypingIndicatorState extends State<_TypingIndicator> - with SingleTickerProviderStateMixin { - late final AnimationController _controller; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 1200), - )..repeat(); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: UIConstants.spacing12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: AppColors.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.auto_awesome_rounded, - size: 14, - color: AppColors.accent, - ), - ), - const SizedBox(width: UIConstants.spacing8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: UIConstants.spacing16, - vertical: UIConstants.spacing12, - ), - decoration: BoxDecoration( - color: AppColors.surfaceContainer, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16), - bottomLeft: Radius.circular(4), - bottomRight: Radius.circular(16), - ), - border: Border.all(color: AppColors.border, width: 1), - ), - child: AnimatedBuilder( - animation: _controller, - builder: (context, _) { - return Row( - mainAxisSize: MainAxisSize.min, - children: List.generate(3, (index) { - final delay = index * 0.2; - final t = (_controller.value - delay) % 1.0; - final bounce = - t < 0.5 ? math.sin(t * math.pi * 2) * 4.0 : 0.0; - return Padding( - padding: EdgeInsets.only(left: index == 0 ? 0 : 4), - child: Transform.translate( - offset: Offset(0, -bounce.abs()), - child: Container( - width: 7, - height: 7, - decoration: BoxDecoration( - color: AppColors.textMuted, - shape: BoxShape.circle, - ), + if (isError) + TextButton( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: () => aiManager.startServers(), + child: Text( + 'Retry', + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.accent, ), ), - ); - }), - ); - }, + ), + ], + ), ), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + color: AppColors.surfaceContainer, + borderRadius: + BorderRadius.circular(UIConstants.borderRadius), + border: + Border.all(color: AppColors.border, width: 1), + ), + child: TextField( + controller: _inputController, + focusNode: _inputFocusNode, + enabled: isReady, + style: TextStyle( + color: isReady + ? AppColors.textPrimary + : AppColors.textMuted, + fontSize: 14, + ), + maxLines: 4, + minLines: 1, + decoration: InputDecoration( + hintText: isReady + ? 'Type a message...' + : 'Waiting for AI...', + hintStyle: const TextStyle( + color: AppColors.textMuted, + fontSize: 14, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: UIConstants.spacing16, + vertical: UIConstants.spacing12, + ), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + ), + onSubmitted: isReady + ? (_) => _sendMessage(controller) + : null, + textInputAction: TextInputAction.send, + ), + ), + ), + const SizedBox(width: UIConstants.spacing8), + SizedBox( + width: 40, + height: 40, + child: Material( + color: (isTyping || !isReady) + ? AppColors.zinc700 + : AppColors.accent, + borderRadius: + BorderRadius.circular(UIConstants.borderRadius), + child: InkWell( + borderRadius: + BorderRadius.circular(UIConstants.borderRadius), + onTap: (isTyping || !isReady) + ? null + : () => _sendMessage(controller), + child: Icon( + Icons.arrow_upward_rounded, + color: (isTyping || !isReady) + ? AppColors.textMuted + : AppColors.zinc950, + size: 20, + ), + ), + ), + ), + ], ), ], ), diff --git a/lib/presentation/chat/chat_state.dart b/lib/presentation/chat/chat_state.dart index a7c1c00..9ed1c0e 100644 --- a/lib/presentation/chat/chat_state.dart +++ b/lib/presentation/chat/chat_state.dart @@ -4,6 +4,18 @@ import 'package:trainhub_flutter/domain/entities/chat_message.dart'; part 'chat_state.freezed.dart'; +enum ThinkingStepStatus { pending, running, completed, error } + +@freezed +class ThinkingStep with _$ThinkingStep { + const factory ThinkingStep({ + required String id, + required String title, + @Default(ThinkingStepStatus.running) ThinkingStepStatus status, + String? details, + }) = _ThinkingStep; +} + @freezed class ChatState with _$ChatState { const factory ChatState({ @@ -11,5 +23,7 @@ class ChatState with _$ChatState { ChatSessionEntity? activeSession, @Default([]) List messages, @Default(false) bool isTyping, + @Default([]) List thinkingSteps, + String? streamingContent, }) = _ChatState; } diff --git a/lib/presentation/chat/chat_state.freezed.dart b/lib/presentation/chat/chat_state.freezed.dart index 00d402d..2dab695 100644 --- a/lib/presentation/chat/chat_state.freezed.dart +++ b/lib/presentation/chat/chat_state.freezed.dart @@ -15,12 +15,219 @@ final _privateConstructorUsedError = UnsupportedError( 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', ); +/// @nodoc +mixin _$ThinkingStep { + String get id => throw _privateConstructorUsedError; + String get title => throw _privateConstructorUsedError; + ThinkingStepStatus get status => throw _privateConstructorUsedError; + String? get details => throw _privateConstructorUsedError; + + /// Create a copy of ThinkingStep + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $ThinkingStepCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $ThinkingStepCopyWith<$Res> { + factory $ThinkingStepCopyWith( + ThinkingStep value, + $Res Function(ThinkingStep) then, + ) = _$ThinkingStepCopyWithImpl<$Res, ThinkingStep>; + @useResult + $Res call({ + String id, + String title, + ThinkingStepStatus status, + String? details, + }); +} + +/// @nodoc +class _$ThinkingStepCopyWithImpl<$Res, $Val extends ThinkingStep> + implements $ThinkingStepCopyWith<$Res> { + _$ThinkingStepCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of ThinkingStep + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? title = null, + Object? status = null, + Object? details = freezed, + }) { + return _then( + _value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + title: null == title + ? _value.title + : title // ignore: cast_nullable_to_non_nullable + as String, + status: null == status + ? _value.status + : status // ignore: cast_nullable_to_non_nullable + as ThinkingStepStatus, + details: freezed == details + ? _value.details + : details // ignore: cast_nullable_to_non_nullable + as String?, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$ThinkingStepImplCopyWith<$Res> + implements $ThinkingStepCopyWith<$Res> { + factory _$$ThinkingStepImplCopyWith( + _$ThinkingStepImpl value, + $Res Function(_$ThinkingStepImpl) then, + ) = __$$ThinkingStepImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + String id, + String title, + ThinkingStepStatus status, + String? details, + }); +} + +/// @nodoc +class __$$ThinkingStepImplCopyWithImpl<$Res> + extends _$ThinkingStepCopyWithImpl<$Res, _$ThinkingStepImpl> + implements _$$ThinkingStepImplCopyWith<$Res> { + __$$ThinkingStepImplCopyWithImpl( + _$ThinkingStepImpl _value, + $Res Function(_$ThinkingStepImpl) _then, + ) : super(_value, _then); + + /// Create a copy of ThinkingStep + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? title = null, + Object? status = null, + Object? details = freezed, + }) { + return _then( + _$ThinkingStepImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + title: null == title + ? _value.title + : title // ignore: cast_nullable_to_non_nullable + as String, + status: null == status + ? _value.status + : status // ignore: cast_nullable_to_non_nullable + as ThinkingStepStatus, + details: freezed == details + ? _value.details + : details // ignore: cast_nullable_to_non_nullable + as String?, + ), + ); + } +} + +/// @nodoc + +class _$ThinkingStepImpl implements _ThinkingStep { + const _$ThinkingStepImpl({ + required this.id, + required this.title, + this.status = ThinkingStepStatus.running, + this.details, + }); + + @override + final String id; + @override + final String title; + @override + @JsonKey() + final ThinkingStepStatus status; + @override + final String? details; + + @override + String toString() { + return 'ThinkingStep(id: $id, title: $title, status: $status, details: $details)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$ThinkingStepImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.title, title) || other.title == title) && + (identical(other.status, status) || other.status == status) && + (identical(other.details, details) || other.details == details)); + } + + @override + int get hashCode => Object.hash(runtimeType, id, title, status, details); + + /// Create a copy of ThinkingStep + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$ThinkingStepImplCopyWith<_$ThinkingStepImpl> get copyWith => + __$$ThinkingStepImplCopyWithImpl<_$ThinkingStepImpl>(this, _$identity); +} + +abstract class _ThinkingStep implements ThinkingStep { + const factory _ThinkingStep({ + required final String id, + required final String title, + final ThinkingStepStatus status, + final String? details, + }) = _$ThinkingStepImpl; + + @override + String get id; + @override + String get title; + @override + ThinkingStepStatus get status; + @override + String? get details; + + /// Create a copy of ThinkingStep + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$ThinkingStepImplCopyWith<_$ThinkingStepImpl> get copyWith => + throw _privateConstructorUsedError; +} + /// @nodoc mixin _$ChatState { List get sessions => throw _privateConstructorUsedError; ChatSessionEntity? get activeSession => throw _privateConstructorUsedError; List get messages => throw _privateConstructorUsedError; bool get isTyping => throw _privateConstructorUsedError; + List get thinkingSteps => throw _privateConstructorUsedError; + String? get streamingContent => throw _privateConstructorUsedError; /// Create a copy of ChatState /// with the given fields replaced by the non-null parameter values. @@ -39,6 +246,8 @@ abstract class $ChatStateCopyWith<$Res> { ChatSessionEntity? activeSession, List messages, bool isTyping, + List thinkingSteps, + String? streamingContent, }); $ChatSessionEntityCopyWith<$Res>? get activeSession; @@ -63,6 +272,8 @@ class _$ChatStateCopyWithImpl<$Res, $Val extends ChatState> Object? activeSession = freezed, Object? messages = null, Object? isTyping = null, + Object? thinkingSteps = null, + Object? streamingContent = freezed, }) { return _then( _value.copyWith( @@ -82,6 +293,14 @@ class _$ChatStateCopyWithImpl<$Res, $Val extends ChatState> ? _value.isTyping : isTyping // ignore: cast_nullable_to_non_nullable as bool, + thinkingSteps: null == thinkingSteps + ? _value.thinkingSteps + : thinkingSteps // ignore: cast_nullable_to_non_nullable + as List, + streamingContent: freezed == streamingContent + ? _value.streamingContent + : streamingContent // ignore: cast_nullable_to_non_nullable + as String?, ) as $Val, ); @@ -116,6 +335,8 @@ abstract class _$$ChatStateImplCopyWith<$Res> ChatSessionEntity? activeSession, List messages, bool isTyping, + List thinkingSteps, + String? streamingContent, }); @override @@ -140,6 +361,8 @@ class __$$ChatStateImplCopyWithImpl<$Res> Object? activeSession = freezed, Object? messages = null, Object? isTyping = null, + Object? thinkingSteps = null, + Object? streamingContent = freezed, }) { return _then( _$ChatStateImpl( @@ -159,6 +382,14 @@ class __$$ChatStateImplCopyWithImpl<$Res> ? _value.isTyping : isTyping // ignore: cast_nullable_to_non_nullable as bool, + thinkingSteps: null == thinkingSteps + ? _value._thinkingSteps + : thinkingSteps // ignore: cast_nullable_to_non_nullable + as List, + streamingContent: freezed == streamingContent + ? _value.streamingContent + : streamingContent // ignore: cast_nullable_to_non_nullable + as String?, ), ); } @@ -172,8 +403,11 @@ class _$ChatStateImpl implements _ChatState { this.activeSession, final List messages = const [], this.isTyping = false, + final List thinkingSteps = const [], + this.streamingContent, }) : _sessions = sessions, - _messages = messages; + _messages = messages, + _thinkingSteps = thinkingSteps; final List _sessions; @override @@ -198,10 +432,21 @@ class _$ChatStateImpl implements _ChatState { @override @JsonKey() final bool isTyping; + final List _thinkingSteps; + @override + @JsonKey() + List get thinkingSteps { + if (_thinkingSteps is EqualUnmodifiableListView) return _thinkingSteps; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_thinkingSteps); + } + + @override + final String? streamingContent; @override String toString() { - return 'ChatState(sessions: $sessions, activeSession: $activeSession, messages: $messages, isTyping: $isTyping)'; + return 'ChatState(sessions: $sessions, activeSession: $activeSession, messages: $messages, isTyping: $isTyping, thinkingSteps: $thinkingSteps, streamingContent: $streamingContent)'; } @override @@ -214,7 +459,13 @@ class _$ChatStateImpl implements _ChatState { other.activeSession == activeSession) && const DeepCollectionEquality().equals(other._messages, _messages) && (identical(other.isTyping, isTyping) || - other.isTyping == isTyping)); + other.isTyping == isTyping) && + const DeepCollectionEquality().equals( + other._thinkingSteps, + _thinkingSteps, + ) && + (identical(other.streamingContent, streamingContent) || + other.streamingContent == streamingContent)); } @override @@ -224,6 +475,8 @@ class _$ChatStateImpl implements _ChatState { activeSession, const DeepCollectionEquality().hash(_messages), isTyping, + const DeepCollectionEquality().hash(_thinkingSteps), + streamingContent, ); /// Create a copy of ChatState @@ -241,6 +494,8 @@ abstract class _ChatState implements ChatState { final ChatSessionEntity? activeSession, final List messages, final bool isTyping, + final List thinkingSteps, + final String? streamingContent, }) = _$ChatStateImpl; @override @@ -251,6 +506,10 @@ abstract class _ChatState implements ChatState { List get messages; @override bool get isTyping; + @override + List get thinkingSteps; + @override + String? get streamingContent; /// Create a copy of ChatState /// with the given fields replaced by the non-null parameter values. diff --git a/lib/presentation/chat/widgets/message_bubble.dart b/lib/presentation/chat/widgets/message_bubble.dart new file mode 100644 index 0000000..76a8daa --- /dev/null +++ b/lib/presentation/chat/widgets/message_bubble.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:trainhub_flutter/core/constants/ui_constants.dart'; +import 'package:trainhub_flutter/core/theme/app_colors.dart'; +import 'package:trainhub_flutter/domain/entities/chat_message.dart'; + +class MessageBubble extends StatelessWidget { + const MessageBubble({ + super.key, + required this.message, + required this.formattedTime, + }); + + final ChatMessageEntity message; + final String formattedTime; + + @override + Widget build(BuildContext context) { + final isUser = message.isUser; + final maxWidth = MediaQuery.of(context).size.width * 0.55; + return Padding( + padding: const EdgeInsets.only(bottom: UIConstants.spacing12), + child: Row( + mainAxisAlignment: + isUser ? MainAxisAlignment.end : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isUser) ...[ + _buildAvatar( + Icons.auto_awesome_rounded, + AppColors.surfaceContainerHigh, + ), + const SizedBox(width: UIConstants.spacing8), + ], + Flexible( + child: Column( + crossAxisAlignment: + isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + Container( + constraints: BoxConstraints(maxWidth: maxWidth), + padding: const EdgeInsets.symmetric( + horizontal: UIConstants.spacing16, + vertical: UIConstants.spacing12, + ), + decoration: BoxDecoration( + color: isUser + ? AppColors.zinc700 + : AppColors.surfaceContainer, + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(16), + topRight: const Radius.circular(16), + bottomLeft: isUser + ? const Radius.circular(16) + : const Radius.circular(4), + bottomRight: isUser + ? const Radius.circular(4) + : const Radius.circular(16), + ), + border: isUser + ? null + : Border.all(color: AppColors.border, width: 1), + ), + child: isUser + ? SelectableText( + message.content, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + height: 1.5, + ), + ) + : MarkdownBody( + data: message.content, + styleSheet: MarkdownStyleSheet( + p: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + height: 1.5, + ), + ), + ), + ), + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Text( + formattedTime, + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 11, + ), + ), + ), + ], + ), + ), + if (isUser) ...[ + const SizedBox(width: UIConstants.spacing8), + _buildAvatar( + Icons.person_rounded, + AppColors.accent.withValues(alpha: 0.15), + ), + ], + ], + ), + ); + } + + Widget _buildAvatar(IconData icon, Color backgroundColor) { + return Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, size: 14, color: AppColors.accent), + ); + } +} diff --git a/lib/presentation/chat/widgets/missing_models_state.dart b/lib/presentation/chat/widgets/missing_models_state.dart new file mode 100644 index 0000000..a3091c1 --- /dev/null +++ b/lib/presentation/chat/widgets/missing_models_state.dart @@ -0,0 +1,115 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:trainhub_flutter/core/constants/ui_constants.dart'; +import 'package:trainhub_flutter/core/router/app_router.dart'; +import 'package:trainhub_flutter/core/theme/app_colors.dart'; + +class MissingModelsState extends StatelessWidget { + const MissingModelsState({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: AppColors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(18), + ), + child: const Icon( + Icons.cloud_download_outlined, + size: 36, + color: AppColors.textMuted, + ), + ), + const SizedBox(height: UIConstants.spacing24), + Text( + 'AI models are missing.', + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + letterSpacing: -0.2, + ), + ), + const SizedBox(height: UIConstants.spacing8), + Text( + 'Please download them to use the AI chat.', + style: GoogleFonts.inter( + fontSize: 14, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: UIConstants.spacing32), + const _GoToSettingsButton(), + ], + ), + ); + } +} + +class _GoToSettingsButton extends StatefulWidget { + const _GoToSettingsButton(); + + @override + State<_GoToSettingsButton> createState() => _GoToSettingsButtonState(); +} + +class _GoToSettingsButtonState extends State<_GoToSettingsButton> { + bool _isHovered = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + child: AnimatedContainer( + duration: UIConstants.animationDuration, + height: 44, + decoration: BoxDecoration( + color: _isHovered + ? AppColors.accent.withValues(alpha: 0.85) + : AppColors.accent, + borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: + BorderRadius.circular(UIConstants.smallBorderRadius), + onTap: () => context.router.push(const SettingsRoute()), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: UIConstants.spacing24, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.settings_outlined, + size: 16, + color: AppColors.zinc950, + ), + const SizedBox(width: UIConstants.spacing8), + Text( + 'Go to Settings', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.zinc950, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/presentation/chat/widgets/new_chat_button.dart b/lib/presentation/chat/widgets/new_chat_button.dart new file mode 100644 index 0000000..fd17db8 --- /dev/null +++ b/lib/presentation/chat/widgets/new_chat_button.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:trainhub_flutter/core/constants/ui_constants.dart'; +import 'package:trainhub_flutter/core/theme/app_colors.dart'; + +class NewChatButton extends StatefulWidget { + const NewChatButton({super.key, required this.onPressed}); + + final VoidCallback onPressed; + + @override + State createState() => _NewChatButtonState(); +} + +class _NewChatButtonState extends State { + bool _isHovered = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + decoration: BoxDecoration( + color: _isHovered + ? AppColors.zinc700 + : AppColors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), + border: Border.all(color: AppColors.border, width: 1), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: + BorderRadius.circular(UIConstants.smallBorderRadius), + onTap: widget.onPressed, + child: const Padding( + padding: EdgeInsets.symmetric( + horizontal: UIConstants.spacing12, + vertical: UIConstants.spacing8, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.add_rounded, + size: 16, + color: AppColors.textSecondary, + ), + SizedBox(width: UIConstants.spacing8), + Text( + 'New Chat', + style: TextStyle( + color: AppColors.textSecondary, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/presentation/chat/widgets/thinking_block.dart b/lib/presentation/chat/widgets/thinking_block.dart new file mode 100644 index 0000000..fe11f64 --- /dev/null +++ b/lib/presentation/chat/widgets/thinking_block.dart @@ -0,0 +1,180 @@ +import 'package:flutter/material.dart'; +import 'package:trainhub_flutter/core/constants/ui_constants.dart'; +import 'package:trainhub_flutter/core/theme/app_colors.dart'; +import 'package:trainhub_flutter/presentation/chat/chat_state.dart'; + +class ThinkingBlock extends StatefulWidget { + const ThinkingBlock({super.key, required this.steps}); + + final List steps; + + @override + State createState() => _ThinkingBlockState(); +} + +class _ThinkingBlockState extends State { + bool _isExpanded = true; + + Color _getStatusColor(ThinkingStepStatus status) { + switch (status) { + case ThinkingStepStatus.running: + return AppColors.info; + case ThinkingStepStatus.completed: + return AppColors.success; + case ThinkingStepStatus.error: + return AppColors.destructive; + case ThinkingStepStatus.pending: + return AppColors.textMuted; + } + } + + Widget _buildStatusIcon(ThinkingStepStatus status) { + if (status == ThinkingStepStatus.running) { + return const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.info, + ), + ); + } + final IconData icon; + switch (status) { + case ThinkingStepStatus.completed: + icon = Icons.check_circle_rounded; + case ThinkingStepStatus.error: + icon = Icons.error_outline_rounded; + default: + icon = Icons.circle_outlined; + } + return Icon(icon, size: 14, color: _getStatusColor(status)); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceContainer, + borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), + border: Border.all(color: AppColors.border, width: 1), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Material( + color: Colors.transparent, + child: InkWell( + borderRadius: + BorderRadius.circular(UIConstants.smallBorderRadius), + onTap: () => setState(() => _isExpanded = !_isExpanded), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + const Icon( + Icons.settings_suggest_rounded, + size: 16, + color: AppColors.warning, + ), + const SizedBox(width: 8), + const Expanded( + child: Text( + 'Assistant actions', + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + Text( + '(${widget.steps.length} steps)', + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 11, + ), + ), + const SizedBox(width: 8), + Icon( + _isExpanded + ? Icons.keyboard_arrow_up + : Icons.keyboard_arrow_down, + size: 16, + color: AppColors.textMuted, + ), + ], + ), + ), + ), + ), + if (_isExpanded) + Container( + decoration: const BoxDecoration( + border: Border( + top: BorderSide(color: AppColors.border, width: 1), + ), + ), + padding: const EdgeInsets.all(12), + child: Column( + children: widget.steps.map(_buildStepRow).toList(), + ), + ), + ], + ), + ); + } + + Widget _buildStepRow(ThinkingStep step) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: _buildStatusIcon(step.status), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + step.title, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + if (step.details != null && step.details!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(4), + border: + Border.all(color: AppColors.border, width: 1), + ), + child: Text( + step.details!, + style: const TextStyle( + fontFamily: 'monospace', + color: AppColors.textSecondary, + fontSize: 11, + ), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/presentation/chat/widgets/typing_bubble.dart b/lib/presentation/chat/widgets/typing_bubble.dart new file mode 100644 index 0000000..a10d0eb --- /dev/null +++ b/lib/presentation/chat/widgets/typing_bubble.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:trainhub_flutter/core/constants/ui_constants.dart'; +import 'package:trainhub_flutter/core/theme/app_colors.dart'; +import 'package:trainhub_flutter/presentation/chat/chat_state.dart'; +import 'package:trainhub_flutter/presentation/chat/widgets/thinking_block.dart'; +import 'package:trainhub_flutter/presentation/chat/widgets/typing_indicator.dart'; + +class TypingBubble extends StatelessWidget { + const TypingBubble({ + super.key, + required this.thinkingSteps, + this.streamingContent, + }); + + final List thinkingSteps; + final String? streamingContent; + + @override + Widget build(BuildContext context) { + final maxWidth = MediaQuery.of(context).size.width * 0.55; + return Padding( + padding: const EdgeInsets.only(bottom: UIConstants.spacing12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: AppColors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.auto_awesome_rounded, + size: 14, + color: AppColors.accent, + ), + ), + const SizedBox(width: UIConstants.spacing8), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (thinkingSteps.isNotEmpty) + Container( + constraints: BoxConstraints(maxWidth: maxWidth), + margin: const EdgeInsets.only(bottom: 8), + child: ThinkingBlock(steps: thinkingSteps), + ), + if (streamingContent != null && streamingContent!.isNotEmpty) + Container( + constraints: BoxConstraints(maxWidth: maxWidth), + padding: const EdgeInsets.symmetric( + horizontal: UIConstants.spacing16, + vertical: UIConstants.spacing12, + ), + decoration: BoxDecoration( + color: AppColors.surfaceContainer, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + bottomLeft: Radius.circular(4), + bottomRight: Radius.circular(16), + ), + border: + Border.all(color: AppColors.border, width: 1), + ), + child: MarkdownBody( + data: streamingContent!, + styleSheet: MarkdownStyleSheet( + p: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + height: 1.5, + ), + ), + ), + ) + else + const TypingIndicator(), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/presentation/chat/widgets/typing_indicator.dart b/lib/presentation/chat/widgets/typing_indicator.dart new file mode 100644 index 0000000..1089591 --- /dev/null +++ b/lib/presentation/chat/widgets/typing_indicator.dart @@ -0,0 +1,111 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:trainhub_flutter/core/constants/ui_constants.dart'; +import 'package:trainhub_flutter/core/theme/app_colors.dart'; + +class TypingIndicator extends StatefulWidget { + const TypingIndicator({super.key}); + + @override + State createState() => _TypingIndicatorState(); +} + +class _TypingIndicatorState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: UIConstants.spacing12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: AppColors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.auto_awesome_rounded, + size: 14, + color: AppColors.accent, + ), + ), + const SizedBox(width: UIConstants.spacing8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: UIConstants.spacing16, + vertical: UIConstants.spacing12, + ), + decoration: BoxDecoration( + color: AppColors.surfaceContainer, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + bottomLeft: Radius.circular(4), + bottomRight: Radius.circular(16), + ), + border: Border.all(color: AppColors.border, width: 1), + ), + child: AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(3, (index) { + final delay = index * 0.2; + final t = (_controller.value - delay) % 1.0; + final bounce = + t < 0.5 ? math.sin(t * math.pi * 2) * 4.0 : 0.0; + return Padding( + padding: EdgeInsets.only(left: index == 0 ? 0 : 4), + child: Transform.translate( + offset: Offset(0, -bounce.abs()), + child: const _Dot(), + ), + ); + }), + ); + }, + ), + ), + ], + ), + ); + } +} + +class _Dot extends StatelessWidget { + const _Dot(); + + @override + Widget build(BuildContext context) { + return Container( + width: 7, + height: 7, + decoration: const BoxDecoration( + color: AppColors.textMuted, + shape: BoxShape.circle, + ), + ); + } +} diff --git a/lib/presentation/home/home_page.dart b/lib/presentation/home/home_page.dart index 773bbb3..fd2c650 100644 --- a/lib/presentation/home/home_page.dart +++ b/lib/presentation/home/home_page.dart @@ -4,11 +4,14 @@ 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/program_workout.dart'; import 'package:trainhub_flutter/presentation/common/widgets/app_empty_state.dart'; -import 'package:trainhub_flutter/presentation/common/widgets/app_stat_card.dart'; import 'package:trainhub_flutter/presentation/home/home_controller.dart'; import 'package:trainhub_flutter/presentation/home/home_state.dart'; +import 'package:trainhub_flutter/presentation/home/widgets/next_workout_banner.dart'; +import 'package:trainhub_flutter/presentation/home/widgets/quick_actions_row.dart'; +import 'package:trainhub_flutter/presentation/home/widgets/recent_activity_section.dart'; +import 'package:trainhub_flutter/presentation/home/widgets/stat_cards_row.dart'; +import 'package:trainhub_flutter/presentation/home/widgets/welcome_header.dart'; @RoutePage() class HomePage extends ConsumerWidget { @@ -20,45 +23,9 @@ class HomePage extends ConsumerWidget { return asyncState.when( loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center( - child: Padding( - padding: const EdgeInsets.all(UIConstants.pagePadding), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.error_outline, - size: 48, - color: AppColors.destructive, - ), - const SizedBox(height: UIConstants.spacing16), - Text( - 'Something went wrong', - style: GoogleFonts.inter( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: UIConstants.spacing8), - Text( - '$e', - style: GoogleFonts.inter( - fontSize: 13, - color: AppColors.textMuted, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: UIConstants.spacing24), - FilledButton.icon( - onPressed: () => - ref.read(homeControllerProvider.notifier).refresh(), - icon: const Icon(Icons.refresh, size: 18), - label: const Text('Retry'), - ), - ], - ), - ), + error: (e, _) => _ErrorView( + error: e, + onRetry: () => ref.read(homeControllerProvider.notifier).refresh(), ), data: (data) { if (data.activeProgramName == null) { @@ -68,9 +35,7 @@ class HomePage extends ConsumerWidget { subtitle: 'Head to Calendar to create or select a training program to get started.', actionLabel: 'Go to Calendar', - onAction: () { - AutoTabsRouter.of(context).setActiveIndex(3); - }, + onAction: () => AutoTabsRouter.of(context).setActiveIndex(3), ); } return _HomeContent(data: data); @@ -79,11 +44,61 @@ class HomePage extends ConsumerWidget { } } -class _HomeContent extends StatelessWidget { - final HomeState data; +class _ErrorView extends StatelessWidget { + const _ErrorView({required this.error, required this.onRetry}); + final Object error; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(UIConstants.pagePadding), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.error_outline, + size: 48, + color: AppColors.destructive, + ), + const SizedBox(height: UIConstants.spacing16), + Text( + 'Something went wrong', + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: UIConstants.spacing8), + Text( + '$error', + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textMuted, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: UIConstants.spacing24), + FilledButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh, size: 18), + label: const Text('Retry'), + ), + ], + ), + ), + ); + } +} + +class _HomeContent extends StatelessWidget { const _HomeContent({required this.data}); + final HomeState data; + @override Widget build(BuildContext context) { return SingleChildScrollView( @@ -91,609 +106,22 @@ class _HomeContent extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // -- Welcome header -- - _WelcomeHeader(programName: data.activeProgramName!), + WelcomeHeader(programName: data.activeProgramName!), const SizedBox(height: UIConstants.spacing24), - - // -- Stat cards row -- - _StatCardsRow( + StatCardsRow( completed: data.completedWorkouts, total: data.totalWorkouts, ), const SizedBox(height: UIConstants.spacing24), - - // -- Next workout banner -- if (data.nextWorkoutName != null) ...[ - _NextWorkoutBanner(workoutName: data.nextWorkoutName!), + NextWorkoutBanner(workoutName: data.nextWorkoutName!), const SizedBox(height: UIConstants.spacing24), ], - - // -- Quick actions -- - _QuickActionsRow(), + const QuickActionsRow(), const SizedBox(height: UIConstants.spacing32), - - // -- Recent activity -- - _RecentActivitySection(activity: data.recentActivity), + RecentActivitySection(activity: data.recentActivity), ], ), ); } } - -// --------------------------------------------------------------------------- -// Welcome header -// --------------------------------------------------------------------------- -class _WelcomeHeader extends StatelessWidget { - final String programName; - - const _WelcomeHeader({required this.programName}); - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Welcome back', - style: GoogleFonts.inter( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppColors.textMuted, - ), - ), - const SizedBox(height: UIConstants.spacing4), - Row( - children: [ - Expanded( - child: Text( - programName, - style: GoogleFonts.inter( - fontSize: 28, - fontWeight: FontWeight.w700, - color: AppColors.textPrimary, - ), - ), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: UIConstants.spacing12, - vertical: 6, - ), - decoration: BoxDecoration( - color: AppColors.accentMuted, - borderRadius: UIConstants.smallCardBorderRadius, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.fitness_center, - size: 14, - color: AppColors.accent, - ), - const SizedBox(width: 6), - Text( - 'Active Program', - style: GoogleFonts.inter( - fontSize: 12, - fontWeight: FontWeight.w600, - color: AppColors.accent, - ), - ), - ], - ), - ), - ], - ), - ], - ); - } -} - -// --------------------------------------------------------------------------- -// Stat cards row -// --------------------------------------------------------------------------- -class _StatCardsRow extends StatelessWidget { - final int completed; - final int total; - - const _StatCardsRow({required this.completed, required this.total}); - - @override - Widget build(BuildContext context) { - final progress = total == 0 ? 0 : (completed / total * 100).round(); - - return Row( - children: [ - Expanded( - child: AppStatCard( - title: 'Completed', - value: '$completed', - icon: Icons.check_circle_outline, - accentColor: AppColors.success, - ), - ), - const SizedBox(width: UIConstants.spacing16), - Expanded( - child: AppStatCard( - title: 'Total Workouts', - value: '$total', - icon: Icons.list_alt, - accentColor: AppColors.info, - ), - ), - const SizedBox(width: UIConstants.spacing16), - Expanded( - child: AppStatCard( - title: 'Progress', - value: '$progress%', - icon: Icons.trending_up, - accentColor: AppColors.purple, - ), - ), - ], - ); - } -} - -// --------------------------------------------------------------------------- -// Next workout banner -// --------------------------------------------------------------------------- -class _NextWorkoutBanner extends StatelessWidget { - final String workoutName; - - const _NextWorkoutBanner({required this.workoutName}); - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(UIConstants.cardPadding), - decoration: BoxDecoration( - color: AppColors.surfaceContainer, - borderRadius: UIConstants.cardBorderRadius, - border: Border.all(color: AppColors.border), - ), - child: Row( - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: AppColors.accentMuted, - borderRadius: UIConstants.smallCardBorderRadius, - ), - child: const Icon( - Icons.play_arrow_rounded, - color: AppColors.accent, - size: 22, - ), - ), - const SizedBox(width: UIConstants.spacing12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Up Next', - style: GoogleFonts.inter( - fontSize: 12, - fontWeight: FontWeight.w500, - color: AppColors.textMuted, - ), - ), - const SizedBox(height: 2), - Text( - workoutName, - style: GoogleFonts.inter( - fontSize: 15, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary, - ), - ), - ], - ), - ), - Icon( - Icons.chevron_right, - color: AppColors.textMuted, - size: 20, - ), - ], - ), - ); - } -} - -// --------------------------------------------------------------------------- -// Quick actions row -// --------------------------------------------------------------------------- -class _QuickActionsRow extends StatelessWidget { - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Quick Actions', - style: GoogleFonts.inter( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: UIConstants.spacing12), - Row( - children: [ - _QuickActionButton( - icon: Icons.play_arrow_rounded, - label: 'New Workout', - color: AppColors.accent, - onTap: () { - AutoTabsRouter.of(context).setActiveIndex(1); - }, - ), - const SizedBox(width: UIConstants.spacing12), - _QuickActionButton( - icon: Icons.description_outlined, - label: 'View Plans', - color: AppColors.info, - onTap: () { - AutoTabsRouter.of(context).setActiveIndex(1); - }, - ), - const SizedBox(width: UIConstants.spacing12), - _QuickActionButton( - icon: Icons.chat_bubble_outline, - label: 'AI Chat', - color: AppColors.purple, - onTap: () { - AutoTabsRouter.of(context).setActiveIndex(4); - }, - ), - ], - ), - ], - ); - } -} - -class _QuickActionButton extends StatefulWidget { - final IconData icon; - final String label; - final Color color; - final VoidCallback onTap; - - const _QuickActionButton({ - required this.icon, - required this.label, - required this.color, - required this.onTap, - }); - - @override - State<_QuickActionButton> createState() => _QuickActionButtonState(); -} - -class _QuickActionButtonState extends State<_QuickActionButton> { - bool _isHovered = false; - - @override - Widget build(BuildContext context) { - return MouseRegion( - onEnter: (_) => setState(() => _isHovered = true), - onExit: (_) => setState(() => _isHovered = false), - child: AnimatedContainer( - duration: UIConstants.animationDuration, - decoration: BoxDecoration( - color: _isHovered - ? widget.color.withValues(alpha: 0.08) - : Colors.transparent, - borderRadius: UIConstants.smallCardBorderRadius, - border: Border.all( - color: _isHovered ? widget.color.withValues(alpha: 0.4) : AppColors.border, - ), - ), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: widget.onTap, - borderRadius: UIConstants.smallCardBorderRadius, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: UIConstants.spacing24, - vertical: UIConstants.spacing12, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - widget.icon, - size: 18, - color: widget.color, - ), - const SizedBox(width: UIConstants.spacing8), - Text( - widget.label, - style: GoogleFonts.inter( - fontSize: 13, - fontWeight: FontWeight.w500, - color: _isHovered - ? widget.color - : AppColors.textSecondary, - ), - ), - ], - ), - ), - ), - ), - ), - ); - } -} - -// --------------------------------------------------------------------------- -// Recent activity section -// --------------------------------------------------------------------------- -class _RecentActivitySection extends StatelessWidget { - final List activity; - - const _RecentActivitySection({required this.activity}); - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - 'Recent Activity', - style: GoogleFonts.inter( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary, - ), - ), - ), - if (activity.isNotEmpty) - Text( - '${activity.length} workout${activity.length == 1 ? '' : 's'}', - style: GoogleFonts.inter( - fontSize: 12, - color: AppColors.textMuted, - ), - ), - ], - ), - const SizedBox(height: UIConstants.spacing12), - if (activity.isEmpty) - _EmptyActivity() - else - _ActivityList(activity: activity), - ], - ); - } -} - -class _EmptyActivity extends StatelessWidget { - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - vertical: 40, - horizontal: UIConstants.spacing24, - ), - decoration: BoxDecoration( - color: AppColors.surfaceContainer, - borderRadius: UIConstants.cardBorderRadius, - border: Border.all(color: AppColors.border), - ), - child: Column( - children: [ - Icon( - Icons.history, - size: 32, - color: AppColors.textMuted, - ), - const SizedBox(height: UIConstants.spacing12), - Text( - 'No completed workouts yet', - style: GoogleFonts.inter( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppColors.textSecondary, - ), - ), - const SizedBox(height: UIConstants.spacing4), - Text( - 'Your recent workout history will appear here.', - style: GoogleFonts.inter( - fontSize: 13, - color: AppColors.textMuted, - ), - ), - ], - ), - ); - } -} - -class _ActivityList extends StatelessWidget { - final List activity; - - const _ActivityList({required this.activity}); - - @override - Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: AppColors.surfaceContainer, - borderRadius: UIConstants.cardBorderRadius, - border: Border.all(color: AppColors.border), - ), - child: ClipRRect( - borderRadius: UIConstants.cardBorderRadius, - child: Column( - children: [ - for (int i = 0; i < activity.length; i++) ...[ - if (i > 0) - const Divider( - height: 1, - thickness: 1, - color: AppColors.border, - ), - _ActivityItem(workout: activity[i]), - ], - ], - ), - ), - ); - } -} - -class _ActivityItem extends StatefulWidget { - final ProgramWorkoutEntity workout; - - const _ActivityItem({required this.workout}); - - @override - State<_ActivityItem> createState() => _ActivityItemState(); -} - -class _ActivityItemState extends State<_ActivityItem> { - bool _isHovered = false; - - Color get _typeColor { - switch (widget.workout.type.toLowerCase()) { - case 'strength': - return AppColors.accent; - case 'cardio': - return AppColors.info; - case 'flexibility': - case 'mobility': - return AppColors.purple; - case 'rest': - return AppColors.textMuted; - default: - return AppColors.success; - } - } - - IconData get _typeIcon { - switch (widget.workout.type.toLowerCase()) { - case 'strength': - return Icons.fitness_center; - case 'cardio': - return Icons.directions_run; - case 'flexibility': - case 'mobility': - return Icons.self_improvement; - case 'rest': - return Icons.bedtime_outlined; - default: - return Icons.check_circle; - } - } - - @override - Widget build(BuildContext context) { - return MouseRegion( - onEnter: (_) => setState(() => _isHovered = true), - onExit: (_) => setState(() => _isHovered = false), - child: AnimatedContainer( - duration: UIConstants.animationDuration, - color: _isHovered - ? AppColors.surfaceContainerHigh.withValues(alpha: 0.5) - : Colors.transparent, - padding: const EdgeInsets.symmetric( - horizontal: UIConstants.cardPadding, - vertical: UIConstants.spacing12, - ), - child: Row( - children: [ - // Leading icon with color coding - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: widget.workout.completed - ? _typeColor.withValues(alpha: 0.15) - : AppColors.zinc800, - borderRadius: UIConstants.smallCardBorderRadius, - ), - child: Icon( - widget.workout.completed ? _typeIcon : Icons.circle_outlined, - size: 18, - color: widget.workout.completed - ? _typeColor - : AppColors.textMuted, - ), - ), - const SizedBox(width: UIConstants.spacing12), - - // Workout info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.workout.name ?? 'Workout', - style: GoogleFonts.inter( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 2), - Text( - 'Week ${widget.workout.weekId} · Day ${widget.workout.day}', - style: GoogleFonts.inter( - fontSize: 12, - color: AppColors.textMuted, - ), - ), - ], - ), - ), - - // Type badge - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: _typeColor.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(6), - ), - child: Text( - widget.workout.type, - style: GoogleFonts.inter( - fontSize: 11, - fontWeight: FontWeight.w500, - color: _typeColor, - ), - ), - ), - const SizedBox(width: UIConstants.spacing12), - - // Status indicator - if (widget.workout.completed) - const Icon( - Icons.check_circle, - size: 18, - color: AppColors.success, - ) - else - const Icon( - Icons.radio_button_unchecked, - size: 18, - color: AppColors.textMuted, - ), - ], - ), - ), - ); - } -} diff --git a/lib/presentation/home/widgets/next_workout_banner.dart b/lib/presentation/home/widgets/next_workout_banner.dart new file mode 100644 index 0000000..f669e40 --- /dev/null +++ b/lib/presentation/home/widgets/next_workout_banner.dart @@ -0,0 +1,70 @@ +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'; + +class NextWorkoutBanner extends StatelessWidget { + const NextWorkoutBanner({super.key, required this.workoutName}); + + final String workoutName; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(UIConstants.cardPadding), + decoration: BoxDecoration( + color: AppColors.surfaceContainer, + borderRadius: UIConstants.cardBorderRadius, + border: Border.all(color: AppColors.border), + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppColors.accentMuted, + borderRadius: UIConstants.smallCardBorderRadius, + ), + child: const Icon( + Icons.play_arrow_rounded, + color: AppColors.accent, + size: 22, + ), + ), + const SizedBox(width: UIConstants.spacing12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Up Next', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppColors.textMuted, + ), + ), + const SizedBox(height: 2), + Text( + workoutName, + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ], + ), + ), + const Icon( + Icons.chevron_right, + color: AppColors.textMuted, + size: 20, + ), + ], + ), + ); + } +} diff --git a/lib/presentation/home/widgets/quick_actions_row.dart b/lib/presentation/home/widgets/quick_actions_row.dart new file mode 100644 index 0000000..0a09c4e --- /dev/null +++ b/lib/presentation/home/widgets/quick_actions_row.dart @@ -0,0 +1,124 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:trainhub_flutter/core/constants/ui_constants.dart'; +import 'package:trainhub_flutter/core/theme/app_colors.dart'; + +class QuickActionsRow extends StatelessWidget { + const QuickActionsRow({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Quick Actions', + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: UIConstants.spacing12), + Row( + children: [ + QuickActionButton( + icon: Icons.play_arrow_rounded, + label: 'New Workout', + color: AppColors.accent, + onTap: () => AutoTabsRouter.of(context).setActiveIndex(1), + ), + const SizedBox(width: UIConstants.spacing12), + QuickActionButton( + icon: Icons.description_outlined, + label: 'View Plans', + color: AppColors.info, + onTap: () => AutoTabsRouter.of(context).setActiveIndex(1), + ), + const SizedBox(width: UIConstants.spacing12), + QuickActionButton( + icon: Icons.chat_bubble_outline, + label: 'AI Chat', + color: AppColors.purple, + onTap: () => AutoTabsRouter.of(context).setActiveIndex(4), + ), + ], + ), + ], + ); + } +} + +class QuickActionButton extends StatefulWidget { + const QuickActionButton({ + super.key, + required this.icon, + required this.label, + required this.color, + required this.onTap, + }); + + final IconData icon; + final String label; + final Color color; + final VoidCallback onTap; + + @override + State createState() => _QuickActionButtonState(); +} + +class _QuickActionButtonState extends State { + bool _isHovered = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + child: AnimatedContainer( + duration: UIConstants.animationDuration, + decoration: BoxDecoration( + color: _isHovered + ? widget.color.withValues(alpha: 0.08) + : Colors.transparent, + borderRadius: UIConstants.smallCardBorderRadius, + border: Border.all( + color: _isHovered + ? widget.color.withValues(alpha: 0.4) + : AppColors.border, + ), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: widget.onTap, + borderRadius: UIConstants.smallCardBorderRadius, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: UIConstants.spacing24, + vertical: UIConstants.spacing12, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(widget.icon, size: 18, color: widget.color), + const SizedBox(width: UIConstants.spacing8), + Text( + widget.label, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w500, + color: + _isHovered ? widget.color : AppColors.textSecondary, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/presentation/home/widgets/recent_activity_section.dart b/lib/presentation/home/widgets/recent_activity_section.dart new file mode 100644 index 0000000..e0f5b27 --- /dev/null +++ b/lib/presentation/home/widgets/recent_activity_section.dart @@ -0,0 +1,272 @@ +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/domain/entities/program_workout.dart'; + +class RecentActivitySection extends StatelessWidget { + const RecentActivitySection({super.key, required this.activity}); + + final List activity; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Recent Activity', + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ), + if (activity.isNotEmpty) + Text( + '${activity.length} workout${activity.length == 1 ? '' : 's'}', + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textMuted, + ), + ), + ], + ), + const SizedBox(height: UIConstants.spacing12), + if (activity.isEmpty) + const _EmptyActivity() + else + _ActivityList(activity: activity), + ], + ); + } +} + +class _EmptyActivity extends StatelessWidget { + const _EmptyActivity(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + vertical: 40, + horizontal: UIConstants.spacing24, + ), + decoration: BoxDecoration( + color: AppColors.surfaceContainer, + borderRadius: UIConstants.cardBorderRadius, + border: Border.all(color: AppColors.border), + ), + child: Column( + children: [ + const Icon(Icons.history, size: 32, color: AppColors.textMuted), + const SizedBox(height: UIConstants.spacing12), + Text( + 'No completed workouts yet', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: UIConstants.spacing4), + Text( + 'Your recent workout history will appear here.', + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textMuted, + ), + ), + ], + ), + ); + } +} + +class _ActivityList extends StatelessWidget { + const _ActivityList({required this.activity}); + + final List activity; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceContainer, + borderRadius: UIConstants.cardBorderRadius, + border: Border.all(color: AppColors.border), + ), + child: ClipRRect( + borderRadius: UIConstants.cardBorderRadius, + child: Column( + children: [ + for (int i = 0; i < activity.length; i++) ...[ + if (i > 0) + const Divider( + height: 1, + thickness: 1, + color: AppColors.border, + ), + _ActivityItem(workout: activity[i]), + ], + ], + ), + ), + ); + } +} + +class _ActivityItem extends StatefulWidget { + const _ActivityItem({required this.workout}); + + final ProgramWorkoutEntity workout; + + @override + State<_ActivityItem> createState() => _ActivityItemState(); +} + +class _ActivityItemState extends State<_ActivityItem> { + bool _isHovered = false; + + Color get _typeColor { + switch (widget.workout.type.toLowerCase()) { + case 'strength': + return AppColors.accent; + case 'cardio': + return AppColors.info; + case 'flexibility': + case 'mobility': + return AppColors.purple; + case 'rest': + return AppColors.textMuted; + default: + return AppColors.success; + } + } + + IconData get _typeIcon { + switch (widget.workout.type.toLowerCase()) { + case 'strength': + return Icons.fitness_center; + case 'cardio': + return Icons.directions_run; + case 'flexibility': + case 'mobility': + return Icons.self_improvement; + case 'rest': + return Icons.bedtime_outlined; + default: + return Icons.check_circle; + } + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + child: AnimatedContainer( + duration: UIConstants.animationDuration, + color: _isHovered + ? AppColors.surfaceContainerHigh.withValues(alpha: 0.5) + : Colors.transparent, + padding: const EdgeInsets.symmetric( + horizontal: UIConstants.cardPadding, + vertical: UIConstants.spacing12, + ), + child: Row( + children: [ + _buildLeadingIcon(), + const SizedBox(width: UIConstants.spacing12), + _buildWorkoutInfo(), + _buildTypeBadge(), + const SizedBox(width: UIConstants.spacing12), + _buildStatusIcon(), + ], + ), + ), + ); + } + + Widget _buildLeadingIcon() { + return Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: widget.workout.completed + ? _typeColor.withValues(alpha: 0.15) + : AppColors.zinc800, + borderRadius: UIConstants.smallCardBorderRadius, + ), + child: Icon( + widget.workout.completed ? _typeIcon : Icons.circle_outlined, + size: 18, + color: widget.workout.completed ? _typeColor : AppColors.textMuted, + ), + ); + } + + Widget _buildWorkoutInfo() { + return Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.workout.name ?? 'Workout', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + 'Week ${widget.workout.weekId} · Day ${widget.workout.day}', + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textMuted, + ), + ), + ], + ), + ); + } + + Widget _buildTypeBadge() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: _typeColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + widget.workout.type, + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w500, + color: _typeColor, + ), + ), + ); + } + + Widget _buildStatusIcon() { + if (widget.workout.completed) { + return const Icon( + Icons.check_circle, + size: 18, + color: AppColors.success, + ); + } + return const Icon( + Icons.radio_button_unchecked, + size: 18, + color: AppColors.textMuted, + ); + } +} diff --git a/lib/presentation/home/widgets/stat_cards_row.dart b/lib/presentation/home/widgets/stat_cards_row.dart new file mode 100644 index 0000000..3137358 --- /dev/null +++ b/lib/presentation/home/widgets/stat_cards_row.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:trainhub_flutter/core/constants/ui_constants.dart'; +import 'package:trainhub_flutter/core/theme/app_colors.dart'; +import 'package:trainhub_flutter/presentation/common/widgets/app_stat_card.dart'; + +class StatCardsRow extends StatelessWidget { + const StatCardsRow({ + super.key, + required this.completed, + required this.total, + }); + + final int completed; + final int total; + + @override + Widget build(BuildContext context) { + final progress = total == 0 ? 0 : (completed / total * 100).round(); + + return Row( + children: [ + Expanded( + child: AppStatCard( + title: 'Completed', + value: '$completed', + icon: Icons.check_circle_outline, + accentColor: AppColors.success, + ), + ), + const SizedBox(width: UIConstants.spacing16), + Expanded( + child: AppStatCard( + title: 'Total Workouts', + value: '$total', + icon: Icons.list_alt, + accentColor: AppColors.info, + ), + ), + const SizedBox(width: UIConstants.spacing16), + Expanded( + child: AppStatCard( + title: 'Progress', + value: '$progress%', + icon: Icons.trending_up, + accentColor: AppColors.purple, + ), + ), + ], + ); + } +} diff --git a/lib/presentation/home/widgets/welcome_header.dart b/lib/presentation/home/widgets/welcome_header.dart new file mode 100644 index 0000000..1eec441 --- /dev/null +++ b/lib/presentation/home/widgets/welcome_header.dart @@ -0,0 +1,71 @@ +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'; + +class WelcomeHeader extends StatelessWidget { + const WelcomeHeader({super.key, required this.programName}); + + final String programName; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Welcome back', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.textMuted, + ), + ), + const SizedBox(height: UIConstants.spacing4), + Row( + children: [ + Expanded( + child: Text( + programName, + style: GoogleFonts.inter( + fontSize: 28, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: UIConstants.spacing12, + vertical: 6, + ), + decoration: BoxDecoration( + color: AppColors.accentMuted, + borderRadius: UIConstants.smallCardBorderRadius, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.fitness_center, + size: 14, + color: AppColors.accent, + ), + const SizedBox(width: 6), + Text( + 'Active Program', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.accent, + ), + ), + ], + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/presentation/settings/ai_model_settings_controller.dart b/lib/presentation/settings/ai_model_settings_controller.dart index b1588fe..c8058e0 100644 --- a/lib/presentation/settings/ai_model_settings_controller.dart +++ b/lib/presentation/settings/ai_model_settings_controller.dart @@ -5,56 +5,28 @@ import 'package:dio/dio.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:trainhub_flutter/core/constants/ai_constants.dart'; +import 'package:trainhub_flutter/data/services/ai_process_manager.dart'; +import 'package:trainhub_flutter/injection.dart' as di; import 'package:trainhub_flutter/presentation/settings/ai_model_settings_state.dart'; part 'ai_model_settings_controller.g.dart'; -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const _llamaBuild = 'b8130'; - -const _nomicModelFile = 'nomic-embed-text-v1.5.Q4_K_M.gguf'; -const _qwenModelFile = 'qwen2.5-7b-instruct-q4_k_m.gguf'; - -const _nomicModelUrl = - 'https://huggingface.co/nomic-ai/nomic-embed-text-v1.5-GGUF/resolve/main/nomic-embed-text-v1.5.Q4_K_M.gguf'; -const _qwenModelUrl = - 'https://huggingface.co/Qwen/Qwen2.5-7B-Instruct-GGUF/resolve/main/qwen2.5-7b-instruct-q4_k_m.gguf'; - -// --------------------------------------------------------------------------- -// Platform helpers -// --------------------------------------------------------------------------- - -/// Returns the llama.cpp archive download URL for the current platform. -/// Throws [UnsupportedError] if the platform is not supported. Future _llamaArchiveUrl() async { + final build = AiConstants.llamaBuild; if (Platform.isMacOS) { - // Detect CPU architecture via `uname -m` final result = await Process.run('uname', ['-m']); final arch = (result.stdout as String).trim(); - if (arch == 'arm64') { - return 'https://github.com/ggml-org/llama.cpp/releases/download/$_llamaBuild/llama-$_llamaBuild-bin-macos-arm64.tar.gz'; - } else { - return 'https://github.com/ggml-org/llama.cpp/releases/download/$_llamaBuild/llama-$_llamaBuild-bin-macos-x64.tar.gz'; - } + final suffix = arch == 'arm64' ? 'macos-arm64' : 'macos-x64'; + return 'https://github.com/ggml-org/llama.cpp/releases/download/$build/llama-$build-bin-$suffix.tar.gz'; } else if (Platform.isWindows) { - return 'https://github.com/ggml-org/llama.cpp/releases/download/$_llamaBuild/llama-$_llamaBuild-bin-win-vulkan-x64.zip'; + return 'https://github.com/ggml-org/llama.cpp/releases/download/$build/llama-$build-bin-win-vulkan-x64.zip'; } else if (Platform.isLinux) { - return 'https://github.com/ggml-org/llama.cpp/releases/download/$_llamaBuild/llama-$_llamaBuild-bin-ubuntu-vulkan-x64.tar.gz'; + return 'https://github.com/ggml-org/llama.cpp/releases/download/$build/llama-$build-bin-ubuntu-vulkan-x64.tar.gz'; } throw UnsupportedError('Unsupported platform: ${Platform.operatingSystem}'); } -/// The expected llama-server binary name for the current platform. -String get _serverBinaryName => - Platform.isWindows ? 'llama-server.exe' : 'llama-server'; - -// --------------------------------------------------------------------------- -// Controller -// --------------------------------------------------------------------------- - @riverpod class AiModelSettingsController extends _$AiModelSettingsController { final _dio = Dio(); @@ -62,30 +34,21 @@ class AiModelSettingsController extends _$AiModelSettingsController { @override AiModelSettingsState build() => const AiModelSettingsState(); - // ------------------------------------------------------------------------- - // Validation - // ------------------------------------------------------------------------- - - /// Checks whether all required files exist on disk and updates - /// [AiModelSettingsState.areModelsValidated]. Future validateModels() async { state = state.copyWith( currentTask: 'Checking installed files…', errorMessage: null, ); - try { final dir = await getApplicationDocumentsDirectory(); final base = dir.path; - - final serverBin = File(p.join(base, _serverBinaryName)); - final nomicModel = File(p.join(base, _nomicModelFile)); - final qwenModel = File(p.join(base, _qwenModelFile)); - - final validated = serverBin.existsSync() && + 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(); - state = state.copyWith( areModelsValidated: validated, currentTask: validated ? 'All files present.' : 'Files missing.', @@ -99,29 +62,22 @@ class AiModelSettingsController extends _$AiModelSettingsController { } } - // ------------------------------------------------------------------------- - // Download & Install - // ------------------------------------------------------------------------- - - /// Downloads and installs the llama.cpp binary and both model files. Future downloadAll() async { if (state.isDownloading) return; - + try { + await di.getIt().stopServers(); + } catch (_) {} state = state.copyWith( isDownloading: true, progress: 0.0, areModelsValidated: false, errorMessage: null, ); - try { final dir = await getApplicationDocumentsDirectory(); - - // -- 1. llama.cpp binary ----------------------------------------------- final archiveUrl = await _llamaArchiveUrl(); final archiveExt = archiveUrl.endsWith('.zip') ? '.zip' : '.tar.gz'; final archivePath = p.join(dir.path, 'llama_binary$archiveExt'); - await _downloadFile( url: archiveUrl, savePath: archivePath, @@ -129,41 +85,32 @@ class AiModelSettingsController extends _$AiModelSettingsController { overallStart: 0.0, overallEnd: 0.2, ); - state = state.copyWith( currentTask: 'Extracting llama.cpp binary…', progress: 0.2, ); await _extractBinary(archivePath, dir.path); - - // Clean up the archive once extracted final archiveFile = File(archivePath); if (archiveFile.existsSync()) archiveFile.deleteSync(); - - // -- 2. Nomic embedding model ------------------------------------------ await _downloadFile( - url: _nomicModelUrl, - savePath: p.join(dir.path, _nomicModelFile), + url: AiConstants.nomicModelUrl, + savePath: p.join(dir.path, AiConstants.nomicModelFile), taskLabel: 'Downloading Nomic embedding model…', overallStart: 0.2, overallEnd: 0.55, ); - - // -- 3. Qwen chat model ------------------------------------------------ await _downloadFile( - url: _qwenModelUrl, - savePath: p.join(dir.path, _qwenModelFile), + url: AiConstants.qwenModelUrl, + savePath: p.join(dir.path, AiConstants.qwenModelFile), taskLabel: 'Downloading Qwen 2.5 7B model…', overallStart: 0.55, overallEnd: 1.0, ); - state = state.copyWith( isDownloading: false, progress: 1.0, currentTask: 'Download complete.', ); - await validateModels(); } on DioException catch (e) { state = state.copyWith( @@ -180,11 +127,6 @@ class AiModelSettingsController extends _$AiModelSettingsController { } } - // ------------------------------------------------------------------------- - // Private helpers - // ------------------------------------------------------------------------- - - /// Downloads a single file with progress mapped into [overallStart]..[overallEnd]. Future _downloadFile({ required String url, required String savePath, @@ -193,7 +135,6 @@ class AiModelSettingsController extends _$AiModelSettingsController { required double overallEnd, }) async { state = state.copyWith(currentTask: taskLabel, progress: overallStart); - await _dio.download( url, savePath, @@ -212,52 +153,62 @@ class AiModelSettingsController extends _$AiModelSettingsController { ); } - /// Extracts the downloaded archive and moves `llama-server[.exe]` to [destDir]. Future _extractBinary(String archivePath, String destDir) async { final extractDir = p.join(destDir, '_llama_extract_tmp'); final extractDirObj = Directory(extractDir); if (extractDirObj.existsSync()) extractDirObj.deleteSync(recursive: true); extractDirObj.createSync(recursive: true); - try { - if (archivePath.endsWith('.zip')) { - await extractFileToDisk(archivePath, extractDir); - } else { - // .tar.gz — use extractFileToDisk which handles both via the archive package - await extractFileToDisk(archivePath, extractDir); + await extractFileToDisk(archivePath, extractDir); + bool foundServer = false; + final binaryName = AiConstants.serverBinaryName; + for (final entity in extractDirObj.listSync(recursive: true)) { + if (entity is File) { + final ext = p.extension(entity.path).toLowerCase(); + final name = p.basename(entity.path); + if (name == binaryName || + ext == '.dll' || + ext == '.so' || + ext == '.dylib') { + final destFile = p.join(destDir, name); + int retryCount = 0; + bool success = false; + while (!success && retryCount < 5) { + try { + if (File(destFile).existsSync()) { + File(destFile).deleteSync(); + } + entity.copySync(destFile); + success = true; + } on FileSystemException catch (_) { + if (retryCount >= 4) { + throw Exception( + 'Failed to overwrite $name. Ensure no other applications are using it.', + ); + } + await Future.delayed(const Duration(milliseconds: 500)); + retryCount++; + } + } + if (name == binaryName) { + foundServer = true; + if (Platform.isMacOS || Platform.isLinux) { + await Process.run('chmod', ['+x', destFile]); + } + } + } + } } - - // Walk the extracted tree to find the server binary - final binary = _findFile(extractDirObj, _serverBinaryName); - if (binary == null) { + if (!foundServer) { throw FileSystemException( 'llama-server binary not found in archive.', archivePath, ); } - - final destBin = p.join(destDir, _serverBinaryName); - binary.copySync(destBin); - - // Make executable on POSIX systems - if (Platform.isMacOS || Platform.isLinux) { - await Process.run('chmod', ['+x', destBin]); - } } finally { - // Always clean up the temp extraction directory if (extractDirObj.existsSync()) { extractDirObj.deleteSync(recursive: true); } } } - - /// Recursively searches [dir] for a file named [name]. - File? _findFile(Directory dir, String name) { - for (final entity in dir.listSync(recursive: true)) { - if (entity is File && p.basename(entity.path) == name) { - return entity; - } - } - return null; - } } diff --git a/lib/presentation/settings/ai_model_settings_controller.g.dart b/lib/presentation/settings/ai_model_settings_controller.g.dart index d1dcdee..d6c8cd5 100644 --- a/lib/presentation/settings/ai_model_settings_controller.g.dart +++ b/lib/presentation/settings/ai_model_settings_controller.g.dart @@ -7,7 +7,7 @@ part of 'ai_model_settings_controller.dart'; // ************************************************************************** String _$aiModelSettingsControllerHash() => - r'5bf80e85e734016b0fa80c6bb84315925f2595b3'; + r'27a37c3fafb21b93a8b5523718f1537419bd382a'; /// See also [AiModelSettingsController]. @ProviderFor(AiModelSettingsController) diff --git a/lib/presentation/settings/settings_page.dart b/lib/presentation/settings/settings_page.dart index e8020f3..a99cbe5 100644 --- a/lib/presentation/settings/settings_page.dart +++ b/lib/presentation/settings/settings_page.dart @@ -3,10 +3,12 @@ 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/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/ai_model_settings_state.dart'; +import 'package:trainhub_flutter/presentation/settings/widgets/ai_models_section.dart'; +import 'package:trainhub_flutter/presentation/settings/widgets/knowledge_base_section.dart'; +import 'package:trainhub_flutter/presentation/settings/widgets/settings_top_bar.dart'; @RoutePage() class SettingsPage extends ConsumerWidget { @@ -15,17 +17,13 @@ class SettingsPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final modelState = ref.watch(aiModelSettingsControllerProvider); - final controller = - ref.read(aiModelSettingsControllerProvider.notifier); + final controller = ref.read(aiModelSettingsControllerProvider.notifier); return Scaffold( backgroundColor: AppColors.surface, body: Column( children: [ - // ── Top bar ────────────────────────────────────────────────────── - _TopBar(onBack: () => context.router.maybePop()), - - // ── Scrollable content ────────────────────────────────────────── + SettingsTopBar(onBack: () => context.router.maybePop()), Expanded( child: SingleChildScrollView( padding: const EdgeInsets.all(UIConstants.pagePadding), @@ -35,7 +33,6 @@ class SettingsPage extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Page title Text( 'Settings', style: GoogleFonts.inter( @@ -46,20 +43,15 @@ class SettingsPage extends ConsumerWidget { ), ), const SizedBox(height: UIConstants.spacing32), - - // AI Models section - _AiModelsSection( + AiModelsSection( modelState: modelState, onDownload: controller.downloadAll, onValidate: controller.validateModels, ), - const SizedBox(height: UIConstants.spacing32), - - // Knowledge Base section - _KnowledgeBaseSection( - onTap: () => context.router - .push(const KnowledgeBaseRoute()), + KnowledgeBaseSection( + onTap: () => + context.router.push(const KnowledgeBaseRoute()), ), ], ), @@ -72,626 +64,3 @@ class SettingsPage extends ConsumerWidget { ); } } - -// ============================================================================= -// Top bar -// ============================================================================= -class _TopBar extends StatelessWidget { - const _TopBar({required this.onBack}); - - final VoidCallback onBack; - - @override - Widget build(BuildContext context) { - return Container( - height: 52, - padding: const EdgeInsets.symmetric(horizontal: UIConstants.spacing16), - decoration: const BoxDecoration( - color: AppColors.surfaceContainer, - border: Border(bottom: BorderSide(color: AppColors.border)), - ), - child: Row( - children: [ - _IconBtn( - icon: Icons.arrow_back_rounded, - tooltip: 'Go back', - onTap: onBack, - ), - const SizedBox(width: UIConstants.spacing12), - Text( - 'Settings', - style: GoogleFonts.inter( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary, - ), - ), - ], - ), - ); - } -} - -// ============================================================================= -// AI Models section -// ============================================================================= -class _AiModelsSection extends StatelessWidget { - const _AiModelsSection({ - required this.modelState, - required this.onDownload, - required this.onValidate, - }); - - final AiModelSettingsState modelState; - final VoidCallback onDownload; - final VoidCallback onValidate; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Section heading - Text( - 'AI Models', - style: GoogleFonts.inter( - fontSize: 13, - fontWeight: FontWeight.w600, - color: AppColors.textMuted, - letterSpacing: 0.8, - ), - ), - const SizedBox(height: UIConstants.spacing12), - - // Card - Container( - decoration: BoxDecoration( - color: AppColors.surfaceContainer, - borderRadius: BorderRadius.circular(UIConstants.borderRadius), - border: Border.all(color: AppColors.border), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Status rows - const _ModelRow( - name: 'llama-server binary', - description: 'llama.cpp inference server (build b8130)', - icon: Icons.terminal_rounded, - ), - const Divider(height: 1, color: AppColors.border), - const _ModelRow( - name: 'Nomic Embed v1.5 Q4_K_M', - description: 'Text embedding model (~300 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)', - icon: Icons.psychology_outlined, - ), - - // Divider before status / actions - const Divider(height: 1, color: AppColors.border), - - Padding( - padding: const EdgeInsets.all(UIConstants.spacing16), - child: _StatusAndActions( - modelState: modelState, - onDownload: onDownload, - onValidate: onValidate, - ), - ), - ], - ), - ), - ], - ); - } -} - -// ============================================================================= -// Single model info row -// ============================================================================= -class _ModelRow extends StatelessWidget { - const _ModelRow({ - required this.name, - required this.description, - required this.icon, - }); - - final String name; - final String description; - final IconData icon; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: UIConstants.spacing16, - vertical: UIConstants.spacing12, - ), - child: Row( - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: AppColors.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), - ), - child: Icon(icon, size: 16, color: AppColors.textSecondary), - ), - const SizedBox(width: UIConstants.spacing12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - name, - style: GoogleFonts.inter( - fontSize: 13, - fontWeight: FontWeight.w500, - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 2), - Text( - description, - style: GoogleFonts.inter( - fontSize: 12, - color: AppColors.textMuted, - ), - ), - ], - ), - ), - ], - ), - ); - } -} - -// ============================================================================= -// Status badge + action buttons -// ============================================================================= -class _StatusAndActions extends StatelessWidget { - const _StatusAndActions({ - required this.modelState, - required this.onDownload, - required this.onValidate, - }); - - final AiModelSettingsState modelState; - final VoidCallback onDownload; - final VoidCallback onValidate; - - @override - Widget build(BuildContext context) { - // While downloading, show progress UI - if (modelState.isDownloading) { - return _DownloadingView(modelState: modelState); - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Status badge - _StatusBadge(validated: modelState.areModelsValidated), - - if (modelState.errorMessage != null) ...[ - const SizedBox(height: UIConstants.spacing12), - _ErrorRow(message: modelState.errorMessage!), - ], - - const SizedBox(height: UIConstants.spacing16), - - // Action buttons - if (!modelState.areModelsValidated) - _ActionButton( - label: 'Download AI Models (~5 GB)', - icon: Icons.download_rounded, - color: AppColors.accent, - textColor: AppColors.zinc950, - onPressed: onDownload, - ) - else - _ActionButton( - label: 'Re-validate Files', - icon: Icons.verified_outlined, - color: Colors.transparent, - textColor: AppColors.textSecondary, - borderColor: AppColors.border, - onPressed: onValidate, - ), - ], - ); - } -} - -class _StatusBadge extends StatelessWidget { - const _StatusBadge({required this.validated}); - - final bool validated; - - @override - Widget build(BuildContext context) { - final color = validated ? AppColors.success : AppColors.textMuted; - final bgColor = - validated ? AppColors.successMuted : AppColors.surfaceContainerHigh; - final label = validated ? 'Ready' : 'Missing'; - final icon = - validated ? Icons.check_circle_outline : Icons.radio_button_unchecked; - - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Status: ', - style: GoogleFonts.inter( - fontSize: 13, - color: AppColors.textSecondary, - ), - ), - const SizedBox(width: UIConstants.spacing4), - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: bgColor, - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 13, color: color), - const SizedBox(width: 5), - Text( - label, - style: GoogleFonts.inter( - fontSize: 12, - fontWeight: FontWeight.w600, - color: color, - ), - ), - ], - ), - ), - ], - ); - } -} - -class _DownloadingView extends StatelessWidget { - const _DownloadingView({required this.modelState}); - - final AiModelSettingsState modelState; - - @override - Widget build(BuildContext context) { - final pct = (modelState.progress * 100).toStringAsFixed(1); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - modelState.currentTask, - style: GoogleFonts.inter( - fontSize: 13, - color: AppColors.textSecondary, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - Text( - '$pct %', - style: GoogleFonts.inter( - fontSize: 13, - fontWeight: FontWeight.w600, - color: AppColors.accent, - ), - ), - ], - ), - const SizedBox(height: UIConstants.spacing8), - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - value: modelState.progress, - minHeight: 6, - backgroundColor: AppColors.zinc800, - valueColor: - const AlwaysStoppedAnimation(AppColors.accent), - ), - ), - if (modelState.errorMessage != null) ...[ - const SizedBox(height: UIConstants.spacing12), - _ErrorRow(message: modelState.errorMessage!), - ], - ], - ); - } -} - -class _ErrorRow extends StatelessWidget { - const _ErrorRow({required this.message}); - - final String message; - - @override - Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon( - Icons.error_outline_rounded, - color: AppColors.destructive, - size: 14, - ), - const SizedBox(width: UIConstants.spacing8), - Expanded( - child: Text( - message, - style: GoogleFonts.inter( - fontSize: 12, - color: AppColors.destructive, - height: 1.4, - ), - ), - ), - ], - ); - } -} - -class _ActionButton extends StatefulWidget { - const _ActionButton({ - required this.label, - required this.icon, - required this.color, - required this.textColor, - required this.onPressed, - this.borderColor, - }); - - final String label; - final IconData icon; - final Color color; - final Color textColor; - final Color? borderColor; - final VoidCallback onPressed; - - @override - State<_ActionButton> createState() => _ActionButtonState(); -} - -class _ActionButtonState extends State<_ActionButton> { - bool _hovered = false; - - @override - Widget build(BuildContext context) { - final hasBorder = widget.borderColor != null; - - return MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: AnimatedContainer( - duration: UIConstants.animationDuration, - height: 40, - decoration: BoxDecoration( - color: hasBorder - ? (_hovered ? AppColors.zinc800 : Colors.transparent) - : (_hovered - ? 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), - onTap: widget.onPressed, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: UIConstants.spacing16, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(widget.icon, size: 16, color: widget.textColor), - const SizedBox(width: UIConstants.spacing8), - Text( - widget.label, - style: GoogleFonts.inter( - fontSize: 13, - fontWeight: FontWeight.w600, - color: widget.textColor, - ), - ), - ], - ), - ), - ), - ), - ), - ); - } -} - -// ============================================================================= -// Generic icon button -// ============================================================================= -class _IconBtn extends StatefulWidget { - const _IconBtn({ - required this.icon, - required this.onTap, - this.tooltip = '', - }); - - final IconData icon; - final VoidCallback onTap; - final String tooltip; - - @override - State<_IconBtn> createState() => _IconBtnState(); -} - -class _IconBtnState extends State<_IconBtn> { - bool _hovered = false; - - @override - Widget build(BuildContext context) { - return Tooltip( - message: widget.tooltip, - child: MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: GestureDetector( - onTap: widget.onTap, - child: AnimatedContainer( - duration: UIConstants.animationDuration, - width: 32, - height: 32, - decoration: BoxDecoration( - color: _hovered ? AppColors.zinc800 : Colors.transparent, - borderRadius: BorderRadius.circular(6), - ), - child: Icon( - widget.icon, - size: 18, - color: - _hovered ? AppColors.textPrimary : AppColors.textSecondary, - ), - ), - ), - ), - ); - } -} - -// ============================================================================= -// Knowledge Base navigation section -// ============================================================================= -class _KnowledgeBaseSection extends StatelessWidget { - const _KnowledgeBaseSection({required this.onTap}); - - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Knowledge Base', - style: GoogleFonts.inter( - fontSize: 13, - fontWeight: FontWeight.w600, - color: AppColors.textMuted, - letterSpacing: 0.8, - ), - ), - const SizedBox(height: UIConstants.spacing12), - _KnowledgeBaseCard(onTap: onTap), - ], - ); - } -} - -class _KnowledgeBaseCard extends StatefulWidget { - const _KnowledgeBaseCard({required this.onTap}); - - final VoidCallback onTap; - - @override - State<_KnowledgeBaseCard> createState() => _KnowledgeBaseCardState(); -} - -class _KnowledgeBaseCardState extends State<_KnowledgeBaseCard> { - bool _hovered = false; - - @override - Widget build(BuildContext context) { - return MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: GestureDetector( - onTap: widget.onTap, - child: AnimatedContainer( - duration: UIConstants.animationDuration, - decoration: BoxDecoration( - color: _hovered - ? AppColors.surfaceContainerHigh - : AppColors.surfaceContainer, - borderRadius: BorderRadius.circular(UIConstants.borderRadius), - border: Border.all( - color: _hovered - ? AppColors.accent.withValues(alpha: 0.3) - : AppColors.border, - ), - ), - padding: const EdgeInsets.symmetric( - horizontal: UIConstants.spacing16, - vertical: UIConstants.spacing16, - ), - child: Row( - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: AppColors.accentMuted, - borderRadius: BorderRadius.circular(10), - ), - child: const Icon( - Icons.hub_outlined, - color: AppColors.accent, - size: 20, - ), - ), - const SizedBox(width: UIConstants.spacing16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Manage Knowledge Base', - style: GoogleFonts.inter( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 3), - Text( - 'Add trainer notes to give the AI context-aware answers.', - style: GoogleFonts.inter( - fontSize: 12, - color: AppColors.textMuted, - ), - ), - ], - ), - ), - Icon( - Icons.chevron_right_rounded, - color: - _hovered ? AppColors.accent : AppColors.textMuted, - size: 20, - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/presentation/settings/widgets/ai_models_section.dart b/lib/presentation/settings/widgets/ai_models_section.dart new file mode 100644 index 0000000..75a79ab --- /dev/null +++ b/lib/presentation/settings/widgets/ai_models_section.dart @@ -0,0 +1,322 @@ +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/presentation/settings/ai_model_settings_state.dart'; +import 'package:trainhub_flutter/presentation/settings/widgets/settings_action_button.dart'; + +class AiModelsSection extends StatelessWidget { + const AiModelsSection({ + super.key, + required this.modelState, + required this.onDownload, + required this.onValidate, + }); + + final AiModelSettingsState modelState; + final VoidCallback onDownload; + final VoidCallback onValidate; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'AI Models', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textMuted, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: UIConstants.spacing12), + Container( + decoration: BoxDecoration( + color: AppColors.surfaceContainer, + borderRadius: BorderRadius.circular(UIConstants.borderRadius), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _ModelRow( + name: 'llama-server binary', + description: 'llama.cpp inference server (build b8130)', + icon: Icons.terminal_rounded, + ), + const Divider(height: 1, color: AppColors.border), + const _ModelRow( + name: 'Nomic Embed v1.5 Q4_K_M', + description: 'Text embedding model (~300 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)', + icon: Icons.psychology_outlined, + ), + const Divider(height: 1, color: AppColors.border), + Padding( + padding: const EdgeInsets.all(UIConstants.spacing16), + child: _StatusAndActions( + modelState: modelState, + onDownload: onDownload, + onValidate: onValidate, + ), + ), + ], + ), + ), + ], + ); + } +} + +class _ModelRow extends StatelessWidget { + const _ModelRow({ + required this.name, + required this.description, + required this.icon, + }); + + final String name; + final String description; + final IconData icon; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: UIConstants.spacing16, + vertical: UIConstants.spacing12, + ), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: AppColors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, size: 16, color: AppColors.textSecondary), + ), + const SizedBox(width: UIConstants.spacing12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + description, + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textMuted, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _StatusAndActions extends StatelessWidget { + const _StatusAndActions({ + required this.modelState, + required this.onDownload, + required this.onValidate, + }); + + final AiModelSettingsState modelState; + final VoidCallback onDownload; + final VoidCallback onValidate; + + @override + Widget build(BuildContext context) { + if (modelState.isDownloading) { + return _DownloadingView(modelState: modelState); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _StatusBadge(validated: modelState.areModelsValidated), + if (modelState.errorMessage != null) ...[ + const SizedBox(height: UIConstants.spacing12), + ErrorRow(message: modelState.errorMessage!), + ], + const SizedBox(height: UIConstants.spacing16), + if (!modelState.areModelsValidated) + SettingsActionButton( + label: 'Download AI Models (~5 GB)', + icon: Icons.download_rounded, + color: AppColors.accent, + textColor: AppColors.zinc950, + onPressed: onDownload, + ) + else + SettingsActionButton( + label: 'Re-validate Files', + icon: Icons.verified_outlined, + color: Colors.transparent, + textColor: AppColors.textSecondary, + borderColor: AppColors.border, + onPressed: onValidate, + ), + ], + ); + } +} + +class _StatusBadge extends StatelessWidget { + const _StatusBadge({required this.validated}); + + final bool validated; + + @override + Widget build(BuildContext context) { + final color = validated ? AppColors.success : AppColors.textMuted; + final bgColor = + validated ? AppColors.successMuted : AppColors.surfaceContainerHigh; + final label = validated ? 'Ready' : 'Missing'; + final icon = + validated ? Icons.check_circle_outline : Icons.radio_button_unchecked; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Status: ', + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + ), + ), + const SizedBox(width: UIConstants.spacing4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 13, color: color), + const SizedBox(width: 5), + Text( + label, + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ], + ), + ), + ], + ); + } +} + +class _DownloadingView extends StatelessWidget { + const _DownloadingView({required this.modelState}); + + final AiModelSettingsState modelState; + + @override + Widget build(BuildContext context) { + final pct = (modelState.progress * 100).toStringAsFixed(1); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + modelState.currentTask, + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Text( + '$pct %', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.accent, + ), + ), + ], + ), + const SizedBox(height: UIConstants.spacing8), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: modelState.progress, + minHeight: 6, + backgroundColor: AppColors.zinc800, + valueColor: + const AlwaysStoppedAnimation(AppColors.accent), + ), + ), + if (modelState.errorMessage != null) ...[ + const SizedBox(height: UIConstants.spacing12), + ErrorRow(message: modelState.errorMessage!), + ], + ], + ); + } +} + +class ErrorRow extends StatelessWidget { + const ErrorRow({super.key, required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.error_outline_rounded, + color: AppColors.destructive, + size: 14, + ), + const SizedBox(width: UIConstants.spacing8), + Expanded( + child: Text( + message, + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.destructive, + height: 1.4, + ), + ), + ), + ], + ); + } +} diff --git a/lib/presentation/settings/widgets/knowledge_base_section.dart b/lib/presentation/settings/widgets/knowledge_base_section.dart new file mode 100644 index 0000000..ae54115 --- /dev/null +++ b/lib/presentation/settings/widgets/knowledge_base_section.dart @@ -0,0 +1,118 @@ +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'; + +class KnowledgeBaseSection extends StatelessWidget { + const KnowledgeBaseSection({super.key, required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Knowledge Base', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textMuted, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: UIConstants.spacing12), + _KnowledgeBaseCard(onTap: onTap), + ], + ); + } +} + +class _KnowledgeBaseCard extends StatefulWidget { + const _KnowledgeBaseCard({required this.onTap}); + + final VoidCallback onTap; + + @override + State<_KnowledgeBaseCard> createState() => _KnowledgeBaseCardState(); +} + +class _KnowledgeBaseCardState extends State<_KnowledgeBaseCard> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + duration: UIConstants.animationDuration, + decoration: BoxDecoration( + color: _hovered + ? AppColors.surfaceContainerHigh + : AppColors.surfaceContainer, + borderRadius: BorderRadius.circular(UIConstants.borderRadius), + border: Border.all( + color: _hovered + ? AppColors.accent.withValues(alpha: 0.3) + : AppColors.border, + ), + ), + padding: const EdgeInsets.symmetric( + horizontal: UIConstants.spacing16, + vertical: UIConstants.spacing16, + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppColors.accentMuted, + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.hub_outlined, + color: AppColors.accent, + size: 20, + ), + ), + const SizedBox(width: UIConstants.spacing16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Manage Knowledge Base', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 3), + Text( + 'Add trainer notes to give the AI context-aware answers.', + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textMuted, + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right_rounded, + color: _hovered ? AppColors.accent : AppColors.textMuted, + size: 20, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/presentation/settings/widgets/settings_action_button.dart b/lib/presentation/settings/widgets/settings_action_button.dart new file mode 100644 index 0000000..4ed9f12 --- /dev/null +++ b/lib/presentation/settings/widgets/settings_action_button.dart @@ -0,0 +1,81 @@ +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'; + +class SettingsActionButton extends StatefulWidget { + const SettingsActionButton({ + super.key, + required this.label, + required this.icon, + required this.color, + required this.textColor, + required this.onPressed, + this.borderColor, + }); + + final String label; + final IconData icon; + final Color color; + final Color textColor; + final Color? borderColor; + final VoidCallback onPressed; + + @override + State createState() => _SettingsActionButtonState(); +} + +class _SettingsActionButtonState extends State { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final hasBorder = widget.borderColor != null; + + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: AnimatedContainer( + duration: UIConstants.animationDuration, + height: 40, + decoration: BoxDecoration( + color: hasBorder + ? (_hovered ? AppColors.zinc800 : Colors.transparent) + : (_hovered + ? 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), + onTap: widget.onPressed, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: UIConstants.spacing16, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(widget.icon, size: 16, color: widget.textColor), + const SizedBox(width: UIConstants.spacing8), + Text( + widget.label, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + color: widget.textColor, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/presentation/settings/widgets/settings_top_bar.dart b/lib/presentation/settings/widgets/settings_top_bar.dart new file mode 100644 index 0000000..347cec3 --- /dev/null +++ b/lib/presentation/settings/widgets/settings_top_bar.dart @@ -0,0 +1,89 @@ +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'; + +class SettingsTopBar extends StatelessWidget { + const SettingsTopBar({super.key, required this.onBack}); + + final VoidCallback onBack; + + @override + Widget build(BuildContext context) { + return Container( + height: 52, + padding: const EdgeInsets.symmetric(horizontal: UIConstants.spacing16), + decoration: const BoxDecoration( + color: AppColors.surfaceContainer, + border: Border(bottom: BorderSide(color: AppColors.border)), + ), + child: Row( + children: [ + SettingsIconButton( + icon: Icons.arrow_back_rounded, + tooltip: 'Go back', + onTap: onBack, + ), + const SizedBox(width: UIConstants.spacing12), + Text( + 'Settings', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ], + ), + ); + } +} + +class SettingsIconButton extends StatefulWidget { + const SettingsIconButton({ + super.key, + required this.icon, + required this.onTap, + this.tooltip = '', + }); + + final IconData icon; + final VoidCallback onTap; + final String tooltip; + + @override + State createState() => _SettingsIconButtonState(); +} + +class _SettingsIconButtonState extends State { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + return Tooltip( + message: widget.tooltip, + child: MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + duration: UIConstants.animationDuration, + width: 32, + height: 32, + decoration: BoxDecoration( + color: _hovered ? AppColors.zinc800 : Colors.transparent, + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + widget.icon, + size: 18, + color: + _hovered ? AppColors.textPrimary : AppColors.textSecondary, + ), + ), + ), + ), + ); + } +} diff --git a/lib/presentation/shell/shell_page.dart b/lib/presentation/shell/shell_page.dart index b7bc52b..b73a64f 100644 --- a/lib/presentation/shell/shell_page.dart +++ b/lib/presentation/shell/shell_page.dart @@ -1,6 +1,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; +import 'package:trainhub_flutter/core/constants/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'; @@ -155,7 +156,7 @@ class _Sidebar extends StatelessWidget { child: Row( children: [ Text( - 'v2.0.0', + 'v${AppConstants.appVersion}', style: GoogleFonts.inter( fontSize: 11, color: AppColors.textMuted, diff --git a/lib/presentation/welcome/welcome_screen.dart b/lib/presentation/welcome/welcome_screen.dart index a4d1a90..3b28924 100644 --- a/lib/presentation/welcome/welcome_screen.dart +++ b/lib/presentation/welcome/welcome_screen.dart @@ -1,12 +1,13 @@ 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/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/ai_model_settings_state.dart'; +import 'package:trainhub_flutter/presentation/welcome/widgets/download_progress.dart'; +import 'package:trainhub_flutter/presentation/welcome/widgets/initial_prompt.dart'; @RoutePage() class WelcomeScreen extends ConsumerStatefulWidget { @@ -22,7 +23,6 @@ class _WelcomeScreenState extends ConsumerState { @override void initState() { super.initState(); - // Validate after the first frame so the provider is ready WidgetsBinding.instance.addPostFrameCallback((_) { ref .read(aiModelSettingsControllerProvider.notifier) @@ -43,19 +43,10 @@ class _WelcomeScreenState extends ConsumerState { context.router.replace(const ShellRoute()); } - void _skip() => _navigateToApp(); - - void _startDownload() { - ref - .read(aiModelSettingsControllerProvider.notifier) - .downloadAll(); - } - @override Widget build(BuildContext context) { final modelState = ref.watch(aiModelSettingsControllerProvider); - // Navigate once download completes and models are validated ref.listen(aiModelSettingsControllerProvider, (prev, next) { if (!_hasNavigated && @@ -73,10 +64,12 @@ class _WelcomeScreenState extends ConsumerState { child: AnimatedSwitcher( duration: UIConstants.animationDuration, child: modelState.isDownloading - ? _DownloadProgress(modelState: modelState) - : _InitialPrompt( - onDownload: _startDownload, - onSkip: _skip, + ? DownloadProgress(modelState: modelState) + : InitialPrompt( + onDownload: () => ref + .read(aiModelSettingsControllerProvider.notifier) + .downloadAll(), + onSkip: _navigateToApp, ), ), ), @@ -84,452 +77,3 @@ class _WelcomeScreenState extends ConsumerState { ); } } - -// ============================================================================= -// Initial prompt card — shown when models are missing -// ============================================================================= -class _InitialPrompt extends StatelessWidget { - const _InitialPrompt({ - required this.onDownload, - required this.onSkip, - }); - - final VoidCallback onDownload; - final VoidCallback onSkip; - - @override - Widget build(BuildContext context) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Logo + wordmark - Row( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: AppColors.accentMuted, - borderRadius: BorderRadius.circular(12), - ), - child: const Icon( - Icons.fitness_center, - color: AppColors.accent, - size: 24, - ), - ), - const SizedBox(width: UIConstants.spacing12), - Text( - 'TrainHub', - style: GoogleFonts.inter( - fontSize: 26, - fontWeight: FontWeight.w700, - color: AppColors.textPrimary, - letterSpacing: -0.5, - ), - ), - ], - ), - - const SizedBox(height: UIConstants.spacing32), - - // Headline - Text( - 'AI-powered coaching,\nright on your device.', - style: GoogleFonts.inter( - fontSize: 28, - fontWeight: FontWeight.w700, - color: AppColors.textPrimary, - height: 1.25, - letterSpacing: -0.5, - ), - ), - - const SizedBox(height: UIConstants.spacing16), - - Text( - 'TrainHub uses on-device AI models to give you intelligent ' - 'training advice, exercise analysis, and personalised chat — ' - 'with zero data sent to the cloud.', - style: GoogleFonts.inter( - fontSize: 14, - color: AppColors.textSecondary, - height: 1.6, - ), - ), - - const SizedBox(height: UIConstants.spacing24), - - // Feature list - const _FeatureRow( - icon: Icons.lock_outline_rounded, - label: '100 % local — your data never leaves this machine.', - ), - const SizedBox(height: UIConstants.spacing12), - const _FeatureRow( - icon: Icons.psychology_outlined, - label: 'Qwen 2.5 7B chat model for training advice.', - ), - const SizedBox(height: UIConstants.spacing12), - const _FeatureRow( - icon: Icons.search_rounded, - label: 'Nomic embedding model for semantic exercise search.', - ), - - const SizedBox(height: UIConstants.spacing32), - - // Download size notice - Container( - padding: const EdgeInsets.symmetric( - horizontal: UIConstants.spacing16, - vertical: UIConstants.spacing12, - ), - decoration: BoxDecoration( - color: AppColors.accentMuted, - borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), - border: Border.all( - color: AppColors.accent.withValues(alpha: 0.3), - ), - ), - child: Row( - children: [ - const Icon( - Icons.download_outlined, - color: AppColors.accent, - size: 18, - ), - const SizedBox(width: UIConstants.spacing12), - Expanded( - child: Text( - 'The download is ~5 GB and only needs to happen once. ' - 'You can skip now and download later from Settings.', - style: GoogleFonts.inter( - fontSize: 13, - color: AppColors.accent, - height: 1.5, - ), - ), - ), - ], - ), - ), - - const SizedBox(height: UIConstants.spacing32), - - // Action buttons - Row( - children: [ - Expanded( - child: _PrimaryButton( - label: 'Download Now', - icon: Icons.download_rounded, - onPressed: onDownload, - ), - ), - const SizedBox(width: UIConstants.spacing12), - Expanded( - child: _SecondaryButton( - label: 'Skip for Now', - onPressed: onSkip, - ), - ), - ], - ), - ], - ); - } -} - -// ============================================================================= -// Download progress card -// ============================================================================= -class _DownloadProgress extends StatelessWidget { - const _DownloadProgress({required this.modelState}); - - final AiModelSettingsState modelState; - - @override - Widget build(BuildContext context) { - final pct = (modelState.progress * 100).toStringAsFixed(1); - - return Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Animated download icon - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: AppColors.accentMuted, - borderRadius: BorderRadius.circular(14), - ), - child: const Icon( - Icons.download_rounded, - color: AppColors.accent, - size: 28, - ), - ), - - const SizedBox(height: UIConstants.spacing24), - - Text( - 'Setting up AI models…', - style: GoogleFonts.inter( - fontSize: 22, - fontWeight: FontWeight.w700, - color: AppColors.textPrimary, - letterSpacing: -0.3, - ), - ), - - const SizedBox(height: UIConstants.spacing8), - - Text( - 'Please keep the app open. This only happens once.', - style: GoogleFonts.inter( - fontSize: 13, - color: AppColors.textSecondary, - ), - ), - - const SizedBox(height: UIConstants.spacing32), - - // Progress bar - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - value: modelState.progress, - minHeight: 6, - backgroundColor: AppColors.zinc800, - valueColor: - const AlwaysStoppedAnimation(AppColors.accent), - ), - ), - - const SizedBox(height: UIConstants.spacing12), - - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - modelState.currentTask, - style: GoogleFonts.inter( - fontSize: 13, - color: AppColors.textSecondary, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - Text( - '$pct %', - style: GoogleFonts.inter( - fontSize: 13, - fontWeight: FontWeight.w600, - color: AppColors.accent, - ), - ), - ], - ), - - if (modelState.errorMessage != null) ...[ - const SizedBox(height: UIConstants.spacing16), - _ErrorBanner(message: modelState.errorMessage!), - ], - ], - ); - } -} - -// ============================================================================= -// Small reusable widgets -// ============================================================================= - -class _FeatureRow extends StatelessWidget { - const _FeatureRow({required this.icon, required this.label}); - - final IconData icon; - final String label; - - @override - Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: AppColors.surfaceContainerHigh, - borderRadius: BorderRadius.circular(6), - ), - child: Icon(icon, size: 14, color: AppColors.accent), - ), - const SizedBox(width: UIConstants.spacing12), - Expanded( - child: Text( - label, - style: GoogleFonts.inter( - fontSize: 13, - color: AppColors.textSecondary, - height: 1.5, - ), - ), - ), - ], - ); - } -} - -class _PrimaryButton extends StatefulWidget { - const _PrimaryButton({ - required this.label, - required this.icon, - required this.onPressed, - }); - - final String label; - final IconData icon; - final VoidCallback onPressed; - - @override - State<_PrimaryButton> createState() => _PrimaryButtonState(); -} - -class _PrimaryButtonState extends State<_PrimaryButton> { - bool _hovered = false; - - @override - Widget build(BuildContext context) { - return MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: AnimatedContainer( - duration: UIConstants.animationDuration, - height: 44, - decoration: BoxDecoration( - color: _hovered - ? AppColors.accent.withValues(alpha: 0.85) - : AppColors.accent, - borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), - ), - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: - BorderRadius.circular(UIConstants.smallBorderRadius), - onTap: widget.onPressed, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(widget.icon, color: AppColors.zinc950, size: 16), - const SizedBox(width: UIConstants.spacing8), - Text( - widget.label, - style: GoogleFonts.inter( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppColors.zinc950, - ), - ), - ], - ), - ), - ), - ), - ); - } -} - -class _SecondaryButton extends StatefulWidget { - const _SecondaryButton({required this.label, required this.onPressed}); - - final String label; - final VoidCallback onPressed; - - @override - State<_SecondaryButton> createState() => _SecondaryButtonState(); -} - -class _SecondaryButtonState extends State<_SecondaryButton> { - bool _hovered = false; - - @override - Widget build(BuildContext context) { - return MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: AnimatedContainer( - duration: UIConstants.animationDuration, - height: 44, - decoration: BoxDecoration( - color: _hovered ? AppColors.zinc800 : Colors.transparent, - borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), - border: Border.all(color: AppColors.border), - ), - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: - BorderRadius.circular(UIConstants.smallBorderRadius), - onTap: widget.onPressed, - child: Center( - child: Text( - widget.label, - style: GoogleFonts.inter( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppColors.textSecondary, - ), - ), - ), - ), - ), - ), - ); - } -} - -class _ErrorBanner extends StatelessWidget { - const _ErrorBanner({required this.message}); - - final String message; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(UIConstants.spacing12), - decoration: BoxDecoration( - color: AppColors.destructiveMuted, - borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), - border: Border.all( - color: AppColors.destructive.withValues(alpha: 0.4), - ), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon( - Icons.error_outline_rounded, - color: AppColors.destructive, - size: 16, - ), - const SizedBox(width: UIConstants.spacing8), - Expanded( - child: Text( - message, - style: GoogleFonts.inter( - fontSize: 12, - color: AppColors.destructive, - height: 1.5, - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/presentation/welcome/widgets/download_progress.dart b/lib/presentation/welcome/widgets/download_progress.dart new file mode 100644 index 0000000..8331199 --- /dev/null +++ b/lib/presentation/welcome/widgets/download_progress.dart @@ -0,0 +1,135 @@ +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/presentation/settings/ai_model_settings_state.dart'; + +class DownloadProgress extends StatelessWidget { + const DownloadProgress({super.key, required this.modelState}); + + final AiModelSettingsState modelState; + + @override + Widget build(BuildContext context) { + final pct = (modelState.progress * 100).toStringAsFixed(1); + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: AppColors.accentMuted, + borderRadius: BorderRadius.circular(14), + ), + child: const Icon( + Icons.download_rounded, + color: AppColors.accent, + size: 28, + ), + ), + const SizedBox(height: UIConstants.spacing24), + Text( + 'Setting up AI models\u2026', + style: GoogleFonts.inter( + fontSize: 22, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + letterSpacing: -0.3, + ), + ), + const SizedBox(height: UIConstants.spacing8), + Text( + 'Please keep the app open. This only happens once.', + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: UIConstants.spacing32), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: modelState.progress, + minHeight: 6, + backgroundColor: AppColors.zinc800, + valueColor: + const AlwaysStoppedAnimation(AppColors.accent), + ), + ), + const SizedBox(height: UIConstants.spacing12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + modelState.currentTask, + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Text( + '$pct %', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.accent, + ), + ), + ], + ), + if (modelState.errorMessage != null) ...[ + const SizedBox(height: UIConstants.spacing16), + ErrorBanner(message: modelState.errorMessage!), + ], + ], + ); + } +} + +class ErrorBanner extends StatelessWidget { + const ErrorBanner({super.key, required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UIConstants.spacing12), + decoration: BoxDecoration( + color: AppColors.destructiveMuted, + borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), + border: Border.all( + color: AppColors.destructive.withValues(alpha: 0.4), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.error_outline_rounded, + color: AppColors.destructive, + size: 16, + ), + const SizedBox(width: UIConstants.spacing8), + Expanded( + child: Text( + message, + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.destructive, + height: 1.5, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/presentation/welcome/widgets/initial_prompt.dart b/lib/presentation/welcome/widgets/initial_prompt.dart new file mode 100644 index 0000000..87340ab --- /dev/null +++ b/lib/presentation/welcome/widgets/initial_prompt.dart @@ -0,0 +1,208 @@ +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/presentation/welcome/widgets/welcome_buttons.dart'; + +class InitialPrompt extends StatelessWidget { + const InitialPrompt({ + super.key, + required this.onDownload, + required this.onSkip, + }); + + final VoidCallback onDownload; + final VoidCallback onSkip; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildLogoRow(), + const SizedBox(height: UIConstants.spacing32), + _buildHeadline(), + const SizedBox(height: UIConstants.spacing16), + _buildDescription(), + const SizedBox(height: UIConstants.spacing24), + _buildFeatureList(), + const SizedBox(height: UIConstants.spacing32), + _buildDownloadNotice(), + const SizedBox(height: UIConstants.spacing32), + _buildActionButtons(), + ], + ); + } + + Widget _buildLogoRow() { + return Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.accentMuted, + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.fitness_center, + color: AppColors.accent, + size: 24, + ), + ), + const SizedBox(width: UIConstants.spacing12), + Text( + 'TrainHub', + style: GoogleFonts.inter( + fontSize: 26, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + letterSpacing: -0.5, + ), + ), + ], + ); + } + + Widget _buildHeadline() { + return Text( + 'AI-powered coaching,\nright on your device.', + style: GoogleFonts.inter( + fontSize: 28, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + height: 1.25, + letterSpacing: -0.5, + ), + ); + } + + Widget _buildDescription() { + return Text( + 'TrainHub uses on-device AI models to give you intelligent ' + 'training advice, exercise analysis, and personalised chat — ' + 'with zero data sent to the cloud.', + style: GoogleFonts.inter( + fontSize: 14, + color: AppColors.textSecondary, + height: 1.6, + ), + ); + } + + Widget _buildFeatureList() { + return const Column( + children: [ + FeatureRow( + icon: Icons.lock_outline_rounded, + label: '100 % local — your data never leaves this machine.', + ), + SizedBox(height: UIConstants.spacing12), + FeatureRow( + icon: Icons.psychology_outlined, + label: 'Qwen 2.5 7B chat model for training advice.', + ), + SizedBox(height: UIConstants.spacing12), + FeatureRow( + icon: Icons.search_rounded, + label: 'Nomic embedding model for semantic exercise search.', + ), + ], + ); + } + + Widget _buildDownloadNotice() { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: UIConstants.spacing16, + vertical: UIConstants.spacing12, + ), + decoration: BoxDecoration( + color: AppColors.accentMuted, + borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), + border: Border.all( + color: AppColors.accent.withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + const Icon( + Icons.download_outlined, + color: AppColors.accent, + size: 18, + ), + const SizedBox(width: UIConstants.spacing12), + Expanded( + child: Text( + 'The download is ~5 GB and only needs to happen once. ' + 'You can skip now and download later from Settings.', + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.accent, + height: 1.5, + ), + ), + ), + ], + ), + ); + } + + Widget _buildActionButtons() { + return Row( + children: [ + Expanded( + child: WelcomePrimaryButton( + label: 'Download Now', + icon: Icons.download_rounded, + onPressed: onDownload, + ), + ), + const SizedBox(width: UIConstants.spacing12), + Expanded( + child: WelcomeSecondaryButton( + label: 'Skip for Now', + onPressed: onSkip, + ), + ), + ], + ); + } +} + +class FeatureRow extends StatelessWidget { + const FeatureRow({super.key, required this.icon, required this.label}); + + final IconData icon; + final String label; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: AppColors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(6), + ), + child: Icon(icon, size: 14, color: AppColors.accent), + ), + const SizedBox(width: UIConstants.spacing12), + Expanded( + child: Text( + label, + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + height: 1.5, + ), + ), + ), + ], + ); + } +} diff --git a/lib/presentation/welcome/widgets/welcome_buttons.dart b/lib/presentation/welcome/widgets/welcome_buttons.dart new file mode 100644 index 0000000..9c56b7a --- /dev/null +++ b/lib/presentation/welcome/widgets/welcome_buttons.dart @@ -0,0 +1,119 @@ +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'; + +class WelcomePrimaryButton extends StatefulWidget { + const WelcomePrimaryButton({ + super.key, + required this.label, + required this.icon, + required this.onPressed, + }); + + final String label; + final IconData icon; + final VoidCallback onPressed; + + @override + State createState() => _WelcomePrimaryButtonState(); +} + +class _WelcomePrimaryButtonState extends State { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: AnimatedContainer( + duration: UIConstants.animationDuration, + height: 44, + decoration: BoxDecoration( + color: _hovered + ? AppColors.accent.withValues(alpha: 0.85) + : AppColors.accent, + borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: + BorderRadius.circular(UIConstants.smallBorderRadius), + onTap: widget.onPressed, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(widget.icon, color: AppColors.zinc950, size: 16), + const SizedBox(width: UIConstants.spacing8), + Text( + widget.label, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.zinc950, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class WelcomeSecondaryButton extends StatefulWidget { + const WelcomeSecondaryButton({ + super.key, + required this.label, + required this.onPressed, + }); + + final String label; + final VoidCallback onPressed; + + @override + State createState() => + _WelcomeSecondaryButtonState(); +} + +class _WelcomeSecondaryButtonState extends State { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: AnimatedContainer( + duration: UIConstants.animationDuration, + height: 44, + decoration: BoxDecoration( + color: _hovered ? AppColors.zinc800 : Colors.transparent, + borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), + border: Border.all(color: AppColors.border), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: + BorderRadius.circular(UIConstants.smallBorderRadius), + onTap: widget.onPressed, + child: Center( + child: Text( + widget.label, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index b7c0c98..d1f2a0b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -149,10 +149,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" charcode: dependency: transitive description: @@ -617,18 +617,18 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" media_kit: dependency: "direct main" description: @@ -1070,10 +1070,10 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.7" timing: dependency: transitive description: