This commit is contained in:
@@ -19,7 +19,8 @@
|
|||||||
"Bash(echo:*)",
|
"Bash(echo:*)",
|
||||||
"Bash(mpv:*)",
|
"Bash(mpv:*)",
|
||||||
"Bash(rpm -qa:*)",
|
"Bash(rpm -qa:*)",
|
||||||
"Bash(ldconfig:*)"
|
"Bash(ldconfig:*)",
|
||||||
|
"Bash(dart analyze:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
143
README.md
143
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
|
## 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)
|
### Setup
|
||||||
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
|
|
||||||
|
|
||||||
For help getting started with Flutter development, view the
|
```bash
|
||||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
# Install dependencies
|
||||||
samples, guidance on mobile development, and a full API reference.
|
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<T>()`.
|
||||||
|
|
||||||
|
### 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 |
|
||||||
|
|||||||
49
lib/core/constants/ai_constants.dart
Normal file
49
lib/core/constants/ai_constants.dart
Normal file
@@ -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.';
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ class AppConstants {
|
|||||||
AppConstants._();
|
AppConstants._();
|
||||||
|
|
||||||
static const String appName = 'TrainHub';
|
static const String appName = 'TrainHub';
|
||||||
|
static const String appVersion = '2.0.0';
|
||||||
static const double windowWidth = 1280;
|
static const double windowWidth = 1280;
|
||||||
static const double windowHeight = 800;
|
static const double windowHeight = 800;
|
||||||
static const double minWindowWidth = 800;
|
static const double minWindowWidth = 800;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import 'package:drift/drift.dart';
|
|||||||
import 'package:trainhub_flutter/data/database/app_database.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/data/database/daos/knowledge_chunk_dao.dart';
|
||||||
import 'package:trainhub_flutter/domain/repositories/note_repository.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';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
const _uuid = Uuid();
|
const _uuid = Uuid();
|
||||||
|
|||||||
174
lib/data/services/ai_process_manager.dart
Normal file
174
lib/data/services/ai_process_manager.dart
Normal file
@@ -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<void> 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<void> 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<bool> _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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,25 +1,24 @@
|
|||||||
import 'package:dio/dio.dart';
|
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.
|
/// Returns a 768-dimensional float vector for any input text.
|
||||||
class EmbeddingService {
|
class EmbeddingService {
|
||||||
final _dio = Dio(
|
final _dio = Dio(
|
||||||
BaseOptions(
|
BaseOptions(
|
||||||
connectTimeout: const Duration(seconds: 10),
|
connectTimeout: AiConstants.embeddingConnectTimeout,
|
||||||
receiveTimeout: const Duration(seconds: 60),
|
receiveTimeout: AiConstants.embeddingReceiveTimeout,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
static const _url = 'http://localhost:8081/v1/embeddings';
|
|
||||||
|
|
||||||
/// Returns the embedding vector for [text].
|
/// Returns the embedding vector for [text].
|
||||||
/// Throws a [DioException] if the Nomic server is unreachable.
|
/// Throws a [DioException] if the Nomic server is unreachable.
|
||||||
Future<List<double>> embed(String text) async {
|
Future<List<double>> embed(String text) async {
|
||||||
final response = await _dio.post<Map<String, dynamic>>(
|
final response = await _dio.post<Map<String, dynamic>>(
|
||||||
_url,
|
AiConstants.embeddingApiUrl,
|
||||||
data: {
|
data: {
|
||||||
'input': text,
|
'input': text,
|
||||||
'model': 'nomic-embed-text-v1.5.Q4_K_M',
|
'model': AiConstants.nomicModelName,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -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<void> 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<List<int>>();
|
|
||||||
_qwenProcess!.stderr.drain<List<int>>();
|
|
||||||
|
|
||||||
// ── 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<List<int>>();
|
|
||||||
_nomicProcess!.stderr.drain<List<int>>();
|
|
||||||
|
|
||||||
_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<void> stopServers() async {
|
|
||||||
_qwenProcess?.kill();
|
|
||||||
_nomicProcess?.kill();
|
|
||||||
_qwenProcess = null;
|
|
||||||
_nomicProcess = null;
|
|
||||||
_running = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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/chat_repository_impl.dart';
|
||||||
import 'package:trainhub_flutter/data/repositories/note_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/repositories/note_repository.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/domain/services/embedding_service.dart';
|
import 'package:trainhub_flutter/data/services/embedding_service.dart';
|
||||||
import 'package:trainhub_flutter/data/database/daos/knowledge_chunk_dao.dart';
|
import 'package:trainhub_flutter/data/database/daos/knowledge_chunk_dao.dart';
|
||||||
|
|
||||||
final GetIt getIt = GetIt.instance;
|
final GetIt getIt = GetIt.instance;
|
||||||
|
|||||||
@@ -4,10 +4,8 @@ import 'package:media_kit/media_kit.dart';
|
|||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
import 'package:trainhub_flutter/core/router/app_router.dart';
|
import 'package:trainhub_flutter/core/router/app_router.dart';
|
||||||
import 'package:trainhub_flutter/core/theme/app_theme.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/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 {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@@ -44,14 +42,10 @@ class TrainHubApp extends ConsumerStatefulWidget {
|
|||||||
ConsumerState<TrainHubApp> createState() => _TrainHubAppState();
|
ConsumerState<TrainHubApp> createState() => _TrainHubAppState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TrainHubAppState extends ConsumerState<TrainHubApp>
|
class _TrainHubAppState extends ConsumerState<TrainHubApp> with WindowListener {
|
||||||
with WindowListener {
|
|
||||||
// Create the router once and reuse it across rebuilds.
|
// Create the router once and reuse it across rebuilds.
|
||||||
final _appRouter = AppRouter();
|
final _appRouter = AppRouter();
|
||||||
|
|
||||||
// Guard flag so we never start the servers more than once per app session.
|
|
||||||
bool _serversStarted = false;
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -87,18 +81,6 @@ class _TrainHubAppState extends ConsumerState<TrainHubApp>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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<AiModelSettingsState>(
|
|
||||||
aiModelSettingsControllerProvider,
|
|
||||||
(prev, next) {
|
|
||||||
if (!_serversStarted && next.areModelsValidated) {
|
|
||||||
_serversStarted = true;
|
|
||||||
di.getIt<AiProcessManager>().startServers();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return MaterialApp.router(
|
return MaterialApp.router(
|
||||||
title: 'TrainHub',
|
title: 'TrainHub',
|
||||||
theme: AppTheme.dark,
|
theme: AppTheme.dark,
|
||||||
|
|||||||
@@ -1,30 +1,32 @@
|
|||||||
|
import 'dart:convert';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:trainhub_flutter/core/constants/ai_constants.dart';
|
||||||
import 'package:trainhub_flutter/domain/repositories/chat_repository.dart';
|
import 'package:trainhub_flutter/domain/repositories/chat_repository.dart';
|
||||||
import 'package:trainhub_flutter/domain/repositories/note_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/injection.dart';
|
||||||
import 'package:trainhub_flutter/presentation/chat/chat_state.dart';
|
import 'package:trainhub_flutter/presentation/chat/chat_state.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
part 'chat_controller.g.dart';
|
part 'chat_controller.g.dart';
|
||||||
|
|
||||||
const _chatApiUrl = 'http://localhost:8080/v1/chat/completions';
|
@riverpod
|
||||||
|
AiProcessManager aiProcessManager(AiProcessManagerRef ref) {
|
||||||
/// Base system prompt that is always included.
|
final manager = getIt<AiProcessManager>();
|
||||||
const _baseSystemPrompt =
|
manager.addListener(() => ref.notifyListeners());
|
||||||
'You are a helpful AI fitness assistant for personal trainers. '
|
return manager;
|
||||||
'Help users design training plans, analyse exercise technique, '
|
}
|
||||||
'and answer questions about sports science and nutrition.';
|
|
||||||
|
|
||||||
@riverpod
|
@riverpod
|
||||||
class ChatController extends _$ChatController {
|
class ChatController extends _$ChatController {
|
||||||
late ChatRepository _repo;
|
late ChatRepository _repo;
|
||||||
late NoteRepository _noteRepo;
|
late NoteRepository _noteRepo;
|
||||||
|
|
||||||
// Shared Dio client — generous timeout for 7B models running on CPU.
|
|
||||||
final _dio = Dio(
|
final _dio = Dio(
|
||||||
BaseOptions(
|
BaseOptions(
|
||||||
connectTimeout: const Duration(seconds: 30),
|
connectTimeout: AiConstants.serverConnectTimeout,
|
||||||
receiveTimeout: const Duration(minutes: 5),
|
receiveTimeout: AiConstants.serverReceiveTimeout,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -32,14 +34,14 @@ class ChatController extends _$ChatController {
|
|||||||
Future<ChatState> build() async {
|
Future<ChatState> build() async {
|
||||||
_repo = getIt<ChatRepository>();
|
_repo = getIt<ChatRepository>();
|
||||||
_noteRepo = getIt<NoteRepository>();
|
_noteRepo = getIt<NoteRepository>();
|
||||||
|
final aiManager = ref.read(aiProcessManagerProvider);
|
||||||
|
if (aiManager.status == AiServerStatus.offline) {
|
||||||
|
aiManager.startServers();
|
||||||
|
}
|
||||||
final sessions = await _repo.getAllSessions();
|
final sessions = await _repo.getAllSessions();
|
||||||
return ChatState(sessions: sessions);
|
return ChatState(sessions: sessions);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Session management (unchanged)
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
Future<void> createSession() async {
|
Future<void> createSession() async {
|
||||||
final session = await _repo.createSession();
|
final session = await _repo.createSession();
|
||||||
final sessions = await _repo.getAllSessions();
|
final sessions = await _repo.getAllSessions();
|
||||||
@@ -72,28 +74,29 @@ class ChatController extends _$ChatController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Send message (RAG + Step D)
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
Future<void> sendMessage(String content) async {
|
Future<void> sendMessage(String content) async {
|
||||||
final current = state.valueOrNull;
|
final current = state.valueOrNull;
|
||||||
if (current == null) return;
|
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 ─────────────────────────────────────
|
Future<String> _resolveSession(ChatState current, String content) async {
|
||||||
String sessionId;
|
if (current.activeSession != null) return current.activeSession!.id;
|
||||||
if (current.activeSession == null) {
|
final session = await _repo.createSession();
|
||||||
final session = await _repo.createSession();
|
final sessions = await _repo.getAllSessions();
|
||||||
sessionId = session.id;
|
state = AsyncValue.data(
|
||||||
final sessions = await _repo.getAllSessions();
|
current.copyWith(sessions: sessions, activeSession: session),
|
||||||
state = AsyncValue.data(
|
);
|
||||||
current.copyWith(sessions: sessions, activeSession: session),
|
return session.id;
|
||||||
);
|
}
|
||||||
} else {
|
|
||||||
sessionId = current.activeSession!.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 2. Persist user message & show typing indicator ───────────────────
|
Future<void> _persistUserMessage(String sessionId, String content) async {
|
||||||
await _repo.addMessage(
|
await _repo.addMessage(
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
@@ -104,95 +107,196 @@ class ChatController extends _$ChatController {
|
|||||||
state.valueOrNull!.copyWith(
|
state.valueOrNull!.copyWith(
|
||||||
messages: messagesAfterUser,
|
messages: messagesAfterUser,
|
||||||
isTyping: true,
|
isTyping: true,
|
||||||
|
thinkingSteps: [],
|
||||||
|
streamingContent: '',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── 3. RAG: retrieve relevant chunks from the knowledge base ──────────
|
Future<List<String>> _searchKnowledgeBase(String query) async {
|
||||||
// Gracefully degrades — if Nomic server is unavailable or no chunks
|
final searchStep = _createStep('Searching knowledge base...');
|
||||||
// exist, the chat still works with the base system prompt alone.
|
|
||||||
List<String> contextChunks = [];
|
List<String> contextChunks = [];
|
||||||
try {
|
try {
|
||||||
contextChunks = await _noteRepo.searchSimilar(content, topK: 3);
|
contextChunks = await _noteRepo.searchSimilar(query, topK: 3);
|
||||||
} catch (_) {
|
if (contextChunks.isNotEmpty) {
|
||||||
// Nomic server not running or no chunks stored — continue without RAG.
|
_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) ──────────────────────────
|
List<Map<String, String>> _buildHistory() {
|
||||||
final systemPrompt = _buildSystemPrompt(contextChunks);
|
final messages = state.valueOrNull?.messages ?? [];
|
||||||
|
return messages
|
||||||
// Build the full conversation history so the model maintains context.
|
.map((m) => <String, String>{
|
||||||
final history = messagesAfterUser
|
'role': m.isUser ? 'user' : 'assistant',
|
||||||
.map(
|
'content': m.content,
|
||||||
(m) => <String, String>{
|
})
|
||||||
'role': m.isUser ? 'user' : 'assistant',
|
|
||||||
'content': m.content,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.toList();
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
// ── 5. POST to Qwen (http://localhost:8080/v1/chat/completions) ────────
|
Future<String> _streamResponse(
|
||||||
String aiResponse;
|
String systemPrompt,
|
||||||
|
List<Map<String, String>> history,
|
||||||
|
) async {
|
||||||
|
final generateStep = _createStep('Generating response...');
|
||||||
|
String fullAiResponse = '';
|
||||||
try {
|
try {
|
||||||
final response = await _dio.post<Map<String, dynamic>>(
|
final response = await _dio.post<ResponseBody>(
|
||||||
_chatApiUrl,
|
AiConstants.chatApiUrl,
|
||||||
|
options: Options(responseType: ResponseType.stream),
|
||||||
data: {
|
data: {
|
||||||
'messages': [
|
'messages': [
|
||||||
{'role': 'system', 'content': systemPrompt},
|
{'role': 'system', 'content': systemPrompt},
|
||||||
...history,
|
...history,
|
||||||
],
|
],
|
||||||
'temperature': 0.7,
|
'temperature': AiConstants.chatTemperature,
|
||||||
|
'stream': true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
aiResponse =
|
_updateStep(
|
||||||
response.data!['choices'][0]['message']['content'] as String;
|
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) {
|
} on DioException catch (e) {
|
||||||
aiResponse =
|
fullAiResponse += '\n\n[AI model communication error]';
|
||||||
'Could not reach the AI server (${e.message}). '
|
_updateStep(
|
||||||
'Make sure AI models are downloaded and the inference servers have '
|
generateStep.id,
|
||||||
'had time to start.';
|
status: ThinkingStepStatus.error,
|
||||||
|
title: 'Generation failed',
|
||||||
|
details: '${e.message}',
|
||||||
|
);
|
||||||
} catch (e) {
|
} 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<void> _persistAssistantResponse(
|
||||||
|
String sessionId,
|
||||||
|
String userContent,
|
||||||
|
String aiResponse,
|
||||||
|
) async {
|
||||||
await _repo.addMessage(
|
await _repo.addMessage(
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: aiResponse,
|
content: aiResponse,
|
||||||
);
|
);
|
||||||
|
|
||||||
final messagesAfterAi = await _repo.getMessages(sessionId);
|
final messagesAfterAi = await _repo.getMessages(sessionId);
|
||||||
if (messagesAfterAi.length <= 2) {
|
if (messagesAfterAi.length <= 2) {
|
||||||
final title =
|
final title = userContent.length > 30
|
||||||
content.length > 30 ? '${content.substring(0, 30)}…' : content;
|
? '${userContent.substring(0, 30)}…'
|
||||||
|
: userContent;
|
||||||
await _repo.updateSessionTitle(sessionId, title);
|
await _repo.updateSessionTitle(sessionId, title);
|
||||||
}
|
}
|
||||||
|
|
||||||
final sessions = await _repo.getAllSessions();
|
final sessions = await _repo.getAllSessions();
|
||||||
state = AsyncValue.data(
|
state = AsyncValue.data(
|
||||||
state.valueOrNull!.copyWith(
|
state.valueOrNull!.copyWith(
|
||||||
messages: messagesAfterAi,
|
messages: messagesAfterAi,
|
||||||
isTyping: false,
|
isTyping: false,
|
||||||
|
streamingContent: null,
|
||||||
|
thinkingSteps: [],
|
||||||
sessions: sessions,
|
sessions: sessions,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
ThinkingStep _createStep(String title) {
|
||||||
// Helpers
|
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<String> chunks) {
|
static String _buildSystemPrompt(List<String> chunks) {
|
||||||
if (chunks.isEmpty) return _baseSystemPrompt;
|
if (chunks.isEmpty) return AiConstants.baseSystemPrompt;
|
||||||
|
|
||||||
final contextBlock = chunks
|
final contextBlock = chunks
|
||||||
.asMap()
|
.asMap()
|
||||||
.entries
|
.entries
|
||||||
.map((e) => '[${e.key + 1}] ${e.value}')
|
.map((e) => '[${e.key + 1}] ${e.value}')
|
||||||
.join('\n\n');
|
.join('\n\n');
|
||||||
|
return '${AiConstants.baseSystemPrompt}\n\n'
|
||||||
return '$_baseSystemPrompt\n\n'
|
|
||||||
'### Relevant notes from the trainer\'s knowledge base:\n'
|
'### Relevant notes from the trainer\'s knowledge base:\n'
|
||||||
'$contextBlock\n\n'
|
'$contextBlock\n\n'
|
||||||
'Use the above context to inform your response when relevant. '
|
'Use the above context to inform your response when relevant. '
|
||||||
|
|||||||
@@ -6,7 +6,24 @@ part of 'chat_controller.dart';
|
|||||||
// RiverpodGenerator
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$chatControllerHash() => r'06ffc6b53c1d878ffc0a758da4f7ee1261ae1340';
|
String _$aiProcessManagerHash() => r'ae77b1e18c06f4192092e1489744626fc8516776';
|
||||||
|
|
||||||
|
/// See also [aiProcessManager].
|
||||||
|
@ProviderFor(aiProcessManager)
|
||||||
|
final aiProcessManagerProvider = AutoDisposeProvider<AiProcessManager>.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<AiProcessManager>;
|
||||||
|
String _$chatControllerHash() => r'266d8a5ac91cbe6c112f85f15adf5a8046e85682';
|
||||||
|
|
||||||
/// See also [ChatController].
|
/// See also [ChatController].
|
||||||
@ProviderFor(ChatController)
|
@ProviderFor(ChatController)
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import 'dart:math' as math;
|
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||||
import 'package:trainhub_flutter/core/router/app_router.dart';
|
|
||||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||||
import 'package:trainhub_flutter/domain/entities/chat_message.dart';
|
import 'package:trainhub_flutter/data/services/ai_process_manager.dart';
|
||||||
import 'package:trainhub_flutter/domain/entities/chat_session.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_controller.dart';
|
||||||
import 'package:trainhub_flutter/presentation/chat/chat_state.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';
|
import 'package:trainhub_flutter/presentation/settings/ai_model_settings_controller.dart';
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
@@ -49,11 +51,10 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
|
|
||||||
void _sendMessage(ChatController controller) {
|
void _sendMessage(ChatController controller) {
|
||||||
final text = _inputController.text.trim();
|
final text = _inputController.text.trim();
|
||||||
if (text.isNotEmpty) {
|
if (text.isEmpty) return;
|
||||||
controller.sendMessage(text);
|
controller.sendMessage(text);
|
||||||
_inputController.clear();
|
_inputController.clear();
|
||||||
_inputFocusNode.requestFocus();
|
_inputFocusNode.requestFocus();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatTimestamp(String timestamp) {
|
String _formatTimestamp(String timestamp) {
|
||||||
@@ -75,16 +76,10 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// ── Gate: check whether AI models are present on disk ─────────────────
|
final modelsValidated =
|
||||||
final modelsValidated = ref
|
ref.watch(aiModelSettingsControllerProvider).areModelsValidated;
|
||||||
.watch(aiModelSettingsControllerProvider)
|
|
||||||
.areModelsValidated;
|
|
||||||
|
|
||||||
// Watch chat state regardless of gate so Riverpod keeps the provider
|
|
||||||
// alive and the scroll listener is always registered.
|
|
||||||
final state = ref.watch(chatControllerProvider);
|
final state = ref.watch(chatControllerProvider);
|
||||||
final controller = ref.read(chatControllerProvider.notifier);
|
final controller = ref.read(chatControllerProvider.notifier);
|
||||||
|
|
||||||
ref.listen(chatControllerProvider, (prev, next) {
|
ref.listen(chatControllerProvider, (prev, next) {
|
||||||
if (next.hasValue &&
|
if (next.hasValue &&
|
||||||
(prev?.value?.messages.length ?? 0) <
|
(prev?.value?.messages.length ?? 0) <
|
||||||
@@ -97,16 +92,12 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
_scrollToBottom();
|
_scrollToBottom();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Show "models missing" placeholder ─────────────────────────────────
|
|
||||||
if (!modelsValidated) {
|
if (!modelsValidated) {
|
||||||
return const Scaffold(
|
return const Scaffold(
|
||||||
backgroundColor: AppColors.surface,
|
backgroundColor: AppColors.surface,
|
||||||
body: _MissingModelsState(),
|
body: MissingModelsState(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Normal chat UI ─────────────────────────────────────────────────────
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.surface,
|
backgroundColor: AppColors.surface,
|
||||||
body: Row(
|
body: Row(
|
||||||
@@ -118,9 +109,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Side Panel
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
Widget _buildSidePanel(
|
Widget _buildSidePanel(
|
||||||
AsyncValue<ChatState> asyncState,
|
AsyncValue<ChatState> asyncState,
|
||||||
ChatController controller,
|
ChatController controller,
|
||||||
@@ -129,9 +117,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
width: 250,
|
width: 250,
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: AppColors.surfaceContainer,
|
color: AppColors.surfaceContainer,
|
||||||
border: Border(
|
border: Border(right: BorderSide(color: AppColors.border, width: 1)),
|
||||||
right: BorderSide(color: AppColors.border, width: 1),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -139,19 +125,17 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
padding: const EdgeInsets.all(UIConstants.spacing12),
|
padding: const EdgeInsets.all(UIConstants.spacing12),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: _NewChatButton(onPressed: controller.createSession),
|
child: NewChatButton(onPressed: controller.createSession),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const Divider(height: 1, color: AppColors.border),
|
const Divider(height: 1, color: AppColors.border),
|
||||||
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: asyncState.when(
|
child: asyncState.when(
|
||||||
data: (data) {
|
data: (data) {
|
||||||
if (data.sessions.isEmpty) {
|
if (data.sessions.isEmpty) {
|
||||||
return Center(
|
return const Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(UIConstants.spacing24),
|
padding: EdgeInsets.all(UIConstants.spacing24),
|
||||||
child: Text(
|
child: Text(
|
||||||
'No conversations yet',
|
'No conversations yet',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -169,23 +153,18 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
itemCount: data.sessions.length,
|
itemCount: data.sessions.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final session = data.sessions[index];
|
final session = data.sessions[index];
|
||||||
final isActive =
|
|
||||||
session.id == data.activeSession?.id;
|
|
||||||
return _buildSessionTile(
|
return _buildSessionTile(
|
||||||
session: session,
|
session: session,
|
||||||
isActive: isActive,
|
isActive: session.id == data.activeSession?.id,
|
||||||
controller: controller,
|
controller: controller,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
error: (_, __) => Center(
|
error: (_, __) => const Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Error loading sessions',
|
'Error loading sessions',
|
||||||
style: TextStyle(
|
style: TextStyle(color: AppColors.textMuted, fontSize: 13),
|
||||||
color: AppColors.textMuted,
|
|
||||||
fontSize: 13,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
loading: () => const Center(
|
loading: () => const Center(
|
||||||
@@ -211,7 +190,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
required ChatController controller,
|
required ChatController controller,
|
||||||
}) {
|
}) {
|
||||||
final isHovered = _hoveredSessionId == session.id;
|
final isHovered = _hoveredSessionId == session.id;
|
||||||
|
|
||||||
return MouseRegion(
|
return MouseRegion(
|
||||||
onEnter: (_) => setState(() => _hoveredSessionId = session.id),
|
onEnter: (_) => setState(() => _hoveredSessionId = session.id),
|
||||||
onExit: (_) => setState(() {
|
onExit: (_) => setState(() {
|
||||||
@@ -240,7 +218,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
border: isActive
|
border: isActive
|
||||||
? Border.all(
|
? Border.all(
|
||||||
color: AppColors.accent.withValues(alpha: 0.3),
|
color: AppColors.accent.withValues(alpha: 0.3),
|
||||||
width: 1,
|
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
@@ -283,7 +260,8 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
Icons.delete_outline_rounded,
|
Icons.delete_outline_rounded,
|
||||||
color: AppColors.textMuted,
|
color: AppColors.textMuted,
|
||||||
),
|
),
|
||||||
onPressed: () => controller.deleteSession(session.id),
|
onPressed: () =>
|
||||||
|
controller.deleteSession(session.id),
|
||||||
tooltip: 'Delete',
|
tooltip: 'Delete',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -296,9 +274,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Chat Area
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
Widget _buildChatArea(
|
Widget _buildChatArea(
|
||||||
AsyncValue<ChatState> asyncState,
|
AsyncValue<ChatState> asyncState,
|
||||||
ChatController controller,
|
ChatController controller,
|
||||||
@@ -315,29 +290,38 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
horizontal: UIConstants.spacing24,
|
horizontal: UIConstants.spacing24,
|
||||||
vertical: UIConstants.spacing16,
|
vertical: UIConstants.spacing16,
|
||||||
),
|
),
|
||||||
itemCount: data.messages.length + (data.isTyping ? 1 : 0),
|
itemCount:
|
||||||
|
data.messages.length + (data.isTyping ? 1 : 0),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
if (index == data.messages.length) {
|
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];
|
final msg = data.messages[index];
|
||||||
return _MessageBubble(
|
return MessageBubble(
|
||||||
message: msg,
|
message: msg,
|
||||||
formattedTime: _formatTimestamp(msg.createdAt),
|
formattedTime: _formatTimestamp(msg.createdAt),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
error: (e, _) => Center(
|
error: (e, _) => const Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
Icon(
|
||||||
Icons.error_outline_rounded,
|
Icons.error_outline_rounded,
|
||||||
color: AppColors.destructive,
|
color: AppColors.destructive,
|
||||||
size: 40,
|
size: 40,
|
||||||
),
|
),
|
||||||
const SizedBox(height: UIConstants.spacing12),
|
SizedBox(height: UIConstants.spacing12),
|
||||||
Text(
|
Text(
|
||||||
'Something went wrong',
|
'Something went wrong',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -402,15 +386,28 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Input Bar
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
Widget _buildInputBar(
|
Widget _buildInputBar(
|
||||||
AsyncValue<ChatState> asyncState,
|
AsyncValue<ChatState> asyncState,
|
||||||
ChatController controller,
|
ChatController controller,
|
||||||
) {
|
) {
|
||||||
|
final aiManager = ref.watch(aiProcessManagerProvider);
|
||||||
final isTyping = asyncState.valueOrNull?.isTyping ?? false;
|
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(
|
return Container(
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: AppColors.surface,
|
color: AppColors.surface,
|
||||||
@@ -420,466 +417,136 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
horizontal: UIConstants.spacing16,
|
horizontal: UIConstants.spacing16,
|
||||||
vertical: UIConstants.spacing12,
|
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(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
children: [
|
||||||
// Icon
|
if (statusMessage != null)
|
||||||
Container(
|
Padding(
|
||||||
width: 72,
|
padding: const EdgeInsets.only(bottom: UIConstants.spacing8),
|
||||||
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,
|
|
||||||
),
|
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
if (isStarting)
|
||||||
Icons.settings_outlined,
|
Container(
|
||||||
size: 16,
|
margin: const EdgeInsets.only(right: 8),
|
||||||
color: AppColors.zinc950,
|
width: 12,
|
||||||
),
|
height: 12,
|
||||||
const SizedBox(width: UIConstants.spacing8),
|
child: const CircularProgressIndicator(
|
||||||
Text(
|
strokeWidth: 2,
|
||||||
'Go to Settings',
|
color: AppColors.info,
|
||||||
style: GoogleFonts.inter(
|
),
|
||||||
fontSize: 14,
|
)
|
||||||
fontWeight: FontWeight.w600,
|
else if (isError)
|
||||||
color: AppColors.zinc950,
|
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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
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(
|
||||||
// New Chat Button
|
fontSize: 12,
|
||||||
// =============================================================================
|
color: AppColors.accent,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}),
|
],
|
||||||
);
|
),
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4,6 +4,18 @@ import 'package:trainhub_flutter/domain/entities/chat_message.dart';
|
|||||||
|
|
||||||
part 'chat_state.freezed.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
|
@freezed
|
||||||
class ChatState with _$ChatState {
|
class ChatState with _$ChatState {
|
||||||
const factory ChatState({
|
const factory ChatState({
|
||||||
@@ -11,5 +23,7 @@ class ChatState with _$ChatState {
|
|||||||
ChatSessionEntity? activeSession,
|
ChatSessionEntity? activeSession,
|
||||||
@Default([]) List<ChatMessageEntity> messages,
|
@Default([]) List<ChatMessageEntity> messages,
|
||||||
@Default(false) bool isTyping,
|
@Default(false) bool isTyping,
|
||||||
|
@Default([]) List<ThinkingStep> thinkingSteps,
|
||||||
|
String? streamingContent,
|
||||||
}) = _ChatState;
|
}) = _ChatState;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
'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<ThinkingStep> 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
|
/// @nodoc
|
||||||
mixin _$ChatState {
|
mixin _$ChatState {
|
||||||
List<ChatSessionEntity> get sessions => throw _privateConstructorUsedError;
|
List<ChatSessionEntity> get sessions => throw _privateConstructorUsedError;
|
||||||
ChatSessionEntity? get activeSession => throw _privateConstructorUsedError;
|
ChatSessionEntity? get activeSession => throw _privateConstructorUsedError;
|
||||||
List<ChatMessageEntity> get messages => throw _privateConstructorUsedError;
|
List<ChatMessageEntity> get messages => throw _privateConstructorUsedError;
|
||||||
bool get isTyping => throw _privateConstructorUsedError;
|
bool get isTyping => throw _privateConstructorUsedError;
|
||||||
|
List<ThinkingStep> get thinkingSteps => throw _privateConstructorUsedError;
|
||||||
|
String? get streamingContent => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
/// Create a copy of ChatState
|
/// Create a copy of ChatState
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@@ -39,6 +246,8 @@ abstract class $ChatStateCopyWith<$Res> {
|
|||||||
ChatSessionEntity? activeSession,
|
ChatSessionEntity? activeSession,
|
||||||
List<ChatMessageEntity> messages,
|
List<ChatMessageEntity> messages,
|
||||||
bool isTyping,
|
bool isTyping,
|
||||||
|
List<ThinkingStep> thinkingSteps,
|
||||||
|
String? streamingContent,
|
||||||
});
|
});
|
||||||
|
|
||||||
$ChatSessionEntityCopyWith<$Res>? get activeSession;
|
$ChatSessionEntityCopyWith<$Res>? get activeSession;
|
||||||
@@ -63,6 +272,8 @@ class _$ChatStateCopyWithImpl<$Res, $Val extends ChatState>
|
|||||||
Object? activeSession = freezed,
|
Object? activeSession = freezed,
|
||||||
Object? messages = null,
|
Object? messages = null,
|
||||||
Object? isTyping = null,
|
Object? isTyping = null,
|
||||||
|
Object? thinkingSteps = null,
|
||||||
|
Object? streamingContent = freezed,
|
||||||
}) {
|
}) {
|
||||||
return _then(
|
return _then(
|
||||||
_value.copyWith(
|
_value.copyWith(
|
||||||
@@ -82,6 +293,14 @@ class _$ChatStateCopyWithImpl<$Res, $Val extends ChatState>
|
|||||||
? _value.isTyping
|
? _value.isTyping
|
||||||
: isTyping // ignore: cast_nullable_to_non_nullable
|
: isTyping // ignore: cast_nullable_to_non_nullable
|
||||||
as bool,
|
as bool,
|
||||||
|
thinkingSteps: null == thinkingSteps
|
||||||
|
? _value.thinkingSteps
|
||||||
|
: thinkingSteps // ignore: cast_nullable_to_non_nullable
|
||||||
|
as List<ThinkingStep>,
|
||||||
|
streamingContent: freezed == streamingContent
|
||||||
|
? _value.streamingContent
|
||||||
|
: streamingContent // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
)
|
)
|
||||||
as $Val,
|
as $Val,
|
||||||
);
|
);
|
||||||
@@ -116,6 +335,8 @@ abstract class _$$ChatStateImplCopyWith<$Res>
|
|||||||
ChatSessionEntity? activeSession,
|
ChatSessionEntity? activeSession,
|
||||||
List<ChatMessageEntity> messages,
|
List<ChatMessageEntity> messages,
|
||||||
bool isTyping,
|
bool isTyping,
|
||||||
|
List<ThinkingStep> thinkingSteps,
|
||||||
|
String? streamingContent,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -140,6 +361,8 @@ class __$$ChatStateImplCopyWithImpl<$Res>
|
|||||||
Object? activeSession = freezed,
|
Object? activeSession = freezed,
|
||||||
Object? messages = null,
|
Object? messages = null,
|
||||||
Object? isTyping = null,
|
Object? isTyping = null,
|
||||||
|
Object? thinkingSteps = null,
|
||||||
|
Object? streamingContent = freezed,
|
||||||
}) {
|
}) {
|
||||||
return _then(
|
return _then(
|
||||||
_$ChatStateImpl(
|
_$ChatStateImpl(
|
||||||
@@ -159,6 +382,14 @@ class __$$ChatStateImplCopyWithImpl<$Res>
|
|||||||
? _value.isTyping
|
? _value.isTyping
|
||||||
: isTyping // ignore: cast_nullable_to_non_nullable
|
: isTyping // ignore: cast_nullable_to_non_nullable
|
||||||
as bool,
|
as bool,
|
||||||
|
thinkingSteps: null == thinkingSteps
|
||||||
|
? _value._thinkingSteps
|
||||||
|
: thinkingSteps // ignore: cast_nullable_to_non_nullable
|
||||||
|
as List<ThinkingStep>,
|
||||||
|
streamingContent: freezed == streamingContent
|
||||||
|
? _value.streamingContent
|
||||||
|
: streamingContent // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -172,8 +403,11 @@ class _$ChatStateImpl implements _ChatState {
|
|||||||
this.activeSession,
|
this.activeSession,
|
||||||
final List<ChatMessageEntity> messages = const [],
|
final List<ChatMessageEntity> messages = const [],
|
||||||
this.isTyping = false,
|
this.isTyping = false,
|
||||||
|
final List<ThinkingStep> thinkingSteps = const [],
|
||||||
|
this.streamingContent,
|
||||||
}) : _sessions = sessions,
|
}) : _sessions = sessions,
|
||||||
_messages = messages;
|
_messages = messages,
|
||||||
|
_thinkingSteps = thinkingSteps;
|
||||||
|
|
||||||
final List<ChatSessionEntity> _sessions;
|
final List<ChatSessionEntity> _sessions;
|
||||||
@override
|
@override
|
||||||
@@ -198,10 +432,21 @@ class _$ChatStateImpl implements _ChatState {
|
|||||||
@override
|
@override
|
||||||
@JsonKey()
|
@JsonKey()
|
||||||
final bool isTyping;
|
final bool isTyping;
|
||||||
|
final List<ThinkingStep> _thinkingSteps;
|
||||||
|
@override
|
||||||
|
@JsonKey()
|
||||||
|
List<ThinkingStep> get thinkingSteps {
|
||||||
|
if (_thinkingSteps is EqualUnmodifiableListView) return _thinkingSteps;
|
||||||
|
// ignore: implicit_dynamic_type
|
||||||
|
return EqualUnmodifiableListView(_thinkingSteps);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
final String? streamingContent;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
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
|
@override
|
||||||
@@ -214,7 +459,13 @@ class _$ChatStateImpl implements _ChatState {
|
|||||||
other.activeSession == activeSession) &&
|
other.activeSession == activeSession) &&
|
||||||
const DeepCollectionEquality().equals(other._messages, _messages) &&
|
const DeepCollectionEquality().equals(other._messages, _messages) &&
|
||||||
(identical(other.isTyping, isTyping) ||
|
(identical(other.isTyping, isTyping) ||
|
||||||
other.isTyping == isTyping));
|
other.isTyping == isTyping) &&
|
||||||
|
const DeepCollectionEquality().equals(
|
||||||
|
other._thinkingSteps,
|
||||||
|
_thinkingSteps,
|
||||||
|
) &&
|
||||||
|
(identical(other.streamingContent, streamingContent) ||
|
||||||
|
other.streamingContent == streamingContent));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -224,6 +475,8 @@ class _$ChatStateImpl implements _ChatState {
|
|||||||
activeSession,
|
activeSession,
|
||||||
const DeepCollectionEquality().hash(_messages),
|
const DeepCollectionEquality().hash(_messages),
|
||||||
isTyping,
|
isTyping,
|
||||||
|
const DeepCollectionEquality().hash(_thinkingSteps),
|
||||||
|
streamingContent,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Create a copy of ChatState
|
/// Create a copy of ChatState
|
||||||
@@ -241,6 +494,8 @@ abstract class _ChatState implements ChatState {
|
|||||||
final ChatSessionEntity? activeSession,
|
final ChatSessionEntity? activeSession,
|
||||||
final List<ChatMessageEntity> messages,
|
final List<ChatMessageEntity> messages,
|
||||||
final bool isTyping,
|
final bool isTyping,
|
||||||
|
final List<ThinkingStep> thinkingSteps,
|
||||||
|
final String? streamingContent,
|
||||||
}) = _$ChatStateImpl;
|
}) = _$ChatStateImpl;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -251,6 +506,10 @@ abstract class _ChatState implements ChatState {
|
|||||||
List<ChatMessageEntity> get messages;
|
List<ChatMessageEntity> get messages;
|
||||||
@override
|
@override
|
||||||
bool get isTyping;
|
bool get isTyping;
|
||||||
|
@override
|
||||||
|
List<ThinkingStep> get thinkingSteps;
|
||||||
|
@override
|
||||||
|
String? get streamingContent;
|
||||||
|
|
||||||
/// Create a copy of ChatState
|
/// Create a copy of ChatState
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
|||||||
121
lib/presentation/chat/widgets/message_bubble.dart
Normal file
121
lib/presentation/chat/widgets/message_bubble.dart
Normal file
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
115
lib/presentation/chat/widgets/missing_models_state.dart
Normal file
115
lib/presentation/chat/widgets/missing_models_state.dart
Normal file
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
lib/presentation/chat/widgets/new_chat_button.dart
Normal file
67
lib/presentation/chat/widgets/new_chat_button.dart
Normal file
@@ -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<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: 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
180
lib/presentation/chat/widgets/thinking_block.dart
Normal file
180
lib/presentation/chat/widgets/thinking_block.dart
Normal file
@@ -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<ThinkingStep> steps;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ThinkingBlock> createState() => _ThinkingBlockState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ThinkingBlockState extends State<ThinkingBlock> {
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
89
lib/presentation/chat/widgets/typing_bubble.dart
Normal file
89
lib/presentation/chat/widgets/typing_bubble.dart
Normal file
@@ -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<ThinkingStep> 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(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
111
lib/presentation/chat/widgets/typing_indicator.dart
Normal file
111
lib/presentation/chat/widgets/typing_indicator.dart
Normal file
@@ -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<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: 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,11 +4,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||||
import 'package:trainhub_flutter/domain/entities/program_workout.dart';
|
|
||||||
import 'package:trainhub_flutter/presentation/common/widgets/app_empty_state.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_controller.dart';
|
||||||
import 'package:trainhub_flutter/presentation/home/home_state.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()
|
@RoutePage()
|
||||||
class HomePage extends ConsumerWidget {
|
class HomePage extends ConsumerWidget {
|
||||||
@@ -20,45 +23,9 @@ class HomePage extends ConsumerWidget {
|
|||||||
|
|
||||||
return asyncState.when(
|
return asyncState.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (e, _) => Center(
|
error: (e, _) => _ErrorView(
|
||||||
child: Padding(
|
error: e,
|
||||||
padding: const EdgeInsets.all(UIConstants.pagePadding),
|
onRetry: () => ref.read(homeControllerProvider.notifier).refresh(),
|
||||||
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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
data: (data) {
|
data: (data) {
|
||||||
if (data.activeProgramName == null) {
|
if (data.activeProgramName == null) {
|
||||||
@@ -68,9 +35,7 @@ class HomePage extends ConsumerWidget {
|
|||||||
subtitle:
|
subtitle:
|
||||||
'Head to Calendar to create or select a training program to get started.',
|
'Head to Calendar to create or select a training program to get started.',
|
||||||
actionLabel: 'Go to Calendar',
|
actionLabel: 'Go to Calendar',
|
||||||
onAction: () {
|
onAction: () => AutoTabsRouter.of(context).setActiveIndex(3),
|
||||||
AutoTabsRouter.of(context).setActiveIndex(3);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return _HomeContent(data: data);
|
return _HomeContent(data: data);
|
||||||
@@ -79,11 +44,61 @@ class HomePage extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HomeContent extends StatelessWidget {
|
class _ErrorView extends StatelessWidget {
|
||||||
final HomeState data;
|
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});
|
const _HomeContent({required this.data});
|
||||||
|
|
||||||
|
final HomeState data;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
@@ -91,609 +106,22 @@ class _HomeContent extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// -- Welcome header --
|
WelcomeHeader(programName: data.activeProgramName!),
|
||||||
_WelcomeHeader(programName: data.activeProgramName!),
|
|
||||||
const SizedBox(height: UIConstants.spacing24),
|
const SizedBox(height: UIConstants.spacing24),
|
||||||
|
StatCardsRow(
|
||||||
// -- Stat cards row --
|
|
||||||
_StatCardsRow(
|
|
||||||
completed: data.completedWorkouts,
|
completed: data.completedWorkouts,
|
||||||
total: data.totalWorkouts,
|
total: data.totalWorkouts,
|
||||||
),
|
),
|
||||||
const SizedBox(height: UIConstants.spacing24),
|
const SizedBox(height: UIConstants.spacing24),
|
||||||
|
|
||||||
// -- Next workout banner --
|
|
||||||
if (data.nextWorkoutName != null) ...[
|
if (data.nextWorkoutName != null) ...[
|
||||||
_NextWorkoutBanner(workoutName: data.nextWorkoutName!),
|
NextWorkoutBanner(workoutName: data.nextWorkoutName!),
|
||||||
const SizedBox(height: UIConstants.spacing24),
|
const SizedBox(height: UIConstants.spacing24),
|
||||||
],
|
],
|
||||||
|
const QuickActionsRow(),
|
||||||
// -- Quick actions --
|
|
||||||
_QuickActionsRow(),
|
|
||||||
const SizedBox(height: UIConstants.spacing32),
|
const SizedBox(height: UIConstants.spacing32),
|
||||||
|
RecentActivitySection(activity: data.recentActivity),
|
||||||
// -- Recent activity --
|
|
||||||
_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<ProgramWorkoutEntity> 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<ProgramWorkoutEntity> 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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
70
lib/presentation/home/widgets/next_workout_banner.dart
Normal file
70
lib/presentation/home/widgets/next_workout_banner.dart
Normal file
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
124
lib/presentation/home/widgets/quick_actions_row.dart
Normal file
124
lib/presentation/home/widgets/quick_actions_row.dart
Normal file
@@ -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<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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
272
lib/presentation/home/widgets/recent_activity_section.dart
Normal file
272
lib/presentation/home/widgets/recent_activity_section.dart
Normal file
@@ -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<ProgramWorkoutEntity> 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<ProgramWorkoutEntity> 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
lib/presentation/home/widgets/stat_cards_row.dart
Normal file
51
lib/presentation/home/widgets/stat_cards_row.dart
Normal file
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
lib/presentation/home/widgets/welcome_header.dart
Normal file
71
lib/presentation/home/widgets/welcome_header.dart
Normal file
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,56 +5,28 @@ import 'package:dio/dio.dart';
|
|||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:trainhub_flutter/core/constants/ai_constants.dart';
|
||||||
|
import 'package:trainhub_flutter/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';
|
import 'package:trainhub_flutter/presentation/settings/ai_model_settings_state.dart';
|
||||||
|
|
||||||
part 'ai_model_settings_controller.g.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<String> _llamaArchiveUrl() async {
|
Future<String> _llamaArchiveUrl() async {
|
||||||
|
final build = AiConstants.llamaBuild;
|
||||||
if (Platform.isMacOS) {
|
if (Platform.isMacOS) {
|
||||||
// Detect CPU architecture via `uname -m`
|
|
||||||
final result = await Process.run('uname', ['-m']);
|
final result = await Process.run('uname', ['-m']);
|
||||||
final arch = (result.stdout as String).trim();
|
final arch = (result.stdout as String).trim();
|
||||||
if (arch == 'arm64') {
|
final suffix = arch == 'arm64' ? 'macos-arm64' : 'macos-x64';
|
||||||
return 'https://github.com/ggml-org/llama.cpp/releases/download/$_llamaBuild/llama-$_llamaBuild-bin-macos-arm64.tar.gz';
|
return 'https://github.com/ggml-org/llama.cpp/releases/download/$build/llama-$build-bin-$suffix.tar.gz';
|
||||||
} else {
|
|
||||||
return 'https://github.com/ggml-org/llama.cpp/releases/download/$_llamaBuild/llama-$_llamaBuild-bin-macos-x64.tar.gz';
|
|
||||||
}
|
|
||||||
} else if (Platform.isWindows) {
|
} 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) {
|
} 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}');
|
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
|
@riverpod
|
||||||
class AiModelSettingsController extends _$AiModelSettingsController {
|
class AiModelSettingsController extends _$AiModelSettingsController {
|
||||||
final _dio = Dio();
|
final _dio = Dio();
|
||||||
@@ -62,30 +34,21 @@ class AiModelSettingsController extends _$AiModelSettingsController {
|
|||||||
@override
|
@override
|
||||||
AiModelSettingsState build() => const AiModelSettingsState();
|
AiModelSettingsState build() => const AiModelSettingsState();
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Validation
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Checks whether all required files exist on disk and updates
|
|
||||||
/// [AiModelSettingsState.areModelsValidated].
|
|
||||||
Future<void> validateModels() async {
|
Future<void> validateModels() async {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
currentTask: 'Checking installed files…',
|
currentTask: 'Checking installed files…',
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
final base = dir.path;
|
final base = dir.path;
|
||||||
|
final serverBin = File(p.join(base, AiConstants.serverBinaryName));
|
||||||
final serverBin = File(p.join(base, _serverBinaryName));
|
final nomicModel = File(p.join(base, AiConstants.nomicModelFile));
|
||||||
final nomicModel = File(p.join(base, _nomicModelFile));
|
final qwenModel = File(p.join(base, AiConstants.qwenModelFile));
|
||||||
final qwenModel = File(p.join(base, _qwenModelFile));
|
final validated =
|
||||||
|
serverBin.existsSync() &&
|
||||||
final validated = serverBin.existsSync() &&
|
|
||||||
nomicModel.existsSync() &&
|
nomicModel.existsSync() &&
|
||||||
qwenModel.existsSync();
|
qwenModel.existsSync();
|
||||||
|
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
areModelsValidated: validated,
|
areModelsValidated: validated,
|
||||||
currentTask: validated ? 'All files present.' : 'Files missing.',
|
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<void> downloadAll() async {
|
Future<void> downloadAll() async {
|
||||||
if (state.isDownloading) return;
|
if (state.isDownloading) return;
|
||||||
|
try {
|
||||||
|
await di.getIt<AiProcessManager>().stopServers();
|
||||||
|
} catch (_) {}
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
isDownloading: true,
|
isDownloading: true,
|
||||||
progress: 0.0,
|
progress: 0.0,
|
||||||
areModelsValidated: false,
|
areModelsValidated: false,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
|
|
||||||
// -- 1. llama.cpp binary -----------------------------------------------
|
|
||||||
final archiveUrl = await _llamaArchiveUrl();
|
final archiveUrl = await _llamaArchiveUrl();
|
||||||
final archiveExt = archiveUrl.endsWith('.zip') ? '.zip' : '.tar.gz';
|
final archiveExt = archiveUrl.endsWith('.zip') ? '.zip' : '.tar.gz';
|
||||||
final archivePath = p.join(dir.path, 'llama_binary$archiveExt');
|
final archivePath = p.join(dir.path, 'llama_binary$archiveExt');
|
||||||
|
|
||||||
await _downloadFile(
|
await _downloadFile(
|
||||||
url: archiveUrl,
|
url: archiveUrl,
|
||||||
savePath: archivePath,
|
savePath: archivePath,
|
||||||
@@ -129,41 +85,32 @@ class AiModelSettingsController extends _$AiModelSettingsController {
|
|||||||
overallStart: 0.0,
|
overallStart: 0.0,
|
||||||
overallEnd: 0.2,
|
overallEnd: 0.2,
|
||||||
);
|
);
|
||||||
|
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
currentTask: 'Extracting llama.cpp binary…',
|
currentTask: 'Extracting llama.cpp binary…',
|
||||||
progress: 0.2,
|
progress: 0.2,
|
||||||
);
|
);
|
||||||
await _extractBinary(archivePath, dir.path);
|
await _extractBinary(archivePath, dir.path);
|
||||||
|
|
||||||
// Clean up the archive once extracted
|
|
||||||
final archiveFile = File(archivePath);
|
final archiveFile = File(archivePath);
|
||||||
if (archiveFile.existsSync()) archiveFile.deleteSync();
|
if (archiveFile.existsSync()) archiveFile.deleteSync();
|
||||||
|
|
||||||
// -- 2. Nomic embedding model ------------------------------------------
|
|
||||||
await _downloadFile(
|
await _downloadFile(
|
||||||
url: _nomicModelUrl,
|
url: AiConstants.nomicModelUrl,
|
||||||
savePath: p.join(dir.path, _nomicModelFile),
|
savePath: p.join(dir.path, AiConstants.nomicModelFile),
|
||||||
taskLabel: 'Downloading Nomic embedding model…',
|
taskLabel: 'Downloading Nomic embedding model…',
|
||||||
overallStart: 0.2,
|
overallStart: 0.2,
|
||||||
overallEnd: 0.55,
|
overallEnd: 0.55,
|
||||||
);
|
);
|
||||||
|
|
||||||
// -- 3. Qwen chat model ------------------------------------------------
|
|
||||||
await _downloadFile(
|
await _downloadFile(
|
||||||
url: _qwenModelUrl,
|
url: AiConstants.qwenModelUrl,
|
||||||
savePath: p.join(dir.path, _qwenModelFile),
|
savePath: p.join(dir.path, AiConstants.qwenModelFile),
|
||||||
taskLabel: 'Downloading Qwen 2.5 7B model…',
|
taskLabel: 'Downloading Qwen 2.5 7B model…',
|
||||||
overallStart: 0.55,
|
overallStart: 0.55,
|
||||||
overallEnd: 1.0,
|
overallEnd: 1.0,
|
||||||
);
|
);
|
||||||
|
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
isDownloading: false,
|
isDownloading: false,
|
||||||
progress: 1.0,
|
progress: 1.0,
|
||||||
currentTask: 'Download complete.',
|
currentTask: 'Download complete.',
|
||||||
);
|
);
|
||||||
|
|
||||||
await validateModels();
|
await validateModels();
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
@@ -180,11 +127,6 @@ class AiModelSettingsController extends _$AiModelSettingsController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Private helpers
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Downloads a single file with progress mapped into [overallStart]..[overallEnd].
|
|
||||||
Future<void> _downloadFile({
|
Future<void> _downloadFile({
|
||||||
required String url,
|
required String url,
|
||||||
required String savePath,
|
required String savePath,
|
||||||
@@ -193,7 +135,6 @@ class AiModelSettingsController extends _$AiModelSettingsController {
|
|||||||
required double overallEnd,
|
required double overallEnd,
|
||||||
}) async {
|
}) async {
|
||||||
state = state.copyWith(currentTask: taskLabel, progress: overallStart);
|
state = state.copyWith(currentTask: taskLabel, progress: overallStart);
|
||||||
|
|
||||||
await _dio.download(
|
await _dio.download(
|
||||||
url,
|
url,
|
||||||
savePath,
|
savePath,
|
||||||
@@ -212,52 +153,62 @@ class AiModelSettingsController extends _$AiModelSettingsController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extracts the downloaded archive and moves `llama-server[.exe]` to [destDir].
|
|
||||||
Future<void> _extractBinary(String archivePath, String destDir) async {
|
Future<void> _extractBinary(String archivePath, String destDir) async {
|
||||||
final extractDir = p.join(destDir, '_llama_extract_tmp');
|
final extractDir = p.join(destDir, '_llama_extract_tmp');
|
||||||
final extractDirObj = Directory(extractDir);
|
final extractDirObj = Directory(extractDir);
|
||||||
if (extractDirObj.existsSync()) extractDirObj.deleteSync(recursive: true);
|
if (extractDirObj.existsSync()) extractDirObj.deleteSync(recursive: true);
|
||||||
extractDirObj.createSync(recursive: true);
|
extractDirObj.createSync(recursive: true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (archivePath.endsWith('.zip')) {
|
await extractFileToDisk(archivePath, extractDir);
|
||||||
await extractFileToDisk(archivePath, extractDir);
|
bool foundServer = false;
|
||||||
} else {
|
final binaryName = AiConstants.serverBinaryName;
|
||||||
// .tar.gz — use extractFileToDisk which handles both via the archive package
|
for (final entity in extractDirObj.listSync(recursive: true)) {
|
||||||
await extractFileToDisk(archivePath, extractDir);
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (!foundServer) {
|
||||||
// Walk the extracted tree to find the server binary
|
|
||||||
final binary = _findFile(extractDirObj, _serverBinaryName);
|
|
||||||
if (binary == null) {
|
|
||||||
throw FileSystemException(
|
throw FileSystemException(
|
||||||
'llama-server binary not found in archive.',
|
'llama-server binary not found in archive.',
|
||||||
archivePath,
|
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 {
|
} finally {
|
||||||
// Always clean up the temp extraction directory
|
|
||||||
if (extractDirObj.existsSync()) {
|
if (extractDirObj.existsSync()) {
|
||||||
extractDirObj.deleteSync(recursive: true);
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ part of 'ai_model_settings_controller.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$aiModelSettingsControllerHash() =>
|
String _$aiModelSettingsControllerHash() =>
|
||||||
r'5bf80e85e734016b0fa80c6bb84315925f2595b3';
|
r'27a37c3fafb21b93a8b5523718f1537419bd382a';
|
||||||
|
|
||||||
/// See also [AiModelSettingsController].
|
/// See also [AiModelSettingsController].
|
||||||
@ProviderFor(AiModelSettingsController)
|
@ProviderFor(AiModelSettingsController)
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
|
||||||
import 'package:trainhub_flutter/core/router/app_router.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_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()
|
@RoutePage()
|
||||||
class SettingsPage extends ConsumerWidget {
|
class SettingsPage extends ConsumerWidget {
|
||||||
@@ -15,17 +17,13 @@ class SettingsPage extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final modelState = ref.watch(aiModelSettingsControllerProvider);
|
final modelState = ref.watch(aiModelSettingsControllerProvider);
|
||||||
final controller =
|
final controller = ref.read(aiModelSettingsControllerProvider.notifier);
|
||||||
ref.read(aiModelSettingsControllerProvider.notifier);
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.surface,
|
backgroundColor: AppColors.surface,
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
// ── Top bar ──────────────────────────────────────────────────────
|
SettingsTopBar(onBack: () => context.router.maybePop()),
|
||||||
_TopBar(onBack: () => context.router.maybePop()),
|
|
||||||
|
|
||||||
// ── Scrollable content ──────────────────────────────────────────
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(UIConstants.pagePadding),
|
padding: const EdgeInsets.all(UIConstants.pagePadding),
|
||||||
@@ -35,7 +33,6 @@ class SettingsPage extends ConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Page title
|
|
||||||
Text(
|
Text(
|
||||||
'Settings',
|
'Settings',
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
@@ -46,20 +43,15 @@ class SettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: UIConstants.spacing32),
|
const SizedBox(height: UIConstants.spacing32),
|
||||||
|
AiModelsSection(
|
||||||
// AI Models section
|
|
||||||
_AiModelsSection(
|
|
||||||
modelState: modelState,
|
modelState: modelState,
|
||||||
onDownload: controller.downloadAll,
|
onDownload: controller.downloadAll,
|
||||||
onValidate: controller.validateModels,
|
onValidate: controller.validateModels,
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: UIConstants.spacing32),
|
const SizedBox(height: UIConstants.spacing32),
|
||||||
|
KnowledgeBaseSection(
|
||||||
// Knowledge Base section
|
onTap: () =>
|
||||||
_KnowledgeBaseSection(
|
context.router.push(const KnowledgeBaseRoute()),
|
||||||
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<Color>(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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
322
lib/presentation/settings/widgets/ai_models_section.dart
Normal file
322
lib/presentation/settings/widgets/ai_models_section.dart
Normal file
@@ -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<Color>(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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
118
lib/presentation/settings/widgets/knowledge_base_section.dart
Normal file
118
lib/presentation/settings/widgets/knowledge_base_section.dart
Normal file
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SettingsActionButton> createState() => _SettingsActionButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SettingsActionButtonState extends State<SettingsActionButton> {
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
89
lib/presentation/settings/widgets/settings_top_bar.dart
Normal file
89
lib/presentation/settings/widgets/settings_top_bar.dart
Normal file
@@ -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<SettingsIconButton> createState() => _SettingsIconButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SettingsIconButtonState extends State<SettingsIconButton> {
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:google_fonts/google_fonts.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/constants/ui_constants.dart';
|
||||||
import 'package:trainhub_flutter/core/router/app_router.dart';
|
import 'package:trainhub_flutter/core/router/app_router.dart';
|
||||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||||
@@ -155,7 +156,7 @@ class _Sidebar extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'v2.0.0',
|
'v${AppConstants.appVersion}',
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: AppColors.textMuted,
|
color: AppColors.textMuted,
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/constants/ui_constants.dart';
|
||||||
import 'package:trainhub_flutter/core/router/app_router.dart';
|
import 'package:trainhub_flutter/core/router/app_router.dart';
|
||||||
import 'package:trainhub_flutter/core/theme/app_colors.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_controller.dart';
|
||||||
import 'package:trainhub_flutter/presentation/settings/ai_model_settings_state.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()
|
@RoutePage()
|
||||||
class WelcomeScreen extends ConsumerStatefulWidget {
|
class WelcomeScreen extends ConsumerStatefulWidget {
|
||||||
@@ -22,7 +23,6 @@ class _WelcomeScreenState extends ConsumerState<WelcomeScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Validate after the first frame so the provider is ready
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
ref
|
ref
|
||||||
.read(aiModelSettingsControllerProvider.notifier)
|
.read(aiModelSettingsControllerProvider.notifier)
|
||||||
@@ -43,19 +43,10 @@ class _WelcomeScreenState extends ConsumerState<WelcomeScreen> {
|
|||||||
context.router.replace(const ShellRoute());
|
context.router.replace(const ShellRoute());
|
||||||
}
|
}
|
||||||
|
|
||||||
void _skip() => _navigateToApp();
|
|
||||||
|
|
||||||
void _startDownload() {
|
|
||||||
ref
|
|
||||||
.read(aiModelSettingsControllerProvider.notifier)
|
|
||||||
.downloadAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final modelState = ref.watch(aiModelSettingsControllerProvider);
|
final modelState = ref.watch(aiModelSettingsControllerProvider);
|
||||||
|
|
||||||
// Navigate once download completes and models are validated
|
|
||||||
ref.listen<AiModelSettingsState>(aiModelSettingsControllerProvider,
|
ref.listen<AiModelSettingsState>(aiModelSettingsControllerProvider,
|
||||||
(prev, next) {
|
(prev, next) {
|
||||||
if (!_hasNavigated &&
|
if (!_hasNavigated &&
|
||||||
@@ -73,10 +64,12 @@ class _WelcomeScreenState extends ConsumerState<WelcomeScreen> {
|
|||||||
child: AnimatedSwitcher(
|
child: AnimatedSwitcher(
|
||||||
duration: UIConstants.animationDuration,
|
duration: UIConstants.animationDuration,
|
||||||
child: modelState.isDownloading
|
child: modelState.isDownloading
|
||||||
? _DownloadProgress(modelState: modelState)
|
? DownloadProgress(modelState: modelState)
|
||||||
: _InitialPrompt(
|
: InitialPrompt(
|
||||||
onDownload: _startDownload,
|
onDownload: () => ref
|
||||||
onSkip: _skip,
|
.read(aiModelSettingsControllerProvider.notifier)
|
||||||
|
.downloadAll(),
|
||||||
|
onSkip: _navigateToApp,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -84,452 +77,3 @@ class _WelcomeScreenState extends ConsumerState<WelcomeScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// 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<Color>(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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
135
lib/presentation/welcome/widgets/download_progress.dart
Normal file
135
lib/presentation/welcome/widgets/download_progress.dart
Normal file
@@ -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<Color>(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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
208
lib/presentation/welcome/widgets/initial_prompt.dart
Normal file
208
lib/presentation/welcome/widgets/initial_prompt.dart
Normal file
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
119
lib/presentation/welcome/widgets/welcome_buttons.dart
Normal file
119
lib/presentation/welcome/widgets/welcome_buttons.dart
Normal file
@@ -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<WelcomePrimaryButton> createState() => _WelcomePrimaryButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WelcomePrimaryButtonState extends State<WelcomePrimaryButton> {
|
||||||
|
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<WelcomeSecondaryButton> createState() =>
|
||||||
|
_WelcomeSecondaryButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WelcomeSecondaryButtonState extends State<WelcomeSecondaryButton> {
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
pubspec.lock
16
pubspec.lock
@@ -149,10 +149,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: characters
|
name: characters
|
||||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.0"
|
||||||
charcode:
|
charcode:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -617,18 +617,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.18"
|
version: "0.12.17"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.13.0"
|
version: "0.11.1"
|
||||||
media_kit:
|
media_kit:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1070,10 +1070,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.9"
|
version: "0.7.7"
|
||||||
timing:
|
timing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
Reference in New Issue
Block a user