Compare commits
2 Commits
v1.0.0-bet
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9dcc4b87de | ||
| 0c9eb8878d |
@@ -8,7 +8,19 @@
|
|||||||
"Bash(dir /s /b \"C:\\\\Users\\\\kaziu\\\\Desktop\\\\Trainhubv2\\\\trainhub_flutter\\\\lib\\\\presentation\\\\*.dart\")",
|
"Bash(dir /s /b \"C:\\\\Users\\\\kaziu\\\\Desktop\\\\Trainhubv2\\\\trainhub_flutter\\\\lib\\\\presentation\\\\*.dart\")",
|
||||||
"Bash(flutter build:*)",
|
"Bash(flutter build:*)",
|
||||||
"Bash(test:*)",
|
"Bash(test:*)",
|
||||||
"Bash(powershell -Command \"Remove-Item ''C:\\\\Users\\\\kaziu\\\\Desktop\\\\Trainhubv2\\\\trainhub_flutter\\\\lib\\\\theme.dart'' -Force -ErrorAction SilentlyContinue; Remove-Item ''C:\\\\Users\\\\kaziu\\\\Desktop\\\\Trainhubv2\\\\trainhub_flutter\\\\lib\\\\models'' -Recurse -Force -ErrorAction SilentlyContinue; Remove-Item ''C:\\\\Users\\\\kaziu\\\\Desktop\\\\Trainhubv2\\\\trainhub_flutter\\\\lib\\\\database'' -Recurse -Force -ErrorAction SilentlyContinue; Remove-Item ''C:\\\\Users\\\\kaziu\\\\Desktop\\\\Trainhubv2\\\\trainhub_flutter\\\\lib\\\\providers'' -Recurse -Force -ErrorAction SilentlyContinue; Write-Output ''Done''\")"
|
"Bash(powershell -Command \"Remove-Item ''C:\\\\Users\\\\kaziu\\\\Desktop\\\\Trainhubv2\\\\trainhub_flutter\\\\lib\\\\theme.dart'' -Force -ErrorAction SilentlyContinue; Remove-Item ''C:\\\\Users\\\\kaziu\\\\Desktop\\\\Trainhubv2\\\\trainhub_flutter\\\\lib\\\\models'' -Recurse -Force -ErrorAction SilentlyContinue; Remove-Item ''C:\\\\Users\\\\kaziu\\\\Desktop\\\\Trainhubv2\\\\trainhub_flutter\\\\lib\\\\database'' -Recurse -Force -ErrorAction SilentlyContinue; Remove-Item ''C:\\\\Users\\\\kaziu\\\\Desktop\\\\Trainhubv2\\\\trainhub_flutter\\\\lib\\\\providers'' -Recurse -Force -ErrorAction SilentlyContinue; Write-Output ''Done''\")",
|
||||||
|
"Bash(flutter analyze:*)",
|
||||||
|
"Bash(ls:*)",
|
||||||
|
"Bash(rpm -q:*)",
|
||||||
|
"Bash(dnf list:*)",
|
||||||
|
"Bash(sudo dnf install:*)",
|
||||||
|
"Bash(git -C /home/kamici/Pulpit/trainhub-flutter stash)",
|
||||||
|
"Bash(git -C /home/kamici/Pulpit/trainhub-flutter stash pop)",
|
||||||
|
"Bash(echo:*)",
|
||||||
|
"Bash(mpv:*)",
|
||||||
|
"Bash(rpm -qa:*)",
|
||||||
|
"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,8 +5,11 @@ import 'package:trainhub_flutter/presentation/calendar/calendar_page.dart';
|
|||||||
import 'package:trainhub_flutter/presentation/chat/chat_page.dart';
|
import 'package:trainhub_flutter/presentation/chat/chat_page.dart';
|
||||||
import 'package:trainhub_flutter/presentation/home/home_page.dart';
|
import 'package:trainhub_flutter/presentation/home/home_page.dart';
|
||||||
import 'package:trainhub_flutter/presentation/plan_editor/plan_editor_page.dart';
|
import 'package:trainhub_flutter/presentation/plan_editor/plan_editor_page.dart';
|
||||||
|
import 'package:trainhub_flutter/presentation/settings/knowledge_base_page.dart';
|
||||||
|
import 'package:trainhub_flutter/presentation/settings/settings_page.dart';
|
||||||
import 'package:trainhub_flutter/presentation/shell/shell_page.dart';
|
import 'package:trainhub_flutter/presentation/shell/shell_page.dart';
|
||||||
import 'package:trainhub_flutter/presentation/trainings/trainings_page.dart';
|
import 'package:trainhub_flutter/presentation/trainings/trainings_page.dart';
|
||||||
|
import 'package:trainhub_flutter/presentation/welcome/welcome_screen.dart';
|
||||||
import 'package:trainhub_flutter/presentation/workout_session/workout_session_page.dart';
|
import 'package:trainhub_flutter/presentation/workout_session/workout_session_page.dart';
|
||||||
|
|
||||||
part 'app_router.gr.dart';
|
part 'app_router.gr.dart';
|
||||||
@@ -15,9 +18,12 @@ part 'app_router.gr.dart';
|
|||||||
class AppRouter extends RootStackRouter {
|
class AppRouter extends RootStackRouter {
|
||||||
@override
|
@override
|
||||||
List<AutoRoute> get routes => [
|
List<AutoRoute> get routes => [
|
||||||
|
// First-launch welcome / model download screen
|
||||||
|
AutoRoute(page: WelcomeRoute.page, initial: true),
|
||||||
|
|
||||||
|
// Main app shell (side-nav + tab router)
|
||||||
AutoRoute(
|
AutoRoute(
|
||||||
page: ShellRoute.page,
|
page: ShellRoute.page,
|
||||||
initial: true,
|
|
||||||
children: [
|
children: [
|
||||||
AutoRoute(page: HomeRoute.page, initial: true),
|
AutoRoute(page: HomeRoute.page, initial: true),
|
||||||
AutoRoute(page: TrainingsRoute.page),
|
AutoRoute(page: TrainingsRoute.page),
|
||||||
@@ -26,7 +32,11 @@ class AppRouter extends RootStackRouter {
|
|||||||
AutoRoute(page: ChatRoute.page),
|
AutoRoute(page: ChatRoute.page),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Full-screen standalone pages
|
||||||
AutoRoute(page: PlanEditorRoute.page),
|
AutoRoute(page: PlanEditorRoute.page),
|
||||||
AutoRoute(page: WorkoutSessionRoute.page),
|
AutoRoute(page: WorkoutSessionRoute.page),
|
||||||
|
AutoRoute(page: SettingsRoute.page),
|
||||||
|
AutoRoute(page: KnowledgeBaseRoute.page),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,22 @@ class HomeRoute extends PageRouteInfo<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// generated route for
|
||||||
|
/// [KnowledgeBasePage]
|
||||||
|
class KnowledgeBaseRoute extends PageRouteInfo<void> {
|
||||||
|
const KnowledgeBaseRoute({List<PageRouteInfo>? children})
|
||||||
|
: super(KnowledgeBaseRoute.name, initialChildren: children);
|
||||||
|
|
||||||
|
static const String name = 'KnowledgeBaseRoute';
|
||||||
|
|
||||||
|
static PageInfo page = PageInfo(
|
||||||
|
name,
|
||||||
|
builder: (data) {
|
||||||
|
return const KnowledgeBasePage();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [PlanEditorPage]
|
/// [PlanEditorPage]
|
||||||
class PlanEditorRoute extends PageRouteInfo<PlanEditorRouteArgs> {
|
class PlanEditorRoute extends PageRouteInfo<PlanEditorRouteArgs> {
|
||||||
@@ -116,6 +132,22 @@ class PlanEditorRouteArgs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// generated route for
|
||||||
|
/// [SettingsPage]
|
||||||
|
class SettingsRoute extends PageRouteInfo<void> {
|
||||||
|
const SettingsRoute({List<PageRouteInfo>? children})
|
||||||
|
: super(SettingsRoute.name, initialChildren: children);
|
||||||
|
|
||||||
|
static const String name = 'SettingsRoute';
|
||||||
|
|
||||||
|
static PageInfo page = PageInfo(
|
||||||
|
name,
|
||||||
|
builder: (data) {
|
||||||
|
return const SettingsPage();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [ShellPage]
|
/// [ShellPage]
|
||||||
class ShellRoute extends PageRouteInfo<void> {
|
class ShellRoute extends PageRouteInfo<void> {
|
||||||
@@ -148,6 +180,22 @@ class TrainingsRoute extends PageRouteInfo<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// generated route for
|
||||||
|
/// [WelcomeScreen]
|
||||||
|
class WelcomeRoute extends PageRouteInfo<void> {
|
||||||
|
const WelcomeRoute({List<PageRouteInfo>? children})
|
||||||
|
: super(WelcomeRoute.name, initialChildren: children);
|
||||||
|
|
||||||
|
static const String name = 'WelcomeRoute';
|
||||||
|
|
||||||
|
static PageInfo page = PageInfo(
|
||||||
|
name,
|
||||||
|
builder: (data) {
|
||||||
|
return const WelcomeScreen();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [WorkoutSessionPage]
|
/// [WorkoutSessionPage]
|
||||||
class WorkoutSessionRoute extends PageRouteInfo<WorkoutSessionRouteArgs> {
|
class WorkoutSessionRoute extends PageRouteInfo<WorkoutSessionRouteArgs> {
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
import 'package:drift/drift.dart';
|
|
||||||
import 'package:drift/native.dart';
|
|
||||||
import 'package:path_provider/path_provider.dart';
|
|
||||||
import 'package:path/path.dart' as p;
|
|
||||||
import 'package:trainhub_flutter/core/constants/app_constants.dart';
|
|
||||||
import 'package:trainhub_flutter/data/database/daos/exercise_dao.dart';
|
|
||||||
import 'package:trainhub_flutter/data/database/daos/training_plan_dao.dart';
|
|
||||||
import 'package:trainhub_flutter/data/database/daos/program_dao.dart';
|
|
||||||
import 'package:trainhub_flutter/data/database/daos/analysis_dao.dart';
|
|
||||||
import 'package:trainhub_flutter/data/database/daos/chat_dao.dart';
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:trainhub_flutter/core/constants/app_constants.dart';
|
||||||
|
import 'package:trainhub_flutter/data/database/daos/analysis_dao.dart';
|
||||||
|
import 'package:trainhub_flutter/data/database/daos/chat_dao.dart';
|
||||||
|
import 'package:trainhub_flutter/data/database/daos/exercise_dao.dart';
|
||||||
|
import 'package:trainhub_flutter/data/database/daos/knowledge_chunk_dao.dart';
|
||||||
|
import 'package:trainhub_flutter/data/database/daos/program_dao.dart';
|
||||||
|
import 'package:trainhub_flutter/data/database/daos/training_plan_dao.dart';
|
||||||
|
|
||||||
part 'app_database.g.dart';
|
part 'app_database.g.dart';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Existing tables (unchanged)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class Exercises extends Table {
|
class Exercises extends Table {
|
||||||
TextColumn get id => text()();
|
TextColumn get id => text()();
|
||||||
TextColumn get name => text()();
|
TextColumn get name => text()();
|
||||||
@@ -116,6 +122,35 @@ class ChatMessages extends Table {
|
|||||||
Set<Column> get primaryKey => {id};
|
Set<Column> get primaryKey => {id};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// v2: Knowledge base chunks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Stores text chunks and their JSON-encoded embedding vectors.
|
||||||
|
/// Used for Retrieval-Augmented Generation (RAG) in the AI chat.
|
||||||
|
class KnowledgeChunks extends Table {
|
||||||
|
/// UUID for this individual chunk.
|
||||||
|
TextColumn get id => text()();
|
||||||
|
|
||||||
|
/// All chunks from the same `addNote()` call share this ID.
|
||||||
|
TextColumn get sourceId => text()();
|
||||||
|
|
||||||
|
/// The raw text of the chunk (max ~500 chars).
|
||||||
|
TextColumn get content => text()();
|
||||||
|
|
||||||
|
/// JSON-encoded `List<double>` — the 768-dim Nomic embedding vector.
|
||||||
|
TextColumn get embedding => text()();
|
||||||
|
|
||||||
|
TextColumn get createdAt => text()();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {id};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Database class
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@DriftDatabase(
|
@DriftDatabase(
|
||||||
tables: [
|
tables: [
|
||||||
Exercises,
|
Exercises,
|
||||||
@@ -127,6 +162,7 @@ class ChatMessages extends Table {
|
|||||||
Annotations,
|
Annotations,
|
||||||
ChatSessions,
|
ChatSessions,
|
||||||
ChatMessages,
|
ChatMessages,
|
||||||
|
KnowledgeChunks, // added in schema v2
|
||||||
],
|
],
|
||||||
daos: [
|
daos: [
|
||||||
ExerciseDao,
|
ExerciseDao,
|
||||||
@@ -134,13 +170,24 @@ class ChatMessages extends Table {
|
|||||||
ProgramDao,
|
ProgramDao,
|
||||||
AnalysisDao,
|
AnalysisDao,
|
||||||
ChatDao,
|
ChatDao,
|
||||||
|
KnowledgeChunkDao, // added in schema v2
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
class AppDatabase extends _$AppDatabase {
|
class AppDatabase extends _$AppDatabase {
|
||||||
AppDatabase() : super(_openConnection());
|
AppDatabase() : super(_openConnection());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 1;
|
int get schemaVersion => 2;
|
||||||
|
|
||||||
|
@override
|
||||||
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
|
onUpgrade: (migrator, from, to) async {
|
||||||
|
// v1 → v2: add knowledge chunks table
|
||||||
|
if (from < 2) {
|
||||||
|
await migrator.createTable(knowledgeChunks);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyDatabase _openConnection() {
|
LazyDatabase _openConnection() {
|
||||||
|
|||||||
@@ -3250,6 +3250,375 @@ class ChatMessagesCompanion extends UpdateCompanion<ChatMessage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class $KnowledgeChunksTable extends KnowledgeChunks
|
||||||
|
with TableInfo<$KnowledgeChunksTable, KnowledgeChunk> {
|
||||||
|
@override
|
||||||
|
final GeneratedDatabase attachedDatabase;
|
||||||
|
final String? _alias;
|
||||||
|
$KnowledgeChunksTable(this.attachedDatabase, [this._alias]);
|
||||||
|
static const VerificationMeta _idMeta = const VerificationMeta('id');
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<String> id = GeneratedColumn<String>(
|
||||||
|
'id',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.string,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
);
|
||||||
|
static const VerificationMeta _sourceIdMeta = const VerificationMeta(
|
||||||
|
'sourceId',
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<String> sourceId = GeneratedColumn<String>(
|
||||||
|
'source_id',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.string,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
);
|
||||||
|
static const VerificationMeta _contentMeta = const VerificationMeta(
|
||||||
|
'content',
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<String> content = GeneratedColumn<String>(
|
||||||
|
'content',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.string,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
);
|
||||||
|
static const VerificationMeta _embeddingMeta = const VerificationMeta(
|
||||||
|
'embedding',
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<String> embedding = GeneratedColumn<String>(
|
||||||
|
'embedding',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.string,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
);
|
||||||
|
static const VerificationMeta _createdAtMeta = const VerificationMeta(
|
||||||
|
'createdAt',
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<String> createdAt = GeneratedColumn<String>(
|
||||||
|
'created_at',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.string,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
List<GeneratedColumn> get $columns => [
|
||||||
|
id,
|
||||||
|
sourceId,
|
||||||
|
content,
|
||||||
|
embedding,
|
||||||
|
createdAt,
|
||||||
|
];
|
||||||
|
@override
|
||||||
|
String get aliasedName => _alias ?? actualTableName;
|
||||||
|
@override
|
||||||
|
String get actualTableName => $name;
|
||||||
|
static const String $name = 'knowledge_chunks';
|
||||||
|
@override
|
||||||
|
VerificationContext validateIntegrity(
|
||||||
|
Insertable<KnowledgeChunk> instance, {
|
||||||
|
bool isInserting = false,
|
||||||
|
}) {
|
||||||
|
final context = VerificationContext();
|
||||||
|
final data = instance.toColumns(true);
|
||||||
|
if (data.containsKey('id')) {
|
||||||
|
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
|
||||||
|
} else if (isInserting) {
|
||||||
|
context.missing(_idMeta);
|
||||||
|
}
|
||||||
|
if (data.containsKey('source_id')) {
|
||||||
|
context.handle(
|
||||||
|
_sourceIdMeta,
|
||||||
|
sourceId.isAcceptableOrUnknown(data['source_id']!, _sourceIdMeta),
|
||||||
|
);
|
||||||
|
} else if (isInserting) {
|
||||||
|
context.missing(_sourceIdMeta);
|
||||||
|
}
|
||||||
|
if (data.containsKey('content')) {
|
||||||
|
context.handle(
|
||||||
|
_contentMeta,
|
||||||
|
content.isAcceptableOrUnknown(data['content']!, _contentMeta),
|
||||||
|
);
|
||||||
|
} else if (isInserting) {
|
||||||
|
context.missing(_contentMeta);
|
||||||
|
}
|
||||||
|
if (data.containsKey('embedding')) {
|
||||||
|
context.handle(
|
||||||
|
_embeddingMeta,
|
||||||
|
embedding.isAcceptableOrUnknown(data['embedding']!, _embeddingMeta),
|
||||||
|
);
|
||||||
|
} else if (isInserting) {
|
||||||
|
context.missing(_embeddingMeta);
|
||||||
|
}
|
||||||
|
if (data.containsKey('created_at')) {
|
||||||
|
context.handle(
|
||||||
|
_createdAtMeta,
|
||||||
|
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta),
|
||||||
|
);
|
||||||
|
} else if (isInserting) {
|
||||||
|
context.missing(_createdAtMeta);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<GeneratedColumn> get $primaryKey => {id};
|
||||||
|
@override
|
||||||
|
KnowledgeChunk map(Map<String, dynamic> data, {String? tablePrefix}) {
|
||||||
|
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
|
||||||
|
return KnowledgeChunk(
|
||||||
|
id: attachedDatabase.typeMapping.read(
|
||||||
|
DriftSqlType.string,
|
||||||
|
data['${effectivePrefix}id'],
|
||||||
|
)!,
|
||||||
|
sourceId: attachedDatabase.typeMapping.read(
|
||||||
|
DriftSqlType.string,
|
||||||
|
data['${effectivePrefix}source_id'],
|
||||||
|
)!,
|
||||||
|
content: attachedDatabase.typeMapping.read(
|
||||||
|
DriftSqlType.string,
|
||||||
|
data['${effectivePrefix}content'],
|
||||||
|
)!,
|
||||||
|
embedding: attachedDatabase.typeMapping.read(
|
||||||
|
DriftSqlType.string,
|
||||||
|
data['${effectivePrefix}embedding'],
|
||||||
|
)!,
|
||||||
|
createdAt: attachedDatabase.typeMapping.read(
|
||||||
|
DriftSqlType.string,
|
||||||
|
data['${effectivePrefix}created_at'],
|
||||||
|
)!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
$KnowledgeChunksTable createAlias(String alias) {
|
||||||
|
return $KnowledgeChunksTable(attachedDatabase, alias);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class KnowledgeChunk extends DataClass implements Insertable<KnowledgeChunk> {
|
||||||
|
/// UUID for this individual chunk.
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
/// All chunks from the same `addNote()` call share this ID.
|
||||||
|
final String sourceId;
|
||||||
|
|
||||||
|
/// The raw text of the chunk (max ~500 chars).
|
||||||
|
final String content;
|
||||||
|
|
||||||
|
/// JSON-encoded `List<double>` — the 768-dim Nomic embedding vector.
|
||||||
|
final String embedding;
|
||||||
|
final String createdAt;
|
||||||
|
const KnowledgeChunk({
|
||||||
|
required this.id,
|
||||||
|
required this.sourceId,
|
||||||
|
required this.content,
|
||||||
|
required this.embedding,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
@override
|
||||||
|
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||||
|
final map = <String, Expression>{};
|
||||||
|
map['id'] = Variable<String>(id);
|
||||||
|
map['source_id'] = Variable<String>(sourceId);
|
||||||
|
map['content'] = Variable<String>(content);
|
||||||
|
map['embedding'] = Variable<String>(embedding);
|
||||||
|
map['created_at'] = Variable<String>(createdAt);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
KnowledgeChunksCompanion toCompanion(bool nullToAbsent) {
|
||||||
|
return KnowledgeChunksCompanion(
|
||||||
|
id: Value(id),
|
||||||
|
sourceId: Value(sourceId),
|
||||||
|
content: Value(content),
|
||||||
|
embedding: Value(embedding),
|
||||||
|
createdAt: Value(createdAt),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory KnowledgeChunk.fromJson(
|
||||||
|
Map<String, dynamic> json, {
|
||||||
|
ValueSerializer? serializer,
|
||||||
|
}) {
|
||||||
|
serializer ??= driftRuntimeOptions.defaultSerializer;
|
||||||
|
return KnowledgeChunk(
|
||||||
|
id: serializer.fromJson<String>(json['id']),
|
||||||
|
sourceId: serializer.fromJson<String>(json['sourceId']),
|
||||||
|
content: serializer.fromJson<String>(json['content']),
|
||||||
|
embedding: serializer.fromJson<String>(json['embedding']),
|
||||||
|
createdAt: serializer.fromJson<String>(json['createdAt']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
|
||||||
|
serializer ??= driftRuntimeOptions.defaultSerializer;
|
||||||
|
return <String, dynamic>{
|
||||||
|
'id': serializer.toJson<String>(id),
|
||||||
|
'sourceId': serializer.toJson<String>(sourceId),
|
||||||
|
'content': serializer.toJson<String>(content),
|
||||||
|
'embedding': serializer.toJson<String>(embedding),
|
||||||
|
'createdAt': serializer.toJson<String>(createdAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
KnowledgeChunk copyWith({
|
||||||
|
String? id,
|
||||||
|
String? sourceId,
|
||||||
|
String? content,
|
||||||
|
String? embedding,
|
||||||
|
String? createdAt,
|
||||||
|
}) => KnowledgeChunk(
|
||||||
|
id: id ?? this.id,
|
||||||
|
sourceId: sourceId ?? this.sourceId,
|
||||||
|
content: content ?? this.content,
|
||||||
|
embedding: embedding ?? this.embedding,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
);
|
||||||
|
KnowledgeChunk copyWithCompanion(KnowledgeChunksCompanion data) {
|
||||||
|
return KnowledgeChunk(
|
||||||
|
id: data.id.present ? data.id.value : this.id,
|
||||||
|
sourceId: data.sourceId.present ? data.sourceId.value : this.sourceId,
|
||||||
|
content: data.content.present ? data.content.value : this.content,
|
||||||
|
embedding: data.embedding.present ? data.embedding.value : this.embedding,
|
||||||
|
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return (StringBuffer('KnowledgeChunk(')
|
||||||
|
..write('id: $id, ')
|
||||||
|
..write('sourceId: $sourceId, ')
|
||||||
|
..write('content: $content, ')
|
||||||
|
..write('embedding: $embedding, ')
|
||||||
|
..write('createdAt: $createdAt')
|
||||||
|
..write(')'))
|
||||||
|
.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(id, sourceId, content, embedding, createdAt);
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
(other is KnowledgeChunk &&
|
||||||
|
other.id == this.id &&
|
||||||
|
other.sourceId == this.sourceId &&
|
||||||
|
other.content == this.content &&
|
||||||
|
other.embedding == this.embedding &&
|
||||||
|
other.createdAt == this.createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
class KnowledgeChunksCompanion extends UpdateCompanion<KnowledgeChunk> {
|
||||||
|
final Value<String> id;
|
||||||
|
final Value<String> sourceId;
|
||||||
|
final Value<String> content;
|
||||||
|
final Value<String> embedding;
|
||||||
|
final Value<String> createdAt;
|
||||||
|
final Value<int> rowid;
|
||||||
|
const KnowledgeChunksCompanion({
|
||||||
|
this.id = const Value.absent(),
|
||||||
|
this.sourceId = const Value.absent(),
|
||||||
|
this.content = const Value.absent(),
|
||||||
|
this.embedding = const Value.absent(),
|
||||||
|
this.createdAt = const Value.absent(),
|
||||||
|
this.rowid = const Value.absent(),
|
||||||
|
});
|
||||||
|
KnowledgeChunksCompanion.insert({
|
||||||
|
required String id,
|
||||||
|
required String sourceId,
|
||||||
|
required String content,
|
||||||
|
required String embedding,
|
||||||
|
required String createdAt,
|
||||||
|
this.rowid = const Value.absent(),
|
||||||
|
}) : id = Value(id),
|
||||||
|
sourceId = Value(sourceId),
|
||||||
|
content = Value(content),
|
||||||
|
embedding = Value(embedding),
|
||||||
|
createdAt = Value(createdAt);
|
||||||
|
static Insertable<KnowledgeChunk> custom({
|
||||||
|
Expression<String>? id,
|
||||||
|
Expression<String>? sourceId,
|
||||||
|
Expression<String>? content,
|
||||||
|
Expression<String>? embedding,
|
||||||
|
Expression<String>? createdAt,
|
||||||
|
Expression<int>? rowid,
|
||||||
|
}) {
|
||||||
|
return RawValuesInsertable({
|
||||||
|
if (id != null) 'id': id,
|
||||||
|
if (sourceId != null) 'source_id': sourceId,
|
||||||
|
if (content != null) 'content': content,
|
||||||
|
if (embedding != null) 'embedding': embedding,
|
||||||
|
if (createdAt != null) 'created_at': createdAt,
|
||||||
|
if (rowid != null) 'rowid': rowid,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
KnowledgeChunksCompanion copyWith({
|
||||||
|
Value<String>? id,
|
||||||
|
Value<String>? sourceId,
|
||||||
|
Value<String>? content,
|
||||||
|
Value<String>? embedding,
|
||||||
|
Value<String>? createdAt,
|
||||||
|
Value<int>? rowid,
|
||||||
|
}) {
|
||||||
|
return KnowledgeChunksCompanion(
|
||||||
|
id: id ?? this.id,
|
||||||
|
sourceId: sourceId ?? this.sourceId,
|
||||||
|
content: content ?? this.content,
|
||||||
|
embedding: embedding ?? this.embedding,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
rowid: rowid ?? this.rowid,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||||
|
final map = <String, Expression>{};
|
||||||
|
if (id.present) {
|
||||||
|
map['id'] = Variable<String>(id.value);
|
||||||
|
}
|
||||||
|
if (sourceId.present) {
|
||||||
|
map['source_id'] = Variable<String>(sourceId.value);
|
||||||
|
}
|
||||||
|
if (content.present) {
|
||||||
|
map['content'] = Variable<String>(content.value);
|
||||||
|
}
|
||||||
|
if (embedding.present) {
|
||||||
|
map['embedding'] = Variable<String>(embedding.value);
|
||||||
|
}
|
||||||
|
if (createdAt.present) {
|
||||||
|
map['created_at'] = Variable<String>(createdAt.value);
|
||||||
|
}
|
||||||
|
if (rowid.present) {
|
||||||
|
map['rowid'] = Variable<int>(rowid.value);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return (StringBuffer('KnowledgeChunksCompanion(')
|
||||||
|
..write('id: $id, ')
|
||||||
|
..write('sourceId: $sourceId, ')
|
||||||
|
..write('content: $content, ')
|
||||||
|
..write('embedding: $embedding, ')
|
||||||
|
..write('createdAt: $createdAt, ')
|
||||||
|
..write('rowid: $rowid')
|
||||||
|
..write(')'))
|
||||||
|
.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
abstract class _$AppDatabase extends GeneratedDatabase {
|
abstract class _$AppDatabase extends GeneratedDatabase {
|
||||||
_$AppDatabase(QueryExecutor e) : super(e);
|
_$AppDatabase(QueryExecutor e) : super(e);
|
||||||
$AppDatabaseManager get managers => $AppDatabaseManager(this);
|
$AppDatabaseManager get managers => $AppDatabaseManager(this);
|
||||||
@@ -3266,6 +3635,9 @@ abstract class _$AppDatabase extends GeneratedDatabase {
|
|||||||
late final $AnnotationsTable annotations = $AnnotationsTable(this);
|
late final $AnnotationsTable annotations = $AnnotationsTable(this);
|
||||||
late final $ChatSessionsTable chatSessions = $ChatSessionsTable(this);
|
late final $ChatSessionsTable chatSessions = $ChatSessionsTable(this);
|
||||||
late final $ChatMessagesTable chatMessages = $ChatMessagesTable(this);
|
late final $ChatMessagesTable chatMessages = $ChatMessagesTable(this);
|
||||||
|
late final $KnowledgeChunksTable knowledgeChunks = $KnowledgeChunksTable(
|
||||||
|
this,
|
||||||
|
);
|
||||||
late final ExerciseDao exerciseDao = ExerciseDao(this as AppDatabase);
|
late final ExerciseDao exerciseDao = ExerciseDao(this as AppDatabase);
|
||||||
late final TrainingPlanDao trainingPlanDao = TrainingPlanDao(
|
late final TrainingPlanDao trainingPlanDao = TrainingPlanDao(
|
||||||
this as AppDatabase,
|
this as AppDatabase,
|
||||||
@@ -3273,6 +3645,9 @@ abstract class _$AppDatabase extends GeneratedDatabase {
|
|||||||
late final ProgramDao programDao = ProgramDao(this as AppDatabase);
|
late final ProgramDao programDao = ProgramDao(this as AppDatabase);
|
||||||
late final AnalysisDao analysisDao = AnalysisDao(this as AppDatabase);
|
late final AnalysisDao analysisDao = AnalysisDao(this as AppDatabase);
|
||||||
late final ChatDao chatDao = ChatDao(this as AppDatabase);
|
late final ChatDao chatDao = ChatDao(this as AppDatabase);
|
||||||
|
late final KnowledgeChunkDao knowledgeChunkDao = KnowledgeChunkDao(
|
||||||
|
this as AppDatabase,
|
||||||
|
);
|
||||||
@override
|
@override
|
||||||
Iterable<TableInfo<Table, Object?>> get allTables =>
|
Iterable<TableInfo<Table, Object?>> get allTables =>
|
||||||
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
|
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
|
||||||
@@ -3287,6 +3662,7 @@ abstract class _$AppDatabase extends GeneratedDatabase {
|
|||||||
annotations,
|
annotations,
|
||||||
chatSessions,
|
chatSessions,
|
||||||
chatMessages,
|
chatMessages,
|
||||||
|
knowledgeChunks,
|
||||||
];
|
];
|
||||||
@override
|
@override
|
||||||
StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([
|
StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([
|
||||||
@@ -6224,6 +6600,212 @@ typedef $$ChatMessagesTableProcessedTableManager =
|
|||||||
ChatMessage,
|
ChatMessage,
|
||||||
PrefetchHooks Function({bool sessionId})
|
PrefetchHooks Function({bool sessionId})
|
||||||
>;
|
>;
|
||||||
|
typedef $$KnowledgeChunksTableCreateCompanionBuilder =
|
||||||
|
KnowledgeChunksCompanion Function({
|
||||||
|
required String id,
|
||||||
|
required String sourceId,
|
||||||
|
required String content,
|
||||||
|
required String embedding,
|
||||||
|
required String createdAt,
|
||||||
|
Value<int> rowid,
|
||||||
|
});
|
||||||
|
typedef $$KnowledgeChunksTableUpdateCompanionBuilder =
|
||||||
|
KnowledgeChunksCompanion Function({
|
||||||
|
Value<String> id,
|
||||||
|
Value<String> sourceId,
|
||||||
|
Value<String> content,
|
||||||
|
Value<String> embedding,
|
||||||
|
Value<String> createdAt,
|
||||||
|
Value<int> rowid,
|
||||||
|
});
|
||||||
|
|
||||||
|
class $$KnowledgeChunksTableFilterComposer
|
||||||
|
extends Composer<_$AppDatabase, $KnowledgeChunksTable> {
|
||||||
|
$$KnowledgeChunksTableFilterComposer({
|
||||||
|
required super.$db,
|
||||||
|
required super.$table,
|
||||||
|
super.joinBuilder,
|
||||||
|
super.$addJoinBuilderToRootComposer,
|
||||||
|
super.$removeJoinBuilderFromRootComposer,
|
||||||
|
});
|
||||||
|
ColumnFilters<String> get id => $composableBuilder(
|
||||||
|
column: $table.id,
|
||||||
|
builder: (column) => ColumnFilters(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
ColumnFilters<String> get sourceId => $composableBuilder(
|
||||||
|
column: $table.sourceId,
|
||||||
|
builder: (column) => ColumnFilters(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
ColumnFilters<String> get content => $composableBuilder(
|
||||||
|
column: $table.content,
|
||||||
|
builder: (column) => ColumnFilters(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
ColumnFilters<String> get embedding => $composableBuilder(
|
||||||
|
column: $table.embedding,
|
||||||
|
builder: (column) => ColumnFilters(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
ColumnFilters<String> get createdAt => $composableBuilder(
|
||||||
|
column: $table.createdAt,
|
||||||
|
builder: (column) => ColumnFilters(column),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class $$KnowledgeChunksTableOrderingComposer
|
||||||
|
extends Composer<_$AppDatabase, $KnowledgeChunksTable> {
|
||||||
|
$$KnowledgeChunksTableOrderingComposer({
|
||||||
|
required super.$db,
|
||||||
|
required super.$table,
|
||||||
|
super.joinBuilder,
|
||||||
|
super.$addJoinBuilderToRootComposer,
|
||||||
|
super.$removeJoinBuilderFromRootComposer,
|
||||||
|
});
|
||||||
|
ColumnOrderings<String> get id => $composableBuilder(
|
||||||
|
column: $table.id,
|
||||||
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
ColumnOrderings<String> get sourceId => $composableBuilder(
|
||||||
|
column: $table.sourceId,
|
||||||
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
ColumnOrderings<String> get content => $composableBuilder(
|
||||||
|
column: $table.content,
|
||||||
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
ColumnOrderings<String> get embedding => $composableBuilder(
|
||||||
|
column: $table.embedding,
|
||||||
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
ColumnOrderings<String> get createdAt => $composableBuilder(
|
||||||
|
column: $table.createdAt,
|
||||||
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class $$KnowledgeChunksTableAnnotationComposer
|
||||||
|
extends Composer<_$AppDatabase, $KnowledgeChunksTable> {
|
||||||
|
$$KnowledgeChunksTableAnnotationComposer({
|
||||||
|
required super.$db,
|
||||||
|
required super.$table,
|
||||||
|
super.joinBuilder,
|
||||||
|
super.$addJoinBuilderToRootComposer,
|
||||||
|
super.$removeJoinBuilderFromRootComposer,
|
||||||
|
});
|
||||||
|
GeneratedColumn<String> get id =>
|
||||||
|
$composableBuilder(column: $table.id, builder: (column) => column);
|
||||||
|
|
||||||
|
GeneratedColumn<String> get sourceId =>
|
||||||
|
$composableBuilder(column: $table.sourceId, builder: (column) => column);
|
||||||
|
|
||||||
|
GeneratedColumn<String> get content =>
|
||||||
|
$composableBuilder(column: $table.content, builder: (column) => column);
|
||||||
|
|
||||||
|
GeneratedColumn<String> get embedding =>
|
||||||
|
$composableBuilder(column: $table.embedding, builder: (column) => column);
|
||||||
|
|
||||||
|
GeneratedColumn<String> get createdAt =>
|
||||||
|
$composableBuilder(column: $table.createdAt, builder: (column) => column);
|
||||||
|
}
|
||||||
|
|
||||||
|
class $$KnowledgeChunksTableTableManager
|
||||||
|
extends
|
||||||
|
RootTableManager<
|
||||||
|
_$AppDatabase,
|
||||||
|
$KnowledgeChunksTable,
|
||||||
|
KnowledgeChunk,
|
||||||
|
$$KnowledgeChunksTableFilterComposer,
|
||||||
|
$$KnowledgeChunksTableOrderingComposer,
|
||||||
|
$$KnowledgeChunksTableAnnotationComposer,
|
||||||
|
$$KnowledgeChunksTableCreateCompanionBuilder,
|
||||||
|
$$KnowledgeChunksTableUpdateCompanionBuilder,
|
||||||
|
(
|
||||||
|
KnowledgeChunk,
|
||||||
|
BaseReferences<
|
||||||
|
_$AppDatabase,
|
||||||
|
$KnowledgeChunksTable,
|
||||||
|
KnowledgeChunk
|
||||||
|
>,
|
||||||
|
),
|
||||||
|
KnowledgeChunk,
|
||||||
|
PrefetchHooks Function()
|
||||||
|
> {
|
||||||
|
$$KnowledgeChunksTableTableManager(
|
||||||
|
_$AppDatabase db,
|
||||||
|
$KnowledgeChunksTable table,
|
||||||
|
) : super(
|
||||||
|
TableManagerState(
|
||||||
|
db: db,
|
||||||
|
table: table,
|
||||||
|
createFilteringComposer: () =>
|
||||||
|
$$KnowledgeChunksTableFilterComposer($db: db, $table: table),
|
||||||
|
createOrderingComposer: () =>
|
||||||
|
$$KnowledgeChunksTableOrderingComposer($db: db, $table: table),
|
||||||
|
createComputedFieldComposer: () =>
|
||||||
|
$$KnowledgeChunksTableAnnotationComposer($db: db, $table: table),
|
||||||
|
updateCompanionCallback:
|
||||||
|
({
|
||||||
|
Value<String> id = const Value.absent(),
|
||||||
|
Value<String> sourceId = const Value.absent(),
|
||||||
|
Value<String> content = const Value.absent(),
|
||||||
|
Value<String> embedding = const Value.absent(),
|
||||||
|
Value<String> createdAt = const Value.absent(),
|
||||||
|
Value<int> rowid = const Value.absent(),
|
||||||
|
}) => KnowledgeChunksCompanion(
|
||||||
|
id: id,
|
||||||
|
sourceId: sourceId,
|
||||||
|
content: content,
|
||||||
|
embedding: embedding,
|
||||||
|
createdAt: createdAt,
|
||||||
|
rowid: rowid,
|
||||||
|
),
|
||||||
|
createCompanionCallback:
|
||||||
|
({
|
||||||
|
required String id,
|
||||||
|
required String sourceId,
|
||||||
|
required String content,
|
||||||
|
required String embedding,
|
||||||
|
required String createdAt,
|
||||||
|
Value<int> rowid = const Value.absent(),
|
||||||
|
}) => KnowledgeChunksCompanion.insert(
|
||||||
|
id: id,
|
||||||
|
sourceId: sourceId,
|
||||||
|
content: content,
|
||||||
|
embedding: embedding,
|
||||||
|
createdAt: createdAt,
|
||||||
|
rowid: rowid,
|
||||||
|
),
|
||||||
|
withReferenceMapper: (p0) => p0
|
||||||
|
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
|
||||||
|
.toList(),
|
||||||
|
prefetchHooksCallback: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef $$KnowledgeChunksTableProcessedTableManager =
|
||||||
|
ProcessedTableManager<
|
||||||
|
_$AppDatabase,
|
||||||
|
$KnowledgeChunksTable,
|
||||||
|
KnowledgeChunk,
|
||||||
|
$$KnowledgeChunksTableFilterComposer,
|
||||||
|
$$KnowledgeChunksTableOrderingComposer,
|
||||||
|
$$KnowledgeChunksTableAnnotationComposer,
|
||||||
|
$$KnowledgeChunksTableCreateCompanionBuilder,
|
||||||
|
$$KnowledgeChunksTableUpdateCompanionBuilder,
|
||||||
|
(
|
||||||
|
KnowledgeChunk,
|
||||||
|
BaseReferences<_$AppDatabase, $KnowledgeChunksTable, KnowledgeChunk>,
|
||||||
|
),
|
||||||
|
KnowledgeChunk,
|
||||||
|
PrefetchHooks Function()
|
||||||
|
>;
|
||||||
|
|
||||||
class $AppDatabaseManager {
|
class $AppDatabaseManager {
|
||||||
final _$AppDatabase _db;
|
final _$AppDatabase _db;
|
||||||
@@ -6246,4 +6828,6 @@ class $AppDatabaseManager {
|
|||||||
$$ChatSessionsTableTableManager(_db, _db.chatSessions);
|
$$ChatSessionsTableTableManager(_db, _db.chatSessions);
|
||||||
$$ChatMessagesTableTableManager get chatMessages =>
|
$$ChatMessagesTableTableManager get chatMessages =>
|
||||||
$$ChatMessagesTableTableManager(_db, _db.chatMessages);
|
$$ChatMessagesTableTableManager(_db, _db.chatMessages);
|
||||||
|
$$KnowledgeChunksTableTableManager get knowledgeChunks =>
|
||||||
|
$$KnowledgeChunksTableTableManager(_db, _db.knowledgeChunks);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,20 @@ class AnalysisDao extends DatabaseAccessor<AppDatabase>
|
|||||||
Future<void> insertAnnotation(AnnotationsCompanion entry) =>
|
Future<void> insertAnnotation(AnnotationsCompanion entry) =>
|
||||||
into(annotations).insert(entry);
|
into(annotations).insert(entry);
|
||||||
|
|
||||||
|
Future<void> updateAnnotation({
|
||||||
|
required String id,
|
||||||
|
required String name,
|
||||||
|
required String description,
|
||||||
|
required String color,
|
||||||
|
}) =>
|
||||||
|
(update(annotations)..where((t) => t.id.equals(id))).write(
|
||||||
|
AnnotationsCompanion(
|
||||||
|
name: Value(name),
|
||||||
|
description: Value(description),
|
||||||
|
color: Value(color),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
Future<void> deleteAnnotation(String id) =>
|
Future<void> deleteAnnotation(String id) =>
|
||||||
(delete(annotations)..where((t) => t.id.equals(id))).go();
|
(delete(annotations)..where((t) => t.id.equals(id))).go();
|
||||||
}
|
}
|
||||||
|
|||||||
35
lib/data/database/daos/knowledge_chunk_dao.dart
Normal file
35
lib/data/database/daos/knowledge_chunk_dao.dart
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:trainhub_flutter/data/database/app_database.dart';
|
||||||
|
|
||||||
|
part 'knowledge_chunk_dao.g.dart';
|
||||||
|
|
||||||
|
@DriftAccessor(tables: [KnowledgeChunks])
|
||||||
|
class KnowledgeChunkDao extends DatabaseAccessor<AppDatabase>
|
||||||
|
with _$KnowledgeChunkDaoMixin {
|
||||||
|
KnowledgeChunkDao(super.db);
|
||||||
|
|
||||||
|
Future<void> insertChunk(KnowledgeChunksCompanion entry) =>
|
||||||
|
into(knowledgeChunks).insert(entry);
|
||||||
|
|
||||||
|
/// Returns every stored chunk, including its JSON-encoded embedding.
|
||||||
|
/// Loaded into memory for in-process cosine similarity scoring.
|
||||||
|
Future<List<KnowledgeChunk>> getAllChunks() =>
|
||||||
|
(select(knowledgeChunks)
|
||||||
|
..orderBy([
|
||||||
|
(t) =>
|
||||||
|
OrderingTerm(expression: t.createdAt, mode: OrderingMode.asc)
|
||||||
|
]))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
Future<int> getCount() async {
|
||||||
|
final rows = await select(knowledgeChunks).get();
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteAll() => delete(knowledgeChunks).go();
|
||||||
|
|
||||||
|
Future<void> deleteBySourceId(String sourceId) =>
|
||||||
|
(delete(knowledgeChunks)
|
||||||
|
..where((t) => t.sourceId.equals(sourceId)))
|
||||||
|
.go();
|
||||||
|
}
|
||||||
8
lib/data/database/daos/knowledge_chunk_dao.g.dart
Normal file
8
lib/data/database/daos/knowledge_chunk_dao.g.dart
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'knowledge_chunk_dao.dart';
|
||||||
|
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
mixin _$KnowledgeChunkDaoMixin on DatabaseAccessor<AppDatabase> {
|
||||||
|
$KnowledgeChunksTable get knowledgeChunks => attachedDatabase.knowledgeChunks;
|
||||||
|
}
|
||||||
@@ -83,6 +83,21 @@ class AnalysisRepositoryImpl implements AnalysisRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updateAnnotation({
|
||||||
|
required String id,
|
||||||
|
required String name,
|
||||||
|
required String description,
|
||||||
|
required String color,
|
||||||
|
}) async {
|
||||||
|
await _dao.updateAnnotation(
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
description: description,
|
||||||
|
color: color,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> deleteAnnotation(String id) async {
|
Future<void> deleteAnnotation(String id) async {
|
||||||
await _dao.deleteAnnotation(id);
|
await _dao.deleteAnnotation(id);
|
||||||
|
|||||||
142
lib/data/repositories/note_repository_impl.dart
Normal file
142
lib/data/repositories/note_repository_impl.dart
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:math' show sqrt;
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:trainhub_flutter/data/database/app_database.dart';
|
||||||
|
import 'package:trainhub_flutter/data/database/daos/knowledge_chunk_dao.dart';
|
||||||
|
import 'package:trainhub_flutter/domain/repositories/note_repository.dart';
|
||||||
|
import 'package:trainhub_flutter/data/services/embedding_service.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
const _uuid = Uuid();
|
||||||
|
|
||||||
|
class NoteRepositoryImpl implements NoteRepository {
|
||||||
|
NoteRepositoryImpl(this._dao, this._embeddingService);
|
||||||
|
|
||||||
|
final KnowledgeChunkDao _dao;
|
||||||
|
final EmbeddingService _embeddingService;
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Public interface
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> addNote(String text) async {
|
||||||
|
final chunks = _chunkText(text);
|
||||||
|
if (chunks.isEmpty) return;
|
||||||
|
|
||||||
|
final sourceId = _uuid.v4();
|
||||||
|
final now = DateTime.now().toIso8601String();
|
||||||
|
|
||||||
|
for (final chunk in chunks) {
|
||||||
|
final embedding = await _embeddingService.embed(chunk);
|
||||||
|
await _dao.insertChunk(
|
||||||
|
KnowledgeChunksCompanion(
|
||||||
|
id: Value(_uuid.v4()),
|
||||||
|
sourceId: Value(sourceId),
|
||||||
|
content: Value(chunk),
|
||||||
|
embedding: Value(jsonEncode(embedding)),
|
||||||
|
createdAt: Value(now),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<String>> searchSimilar(String query, {int topK = 3}) async {
|
||||||
|
final allRows = await _dao.getAllChunks();
|
||||||
|
if (allRows.isEmpty) return [];
|
||||||
|
|
||||||
|
final queryEmbedding = await _embeddingService.embed(query);
|
||||||
|
|
||||||
|
final scored = allRows.map((row) {
|
||||||
|
final emb =
|
||||||
|
(jsonDecode(row.embedding) as List<dynamic>)
|
||||||
|
.map((e) => (e as num).toDouble())
|
||||||
|
.toList();
|
||||||
|
return _Scored(
|
||||||
|
score: _cosineSimilarity(queryEmbedding, emb),
|
||||||
|
text: row.content,
|
||||||
|
);
|
||||||
|
}).toList()
|
||||||
|
..sort((a, b) => b.score.compareTo(a.score));
|
||||||
|
|
||||||
|
return scored.take(topK).map((s) => s.text).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> getChunkCount() => _dao.getCount();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> clearAll() => _dao.deleteAll();
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Text chunking
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Splits [text] into semantically meaningful chunks of at most [maxChars].
|
||||||
|
/// Strategy:
|
||||||
|
/// 1. Split by blank lines (paragraph boundaries).
|
||||||
|
/// 2. If a paragraph is still too long, split further by sentence.
|
||||||
|
/// 3. Accumulate sentences until the chunk would exceed [maxChars].
|
||||||
|
static List<String> _chunkText(String text, {int maxChars = 500}) {
|
||||||
|
final chunks = <String>[];
|
||||||
|
|
||||||
|
for (final paragraph in text.split(RegExp(r'\n{2,}'))) {
|
||||||
|
final p = paragraph.trim();
|
||||||
|
if (p.isEmpty) continue;
|
||||||
|
|
||||||
|
if (p.length <= maxChars) {
|
||||||
|
chunks.add(p);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split long paragraph by sentence boundaries (. ! ?)
|
||||||
|
final sentences =
|
||||||
|
p.split(RegExp(r'(?<=[.!?])\s+'));
|
||||||
|
var current = '';
|
||||||
|
|
||||||
|
for (final sentence in sentences) {
|
||||||
|
final candidate =
|
||||||
|
current.isEmpty ? sentence : '$current $sentence';
|
||||||
|
if (candidate.length <= maxChars) {
|
||||||
|
current = candidate;
|
||||||
|
} else {
|
||||||
|
if (current.isNotEmpty) chunks.add(current);
|
||||||
|
// If a single sentence is longer than maxChars, include it as-is
|
||||||
|
// rather than discarding it.
|
||||||
|
current = sentence.length > maxChars ? '' : sentence;
|
||||||
|
if (sentence.length > maxChars) chunks.add(sentence);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current.isNotEmpty) chunks.add(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Cosine similarity
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static double _cosineSimilarity(List<double> a, List<double> b) {
|
||||||
|
var dot = 0.0;
|
||||||
|
var normA = 0.0;
|
||||||
|
var normB = 0.0;
|
||||||
|
final len = a.length < b.length ? a.length : b.length;
|
||||||
|
for (var i = 0; i < len; i++) {
|
||||||
|
dot += a[i] * b[i];
|
||||||
|
normA += a[i] * a[i];
|
||||||
|
normB += b[i] * b[i];
|
||||||
|
}
|
||||||
|
if (normA == 0.0 || normB == 0.0) return 0.0;
|
||||||
|
return dot / (sqrt(normA) * sqrt(normB));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple value holder used for sorting — not exported.
|
||||||
|
class _Scored {
|
||||||
|
const _Scored({required this.score, required this.text});
|
||||||
|
final double score;
|
||||||
|
final String text;
|
||||||
|
}
|
||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
lib/data/services/embedding_service.dart
Normal file
30
lib/data/services/embedding_service.dart
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:trainhub_flutter/core/constants/ai_constants.dart';
|
||||||
|
|
||||||
|
/// Wraps the Nomic embedding server (llama.cpp).
|
||||||
|
/// Returns a 768-dimensional float vector for any input text.
|
||||||
|
class EmbeddingService {
|
||||||
|
final _dio = Dio(
|
||||||
|
BaseOptions(
|
||||||
|
connectTimeout: AiConstants.embeddingConnectTimeout,
|
||||||
|
receiveTimeout: AiConstants.embeddingReceiveTimeout,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Returns the embedding vector for [text].
|
||||||
|
/// Throws a [DioException] if the Nomic server is unreachable.
|
||||||
|
Future<List<double>> embed(String text) async {
|
||||||
|
final response = await _dio.post<Map<String, dynamic>>(
|
||||||
|
AiConstants.embeddingApiUrl,
|
||||||
|
data: {
|
||||||
|
'input': text,
|
||||||
|
'model': AiConstants.nomicModelName,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final raw =
|
||||||
|
(response.data!['data'] as List<dynamic>)[0]['embedding']
|
||||||
|
as List<dynamic>;
|
||||||
|
return raw.map((e) => (e as num).toDouble()).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
16
lib/domain/entities/note_chunk.dart
Normal file
16
lib/domain/entities/note_chunk.dart
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'note_chunk.freezed.dart';
|
||||||
|
|
||||||
|
/// A single text chunk produced by splitting a trainer's note.
|
||||||
|
/// [sourceId] groups all chunks that came from the same original note
|
||||||
|
/// (useful for bulk deletion later).
|
||||||
|
@freezed
|
||||||
|
class NoteChunkEntity with _$NoteChunkEntity {
|
||||||
|
const factory NoteChunkEntity({
|
||||||
|
required String id,
|
||||||
|
required String text,
|
||||||
|
required String sourceId,
|
||||||
|
required String createdAt,
|
||||||
|
}) = _NoteChunkEntity;
|
||||||
|
}
|
||||||
215
lib/domain/entities/note_chunk.freezed.dart
Normal file
215
lib/domain/entities/note_chunk.freezed.dart
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
// coverage:ignore-file
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||||
|
|
||||||
|
part of 'note_chunk.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// FreezedGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
T _$identity<T>(T value) => value;
|
||||||
|
|
||||||
|
final _privateConstructorUsedError = UnsupportedError(
|
||||||
|
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
mixin _$NoteChunkEntity {
|
||||||
|
String get id => throw _privateConstructorUsedError;
|
||||||
|
String get text => throw _privateConstructorUsedError;
|
||||||
|
String get sourceId => throw _privateConstructorUsedError;
|
||||||
|
String get createdAt => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// Create a copy of NoteChunkEntity
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
$NoteChunkEntityCopyWith<NoteChunkEntity> get copyWith =>
|
||||||
|
throw _privateConstructorUsedError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract class $NoteChunkEntityCopyWith<$Res> {
|
||||||
|
factory $NoteChunkEntityCopyWith(
|
||||||
|
NoteChunkEntity value,
|
||||||
|
$Res Function(NoteChunkEntity) then,
|
||||||
|
) = _$NoteChunkEntityCopyWithImpl<$Res, NoteChunkEntity>;
|
||||||
|
@useResult
|
||||||
|
$Res call({String id, String text, String sourceId, String createdAt});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
class _$NoteChunkEntityCopyWithImpl<$Res, $Val extends NoteChunkEntity>
|
||||||
|
implements $NoteChunkEntityCopyWith<$Res> {
|
||||||
|
_$NoteChunkEntityCopyWithImpl(this._value, this._then);
|
||||||
|
|
||||||
|
// ignore: unused_field
|
||||||
|
final $Val _value;
|
||||||
|
// ignore: unused_field
|
||||||
|
final $Res Function($Val) _then;
|
||||||
|
|
||||||
|
/// Create a copy of NoteChunkEntity
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
@override
|
||||||
|
$Res call({
|
||||||
|
Object? id = null,
|
||||||
|
Object? text = null,
|
||||||
|
Object? sourceId = null,
|
||||||
|
Object? createdAt = null,
|
||||||
|
}) {
|
||||||
|
return _then(
|
||||||
|
_value.copyWith(
|
||||||
|
id: null == id
|
||||||
|
? _value.id
|
||||||
|
: id // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
text: null == text
|
||||||
|
? _value.text
|
||||||
|
: text // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
sourceId: null == sourceId
|
||||||
|
? _value.sourceId
|
||||||
|
: sourceId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
createdAt: null == createdAt
|
||||||
|
? _value.createdAt
|
||||||
|
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
)
|
||||||
|
as $Val,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract class _$$NoteChunkEntityImplCopyWith<$Res>
|
||||||
|
implements $NoteChunkEntityCopyWith<$Res> {
|
||||||
|
factory _$$NoteChunkEntityImplCopyWith(
|
||||||
|
_$NoteChunkEntityImpl value,
|
||||||
|
$Res Function(_$NoteChunkEntityImpl) then,
|
||||||
|
) = __$$NoteChunkEntityImplCopyWithImpl<$Res>;
|
||||||
|
@override
|
||||||
|
@useResult
|
||||||
|
$Res call({String id, String text, String sourceId, String createdAt});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
class __$$NoteChunkEntityImplCopyWithImpl<$Res>
|
||||||
|
extends _$NoteChunkEntityCopyWithImpl<$Res, _$NoteChunkEntityImpl>
|
||||||
|
implements _$$NoteChunkEntityImplCopyWith<$Res> {
|
||||||
|
__$$NoteChunkEntityImplCopyWithImpl(
|
||||||
|
_$NoteChunkEntityImpl _value,
|
||||||
|
$Res Function(_$NoteChunkEntityImpl) _then,
|
||||||
|
) : super(_value, _then);
|
||||||
|
|
||||||
|
/// Create a copy of NoteChunkEntity
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
@override
|
||||||
|
$Res call({
|
||||||
|
Object? id = null,
|
||||||
|
Object? text = null,
|
||||||
|
Object? sourceId = null,
|
||||||
|
Object? createdAt = null,
|
||||||
|
}) {
|
||||||
|
return _then(
|
||||||
|
_$NoteChunkEntityImpl(
|
||||||
|
id: null == id
|
||||||
|
? _value.id
|
||||||
|
: id // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
text: null == text
|
||||||
|
? _value.text
|
||||||
|
: text // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
sourceId: null == sourceId
|
||||||
|
? _value.sourceId
|
||||||
|
: sourceId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
createdAt: null == createdAt
|
||||||
|
? _value.createdAt
|
||||||
|
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
|
||||||
|
class _$NoteChunkEntityImpl implements _NoteChunkEntity {
|
||||||
|
const _$NoteChunkEntityImpl({
|
||||||
|
required this.id,
|
||||||
|
required this.text,
|
||||||
|
required this.sourceId,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
final String id;
|
||||||
|
@override
|
||||||
|
final String text;
|
||||||
|
@override
|
||||||
|
final String sourceId;
|
||||||
|
@override
|
||||||
|
final String createdAt;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'NoteChunkEntity(id: $id, text: $text, sourceId: $sourceId, createdAt: $createdAt)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) ||
|
||||||
|
(other.runtimeType == runtimeType &&
|
||||||
|
other is _$NoteChunkEntityImpl &&
|
||||||
|
(identical(other.id, id) || other.id == id) &&
|
||||||
|
(identical(other.text, text) || other.text == text) &&
|
||||||
|
(identical(other.sourceId, sourceId) ||
|
||||||
|
other.sourceId == sourceId) &&
|
||||||
|
(identical(other.createdAt, createdAt) ||
|
||||||
|
other.createdAt == createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(runtimeType, id, text, sourceId, createdAt);
|
||||||
|
|
||||||
|
/// Create a copy of NoteChunkEntity
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
_$$NoteChunkEntityImplCopyWith<_$NoteChunkEntityImpl> get copyWith =>
|
||||||
|
__$$NoteChunkEntityImplCopyWithImpl<_$NoteChunkEntityImpl>(
|
||||||
|
this,
|
||||||
|
_$identity,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _NoteChunkEntity implements NoteChunkEntity {
|
||||||
|
const factory _NoteChunkEntity({
|
||||||
|
required final String id,
|
||||||
|
required final String text,
|
||||||
|
required final String sourceId,
|
||||||
|
required final String createdAt,
|
||||||
|
}) = _$NoteChunkEntityImpl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get id;
|
||||||
|
@override
|
||||||
|
String get text;
|
||||||
|
@override
|
||||||
|
String get sourceId;
|
||||||
|
@override
|
||||||
|
String get createdAt;
|
||||||
|
|
||||||
|
/// Create a copy of NoteChunkEntity
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
_$$NoteChunkEntityImplCopyWith<_$NoteChunkEntityImpl> get copyWith =>
|
||||||
|
throw _privateConstructorUsedError;
|
||||||
|
}
|
||||||
@@ -15,5 +15,11 @@ abstract class AnalysisRepository {
|
|||||||
required double endTime,
|
required double endTime,
|
||||||
required String color,
|
required String color,
|
||||||
});
|
});
|
||||||
|
Future<void> updateAnnotation({
|
||||||
|
required String id,
|
||||||
|
required String name,
|
||||||
|
required String description,
|
||||||
|
required String color,
|
||||||
|
});
|
||||||
Future<void> deleteAnnotation(String id);
|
Future<void> deleteAnnotation(String id);
|
||||||
}
|
}
|
||||||
|
|||||||
21
lib/domain/repositories/note_repository.dart
Normal file
21
lib/domain/repositories/note_repository.dart
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/// Persistence interface for the trainer's knowledge base.
|
||||||
|
///
|
||||||
|
/// The implementation splits raw text into chunks, generates embeddings
|
||||||
|
/// via the Nomic server, stores them in the local database, and provides
|
||||||
|
/// semantic search for RAG context injection.
|
||||||
|
abstract class NoteRepository {
|
||||||
|
/// Splits [text] into overlapping chunks, generates an embedding for each,
|
||||||
|
/// and persists them under a shared [sourceId].
|
||||||
|
Future<void> addNote(String text);
|
||||||
|
|
||||||
|
/// Returns the [topK] most semantically similar chunk texts for [query].
|
||||||
|
/// Returns an empty list if no chunks are stored or the embedding server
|
||||||
|
/// is unavailable.
|
||||||
|
Future<List<String>> searchSimilar(String query, {int topK = 3});
|
||||||
|
|
||||||
|
/// Returns the total number of stored chunks.
|
||||||
|
Future<int> getChunkCount();
|
||||||
|
|
||||||
|
/// Deletes every stored chunk (full knowledge-base reset).
|
||||||
|
Future<void> clearAll();
|
||||||
|
}
|
||||||
@@ -15,10 +15,19 @@ import 'package:trainhub_flutter/data/repositories/training_plan_repository_impl
|
|||||||
import 'package:trainhub_flutter/data/repositories/program_repository_impl.dart';
|
import 'package:trainhub_flutter/data/repositories/program_repository_impl.dart';
|
||||||
import 'package:trainhub_flutter/data/repositories/analysis_repository_impl.dart';
|
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/domain/repositories/note_repository.dart';
|
||||||
|
import 'package:trainhub_flutter/data/services/ai_process_manager.dart';
|
||||||
|
import 'package:trainhub_flutter/data/services/embedding_service.dart';
|
||||||
|
import 'package:trainhub_flutter/data/database/daos/knowledge_chunk_dao.dart';
|
||||||
|
|
||||||
final GetIt getIt = GetIt.instance;
|
final GetIt getIt = GetIt.instance;
|
||||||
|
|
||||||
void init() {
|
void init() {
|
||||||
|
// AI Process Manager — must be registered before anything else so that the
|
||||||
|
// window lifecycle hook can always find it via getIt<AiProcessManager>().
|
||||||
|
getIt.registerSingleton<AiProcessManager>(AiProcessManager());
|
||||||
|
|
||||||
// Database
|
// Database
|
||||||
getIt.registerSingleton<AppDatabase>(AppDatabase());
|
getIt.registerSingleton<AppDatabase>(AppDatabase());
|
||||||
|
|
||||||
@@ -30,6 +39,12 @@ void init() {
|
|||||||
getIt.registerSingleton<ProgramDao>(ProgramDao(getIt<AppDatabase>()));
|
getIt.registerSingleton<ProgramDao>(ProgramDao(getIt<AppDatabase>()));
|
||||||
getIt.registerSingleton<AnalysisDao>(AnalysisDao(getIt<AppDatabase>()));
|
getIt.registerSingleton<AnalysisDao>(AnalysisDao(getIt<AppDatabase>()));
|
||||||
getIt.registerSingleton<ChatDao>(ChatDao(getIt<AppDatabase>()));
|
getIt.registerSingleton<ChatDao>(ChatDao(getIt<AppDatabase>()));
|
||||||
|
getIt.registerSingleton<KnowledgeChunkDao>(
|
||||||
|
KnowledgeChunkDao(getIt<AppDatabase>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Services
|
||||||
|
getIt.registerSingleton<EmbeddingService>(EmbeddingService());
|
||||||
|
|
||||||
// Repositories
|
// Repositories
|
||||||
getIt.registerLazySingleton<ExerciseRepository>(
|
getIt.registerLazySingleton<ExerciseRepository>(
|
||||||
@@ -47,4 +62,10 @@ void init() {
|
|||||||
getIt.registerLazySingleton<ChatRepository>(
|
getIt.registerLazySingleton<ChatRepository>(
|
||||||
() => ChatRepositoryImpl(getIt<ChatDao>()),
|
() => ChatRepositoryImpl(getIt<ChatDao>()),
|
||||||
);
|
);
|
||||||
|
getIt.registerLazySingleton<NoteRepository>(
|
||||||
|
() => NoteRepositoryImpl(
|
||||||
|
getIt<KnowledgeChunkDao>(),
|
||||||
|
getIt<EmbeddingService>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
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: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/data/services/ai_process_manager.dart';
|
||||||
import 'package:trainhub_flutter/injection.dart' as di;
|
import 'package:trainhub_flutter/injection.dart' as di;
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
MediaKit.ensureInitialized();
|
||||||
await windowManager.ensureInitialized();
|
await windowManager.ensureInitialized();
|
||||||
|
|
||||||
// Initialize dependency injection
|
|
||||||
di.init();
|
di.init();
|
||||||
|
|
||||||
WindowOptions windowOptions = const WindowOptions(
|
const windowOptions = WindowOptions(
|
||||||
size: Size(1280, 800),
|
size: Size(1280, 800),
|
||||||
minimumSize: Size(800, 600),
|
minimumSize: Size(800, 600),
|
||||||
center: true,
|
center: true,
|
||||||
@@ -30,17 +32,59 @@ void main() async {
|
|||||||
runApp(const ProviderScope(child: TrainHubApp()));
|
runApp(const ProviderScope(child: TrainHubApp()));
|
||||||
}
|
}
|
||||||
|
|
||||||
class TrainHubApp extends StatelessWidget {
|
// =============================================================================
|
||||||
|
// Root application widget
|
||||||
|
// =============================================================================
|
||||||
|
class TrainHubApp extends ConsumerStatefulWidget {
|
||||||
const TrainHubApp({super.key});
|
const TrainHubApp({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
ConsumerState<TrainHubApp> createState() => _TrainHubAppState();
|
||||||
final appRouter = AppRouter();
|
}
|
||||||
|
|
||||||
|
class _TrainHubAppState extends ConsumerState<TrainHubApp> with WindowListener {
|
||||||
|
// Create the router once and reuse it across rebuilds.
|
||||||
|
final _appRouter = AppRouter();
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Lifecycle
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
windowManager.addListener(this);
|
||||||
|
// Intercept the OS close event so we can kill child processes first.
|
||||||
|
windowManager.setPreventClose(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
windowManager.removeListener(this);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// WindowListener — critical: kill servers before the window is destroyed
|
||||||
|
// so they don't become zombie processes consuming 5 GB of RAM.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onWindowClose() async {
|
||||||
|
await di.getIt<AiProcessManager>().stopServers();
|
||||||
|
await windowManager.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Build
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp.router(
|
return MaterialApp.router(
|
||||||
title: 'TrainHub',
|
title: 'TrainHub',
|
||||||
theme: AppTheme.dark,
|
theme: AppTheme.dark,
|
||||||
routerConfig: appRouter.config(),
|
routerConfig: _appRouter.config(),
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:trainhub_flutter/injection.dart';
|
import 'package:trainhub_flutter/injection.dart';
|
||||||
|
import 'package:trainhub_flutter/domain/entities/annotation.dart';
|
||||||
import 'package:trainhub_flutter/domain/repositories/analysis_repository.dart';
|
import 'package:trainhub_flutter/domain/repositories/analysis_repository.dart';
|
||||||
|
import 'package:trainhub_flutter/domain/repositories/exercise_repository.dart';
|
||||||
import 'package:trainhub_flutter/presentation/analysis/analysis_state.dart';
|
import 'package:trainhub_flutter/presentation/analysis/analysis_state.dart';
|
||||||
|
|
||||||
part 'analysis_controller.g.dart';
|
part 'analysis_controller.g.dart';
|
||||||
@@ -8,10 +10,12 @@ part 'analysis_controller.g.dart';
|
|||||||
@riverpod
|
@riverpod
|
||||||
class AnalysisController extends _$AnalysisController {
|
class AnalysisController extends _$AnalysisController {
|
||||||
late AnalysisRepository _repo;
|
late AnalysisRepository _repo;
|
||||||
|
late ExerciseRepository _exerciseRepo;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<AnalysisState> build() async {
|
Future<AnalysisState> build() async {
|
||||||
_repo = getIt<AnalysisRepository>();
|
_repo = getIt<AnalysisRepository>();
|
||||||
|
_exerciseRepo = getIt<ExerciseRepository>();
|
||||||
final sessions = await _repo.getAllSessions();
|
final sessions = await _repo.getAllSessions();
|
||||||
return AnalysisState(sessions: sessions);
|
return AnalysisState(sessions: sessions);
|
||||||
}
|
}
|
||||||
@@ -35,10 +39,7 @@ class AnalysisController extends _$AnalysisController {
|
|||||||
final annotations = await _repo.getAnnotations(id);
|
final annotations = await _repo.getAnnotations(id);
|
||||||
final current = state.valueOrNull ?? const AnalysisState();
|
final current = state.valueOrNull ?? const AnalysisState();
|
||||||
state = AsyncValue.data(
|
state = AsyncValue.data(
|
||||||
current.copyWith(
|
current.copyWith(activeSession: session, annotations: annotations),
|
||||||
activeSession: session,
|
|
||||||
annotations: annotations,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +78,34 @@ class AnalysisController extends _$AnalysisController {
|
|||||||
state = AsyncValue.data(current.copyWith(annotations: annotations));
|
state = AsyncValue.data(current.copyWith(annotations: annotations));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> updateAnnotation(AnnotationEntity annotation) async {
|
||||||
|
final current = state.valueOrNull;
|
||||||
|
if (current?.activeSession == null) return;
|
||||||
|
await _repo.updateAnnotation(
|
||||||
|
id: annotation.id,
|
||||||
|
name: annotation.name ?? '',
|
||||||
|
description: annotation.description ?? '',
|
||||||
|
color: annotation.color ?? 'grey',
|
||||||
|
);
|
||||||
|
final annotations = await _repo.getAnnotations(current!.activeSession!.id);
|
||||||
|
state = AsyncValue.data(current.copyWith(annotations: annotations));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> createExerciseFromAnnotation({
|
||||||
|
required String name,
|
||||||
|
required String instructions,
|
||||||
|
required String videoPath,
|
||||||
|
required double startTime,
|
||||||
|
required double endTime,
|
||||||
|
}) async {
|
||||||
|
final videoRef = '$videoPath#t=${startTime.toStringAsFixed(2)},${endTime.toStringAsFixed(2)}';
|
||||||
|
await _exerciseRepo.create(
|
||||||
|
name: name,
|
||||||
|
instructions: instructions.isEmpty ? null : instructions,
|
||||||
|
videoUrl: videoRef,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> deleteAnnotation(String id) async {
|
Future<void> deleteAnnotation(String id) async {
|
||||||
await _repo.deleteAnnotation(id);
|
await _repo.deleteAnnotation(id);
|
||||||
final current = state.valueOrNull;
|
final current = state.valueOrNull;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ part of 'analysis_controller.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$analysisControllerHash() =>
|
String _$analysisControllerHash() =>
|
||||||
r'855d4ab55b8dc398e10c19d0ed245a60f104feed';
|
r'f6ad8fc731654c59a90d8ce64438b8490bbfa231';
|
||||||
|
|
||||||
/// See also [AnalysisController].
|
/// See also [AnalysisController].
|
||||||
@ProviderFor(AnalysisController)
|
@ProviderFor(AnalysisController)
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import 'dart:io';
|
import 'dart:async';
|
||||||
|
|
||||||
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:media_kit/media_kit.dart';
|
||||||
|
import 'package:media_kit_video/media_kit_video.dart';
|
||||||
|
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||||
import 'package:trainhub_flutter/domain/entities/analysis_session.dart';
|
import 'package:trainhub_flutter/domain/entities/analysis_session.dart';
|
||||||
import 'package:trainhub_flutter/domain/entities/annotation.dart';
|
import 'package:trainhub_flutter/domain/entities/annotation.dart';
|
||||||
import 'package:trainhub_flutter/presentation/analysis/analysis_controller.dart';
|
import 'package:trainhub_flutter/presentation/analysis/analysis_controller.dart';
|
||||||
import 'package:video_player/video_player.dart';
|
|
||||||
|
|
||||||
class AnalysisViewer extends ConsumerStatefulWidget {
|
class AnalysisViewer extends ConsumerStatefulWidget {
|
||||||
final AnalysisSessionEntity session;
|
final AnalysisSessionEntity session;
|
||||||
@@ -24,19 +26,34 @@ class AnalysisViewer extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AnalysisViewerState extends ConsumerState<AnalysisViewer> {
|
class _AnalysisViewerState extends ConsumerState<AnalysisViewer> {
|
||||||
VideoPlayerController? _videoController;
|
late final Player _player;
|
||||||
|
late final VideoController _videoController;
|
||||||
|
|
||||||
bool _isPlaying = false;
|
bool _isPlaying = false;
|
||||||
double _currentPosition = 0.0;
|
double _currentPosition = 0.0;
|
||||||
double _totalDuration = 1.0;
|
double _totalDuration = 1.0;
|
||||||
|
bool _isInitialized = false;
|
||||||
|
bool _hasError = false;
|
||||||
|
|
||||||
// IN/OUT points
|
|
||||||
double? _inPoint;
|
double? _inPoint;
|
||||||
double? _outPoint;
|
double? _outPoint;
|
||||||
bool _isLooping = false;
|
bool _isLooping = false;
|
||||||
|
|
||||||
|
late StreamSubscription<Duration> _positionSubscription;
|
||||||
|
late StreamSubscription<Duration> _durationSubscription;
|
||||||
|
late StreamSubscription<bool> _playingSubscription;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_player = Player();
|
||||||
|
_videoController = VideoController(
|
||||||
|
_player,
|
||||||
|
configuration: const VideoControllerConfiguration(
|
||||||
|
enableHardwareAcceleration: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_setupStreams();
|
||||||
_initializeVideo();
|
_initializeVideo();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,66 +65,67 @@ class _AnalysisViewerState extends ConsumerState<AnalysisViewer> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initializeVideo() async {
|
void _setupStreams() {
|
||||||
final path = widget.session.videoPath;
|
_positionSubscription = _player.stream.position.listen((position) {
|
||||||
if (path == null) return;
|
final posSeconds = position.inMilliseconds / 1000.0;
|
||||||
|
if (_isLooping && _outPoint != null && posSeconds >= _outPoint!) {
|
||||||
_videoController?.dispose();
|
|
||||||
|
|
||||||
final file = File(path);
|
|
||||||
if (await file.exists()) {
|
|
||||||
_videoController = VideoPlayerController.file(file);
|
|
||||||
await _videoController!.initialize();
|
|
||||||
setState(() {
|
|
||||||
_totalDuration = _videoController!.value.duration.inSeconds.toDouble();
|
|
||||||
});
|
|
||||||
_videoController!.addListener(_videoListener);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _videoListener() {
|
|
||||||
if (_videoController == null) return;
|
|
||||||
|
|
||||||
final bool isPlaying = _videoController!.value.isPlaying;
|
|
||||||
final double position =
|
|
||||||
_videoController!.value.position.inMilliseconds / 1000.0;
|
|
||||||
|
|
||||||
// Loop logic
|
|
||||||
if (_isLooping && _outPoint != null && position >= _outPoint!) {
|
|
||||||
_seekTo(_inPoint ?? 0.0);
|
_seekTo(_inPoint ?? 0.0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setState(() => _currentPosition = posSeconds);
|
||||||
|
});
|
||||||
|
|
||||||
if (isPlaying != _isPlaying || (position - _currentPosition).abs() > 0.1) {
|
_durationSubscription = _player.stream.duration.listen((duration) {
|
||||||
setState(() {
|
final total = duration.inMilliseconds / 1000.0;
|
||||||
_isPlaying = isPlaying;
|
setState(() => _totalDuration = total > 0 ? total : 1.0);
|
||||||
_currentPosition = position;
|
});
|
||||||
|
|
||||||
|
_playingSubscription = _player.stream.playing.listen((isPlaying) {
|
||||||
|
setState(() => _isPlaying = isPlaying);
|
||||||
|
});
|
||||||
|
|
||||||
|
_player.stream.completed.listen((_) {
|
||||||
|
setState(() => _isPlaying = false);
|
||||||
|
});
|
||||||
|
|
||||||
|
_player.stream.error.listen((error) {
|
||||||
|
debugPrint('Video player error: $error');
|
||||||
|
if (mounted) setState(() => _hasError = true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _initializeVideo() async {
|
||||||
|
final path = widget.session.videoPath;
|
||||||
|
if (path == null) return;
|
||||||
|
if (mounted) setState(() { _isInitialized = false; _hasError = false; });
|
||||||
|
await _player.open(Media(path), play: false);
|
||||||
|
// Seek to zero so MPV renders the first frame before the user presses play.
|
||||||
|
await _player.seek(Duration.zero);
|
||||||
|
if (mounted) setState(() => _isInitialized = true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_videoController?.removeListener(_videoListener);
|
_positionSubscription.cancel();
|
||||||
_videoController?.dispose();
|
_durationSubscription.cancel();
|
||||||
|
_playingSubscription.cancel();
|
||||||
|
_player.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _togglePlay() {
|
void _togglePlay() {
|
||||||
if (_videoController == null) return;
|
if (_isPlaying) {
|
||||||
if (_videoController!.value.isPlaying) {
|
_player.pause();
|
||||||
_videoController!.pause();
|
|
||||||
} else {
|
} else {
|
||||||
if (_isLooping && _outPoint != null && _currentPosition >= _outPoint!) {
|
if (_isLooping && _outPoint != null && _currentPosition >= _outPoint!) {
|
||||||
_seekTo(_inPoint ?? 0.0);
|
_seekTo(_inPoint ?? 0.0);
|
||||||
}
|
}
|
||||||
_videoController!.play();
|
_player.play();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _seekTo(double value) {
|
void _seekTo(double value) {
|
||||||
if (_videoController == null) return;
|
_player.seek(Duration(milliseconds: (value * 1000).toInt()));
|
||||||
_videoController!.seekTo(Duration(milliseconds: (value * 1000).toInt()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _setInPoint() {
|
void _setInPoint() {
|
||||||
@@ -137,9 +155,7 @@ class _AnalysisViewerState extends ConsumerState<AnalysisViewer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _toggleLoop() {
|
void _toggleLoop() {
|
||||||
setState(() {
|
setState(() => _isLooping = !_isLooping);
|
||||||
_isLooping = !_isLooping;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _playRange(double start, double end) {
|
void _playRange(double start, double end) {
|
||||||
@@ -149,7 +165,7 @@ class _AnalysisViewerState extends ConsumerState<AnalysisViewer> {
|
|||||||
_isLooping = true;
|
_isLooping = true;
|
||||||
});
|
});
|
||||||
_seekTo(start);
|
_seekTo(start);
|
||||||
_videoController?.play();
|
_player.play();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -158,23 +174,33 @@ class _AnalysisViewerState extends ConsumerState<AnalysisViewer> {
|
|||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
// Video Area
|
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 3,
|
flex: 3,
|
||||||
child: Container(
|
child: Container(
|
||||||
color: Colors.black,
|
color: Colors.black,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child:
|
child: _hasError
|
||||||
_videoController != null &&
|
? const Center(
|
||||||
_videoController!.value.isInitialized
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.videocam_off, color: Colors.grey, size: 40),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Failed to load video',
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: _isInitialized
|
||||||
? Stack(
|
? Stack(
|
||||||
alignment: Alignment.bottomCenter,
|
alignment: Alignment.bottomCenter,
|
||||||
children: [
|
children: [
|
||||||
Center(
|
Video(
|
||||||
child: AspectRatio(
|
controller: _videoController,
|
||||||
aspectRatio: _videoController!.value.aspectRatio,
|
controls: NoVideoControls,
|
||||||
child: VideoPlayer(_videoController!),
|
fit: BoxFit.contain,
|
||||||
),
|
|
||||||
),
|
),
|
||||||
_buildTimelineControls(),
|
_buildTimelineControls(),
|
||||||
],
|
],
|
||||||
@@ -182,8 +208,6 @@ class _AnalysisViewerState extends ConsumerState<AnalysisViewer> {
|
|||||||
: const Center(child: CircularProgressIndicator()),
|
: const Center(child: CircularProgressIndicator()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Annotations List
|
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 2,
|
flex: 2,
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -197,33 +221,28 @@ class _AnalysisViewerState extends ConsumerState<AnalysisViewer> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
"Annotations",
|
'Annotations',
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
),
|
),
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
onPressed: () {
|
onPressed: () => _showAddAnnotationDialog(controller),
|
||||||
double start = _inPoint ?? _currentPosition;
|
|
||||||
double end = _outPoint ?? (_currentPosition + 5.0);
|
|
||||||
if (end > _totalDuration) end = _totalDuration;
|
|
||||||
|
|
||||||
controller.addAnnotation(
|
|
||||||
name: "New Annotation",
|
|
||||||
description:
|
|
||||||
"${_formatDuration(start)} - ${_formatDuration(end)}",
|
|
||||||
startTime: start,
|
|
||||||
endTime: end,
|
|
||||||
color: "red",
|
|
||||||
);
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
label: const Text("Add from Selection"),
|
label: const Text('Add from Selection'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView.separated(
|
child: widget.annotations.isEmpty
|
||||||
|
? const Center(
|
||||||
|
child: Text(
|
||||||
|
'No annotations yet.\nSet IN/OUT points and click "Add from Selection".',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.separated(
|
||||||
separatorBuilder: (context, index) =>
|
separatorBuilder: (context, index) =>
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
itemCount: widget.annotations.length,
|
itemCount: widget.annotations.length,
|
||||||
@@ -233,39 +252,14 @@ class _AnalysisViewerState extends ConsumerState<AnalysisViewer> {
|
|||||||
_currentPosition >= note.startTime &&
|
_currentPosition >= note.startTime &&
|
||||||
_currentPosition <= note.endTime;
|
_currentPosition <= note.endTime;
|
||||||
|
|
||||||
return Container(
|
return _AnnotationListItem(
|
||||||
color: isActive
|
annotation: note,
|
||||||
? Theme.of(
|
isActive: isActive,
|
||||||
context,
|
onPlay: () =>
|
||||||
).colorScheme.primaryContainer.withOpacity(0.2)
|
|
||||||
: null,
|
|
||||||
child: ListTile(
|
|
||||||
leading: Icon(
|
|
||||||
Icons.label,
|
|
||||||
color: _parseColor(note.color ?? 'grey'),
|
|
||||||
),
|
|
||||||
title: Text(note.name ?? 'Untitled'),
|
|
||||||
subtitle: Text(
|
|
||||||
"${_formatDuration(note.startTime)} - ${_formatDuration(note.endTime)}",
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
trailing: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.play_circle_outline),
|
|
||||||
onPressed: () =>
|
|
||||||
_playRange(note.startTime, note.endTime),
|
_playRange(note.startTime, note.endTime),
|
||||||
tooltip: "Play Range",
|
onEdit: () =>
|
||||||
),
|
_showEditAnnotationDialog(controller, note),
|
||||||
IconButton(
|
onDelete: () => controller.deleteAnnotation(note.id),
|
||||||
icon: const Icon(Icons.delete_outline),
|
|
||||||
onPressed: () =>
|
|
||||||
controller.deleteAnnotation(note.id),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -289,56 +283,57 @@ class _AnalysisViewerState extends ConsumerState<AnalysisViewer> {
|
|||||||
|
|
||||||
Widget _buildTimelineControls() {
|
Widget _buildTimelineControls() {
|
||||||
return Container(
|
return Container(
|
||||||
color: Colors.black.withOpacity(0.8),
|
color: Colors.black.withValues(alpha: 0.8),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Timeline Visualization
|
_buildAnnotationTimeline(),
|
||||||
SizedBox(
|
_buildSeekSlider(),
|
||||||
|
_buildControlsRow(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAnnotationTimeline() {
|
||||||
|
return SizedBox(
|
||||||
height: 20,
|
height: 20,
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
// Annotations
|
|
||||||
...widget.annotations.map((note) {
|
...widget.annotations.map((note) {
|
||||||
final left =
|
final left =
|
||||||
(note.startTime / _totalDuration) *
|
(note.startTime / _totalDuration) * constraints.maxWidth;
|
||||||
constraints.maxWidth;
|
|
||||||
final width =
|
final width =
|
||||||
((note.endTime - note.startTime) / _totalDuration) *
|
((note.endTime - note.startTime) / _totalDuration) *
|
||||||
constraints.maxWidth;
|
constraints.maxWidth;
|
||||||
return Positioned(
|
return Positioned(
|
||||||
left: left,
|
left: left,
|
||||||
width: width,
|
width: width.clamp(2.0, constraints.maxWidth),
|
||||||
top: 4,
|
top: 4,
|
||||||
bottom: 4,
|
bottom: 4,
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _parseColor(
|
color: _parseColor(
|
||||||
note.color ?? 'grey',
|
note.color ?? 'grey',
|
||||||
).withOpacity(0.6),
|
).withValues(alpha: 0.6),
|
||||||
borderRadius: BorderRadius.circular(2),
|
borderRadius: BorderRadius.circular(2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
// IN Point
|
|
||||||
if (_inPoint != null)
|
if (_inPoint != null)
|
||||||
Positioned(
|
Positioned(
|
||||||
left:
|
left: (_inPoint! / _totalDuration) * constraints.maxWidth,
|
||||||
(_inPoint! / _totalDuration) * constraints.maxWidth,
|
|
||||||
top: 0,
|
top: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
child: Container(width: 2, color: Colors.green),
|
child: Container(width: 2, color: Colors.green),
|
||||||
),
|
),
|
||||||
// OUT Point
|
|
||||||
if (_outPoint != null)
|
if (_outPoint != null)
|
||||||
Positioned(
|
Positioned(
|
||||||
left:
|
left: (_outPoint! / _totalDuration) * constraints.maxWidth,
|
||||||
(_outPoint! / _totalDuration) *
|
|
||||||
constraints.maxWidth,
|
|
||||||
top: 0,
|
top: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
child: Container(width: 2, color: Colors.red),
|
child: Container(width: 2, color: Colors.red),
|
||||||
@@ -347,10 +342,11 @@ class _AnalysisViewerState extends ConsumerState<AnalysisViewer> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Slider
|
Widget _buildSeekSlider() {
|
||||||
SliderTheme(
|
return SliderTheme(
|
||||||
data: SliderTheme.of(context).copyWith(
|
data: SliderTheme.of(context).copyWith(
|
||||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
|
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
|
||||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 10),
|
overlayShape: const RoundSliderOverlayShape(overlayRadius: 10),
|
||||||
@@ -360,14 +356,15 @@ class _AnalysisViewerState extends ConsumerState<AnalysisViewer> {
|
|||||||
value: _currentPosition.clamp(0.0, _totalDuration),
|
value: _currentPosition.clamp(0.0, _totalDuration),
|
||||||
min: 0.0,
|
min: 0.0,
|
||||||
max: _totalDuration,
|
max: _totalDuration,
|
||||||
onChanged: (value) => _seekTo(value),
|
onChanged: _seekTo,
|
||||||
activeColor: Theme.of(context).colorScheme.primary,
|
activeColor: Theme.of(context).colorScheme.primary,
|
||||||
inactiveColor: Colors.grey,
|
inactiveColor: Colors.grey,
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Controls Row
|
Widget _buildControlsRow() {
|
||||||
Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -377,12 +374,8 @@ class _AnalysisViewerState extends ConsumerState<AnalysisViewer> {
|
|||||||
style: const TextStyle(color: Colors.white, fontSize: 12),
|
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(
|
icon: const Icon(Icons.keyboard_arrow_left, color: Colors.white),
|
||||||
Icons.keyboard_arrow_left,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
onPressed: () => _seekTo(_currentPosition - 1),
|
onPressed: () => _seekTo(_currentPosition - 1),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -393,24 +386,18 @@ class _AnalysisViewerState extends ConsumerState<AnalysisViewer> {
|
|||||||
onPressed: _togglePlay,
|
onPressed: _togglePlay,
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(
|
icon: const Icon(Icons.keyboard_arrow_right, color: Colors.white),
|
||||||
Icons.keyboard_arrow_right,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
onPressed: () => _seekTo(_currentPosition + 1),
|
onPressed: () => _seekTo(_currentPosition + 1),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
|
|
||||||
// IN/OUT Controls
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.login, color: Colors.green),
|
icon: const Icon(Icons.login, color: Colors.green),
|
||||||
tooltip: "Set IN Point",
|
tooltip: 'Set IN Point',
|
||||||
onPressed: _setInPoint,
|
onPressed: _setInPoint,
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.logout, color: Colors.red),
|
icon: const Icon(Icons.logout, color: Colors.red),
|
||||||
tooltip: "Set OUT Point",
|
tooltip: 'Set OUT Point',
|
||||||
onPressed: _setOutPoint,
|
onPressed: _setOutPoint,
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -420,19 +407,15 @@ class _AnalysisViewerState extends ConsumerState<AnalysisViewer> {
|
|||||||
? Theme.of(context).colorScheme.primary
|
? Theme.of(context).colorScheme.primary
|
||||||
: Colors.white,
|
: Colors.white,
|
||||||
),
|
),
|
||||||
tooltip: "Toggle Loop",
|
tooltip: 'Toggle Loop',
|
||||||
onPressed: _toggleLoop,
|
onPressed: _toggleLoop,
|
||||||
),
|
),
|
||||||
if (_inPoint != null || _outPoint != null)
|
if (_inPoint != null || _outPoint != null)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(
|
icon: const Icon(Icons.cancel_outlined, color: Colors.white),
|
||||||
Icons.cancel_outlined,
|
tooltip: 'Clear Points',
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
tooltip: "Clear Points",
|
|
||||||
onPressed: _clearPoints,
|
onPressed: _clearPoints,
|
||||||
),
|
),
|
||||||
|
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Text(
|
Text(
|
||||||
_formatDuration(_totalDuration),
|
_formatDuration(_totalDuration),
|
||||||
@@ -440,8 +423,83 @@ class _AnalysisViewerState extends ConsumerState<AnalysisViewer> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showAddAnnotationDialog(AnalysisController controller) {
|
||||||
|
final double start = _inPoint ?? _currentPosition;
|
||||||
|
double end = _outPoint ?? (_currentPosition + 5.0);
|
||||||
|
if (end > _totalDuration) end = _totalDuration;
|
||||||
|
final videoPath = widget.session.videoPath ?? '';
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => _AnnotationDialog(
|
||||||
|
initialName: '',
|
||||||
|
initialDescription: '',
|
||||||
|
initialColor: 'red',
|
||||||
|
startTime: start,
|
||||||
|
endTime: end,
|
||||||
|
videoPath: videoPath,
|
||||||
|
onSave: ({
|
||||||
|
required String name,
|
||||||
|
required String description,
|
||||||
|
required String color,
|
||||||
|
required bool createExercise,
|
||||||
|
required String exerciseName,
|
||||||
|
required String exerciseInstructions,
|
||||||
|
}) async {
|
||||||
|
await controller.addAnnotation(
|
||||||
|
name: name,
|
||||||
|
description: description,
|
||||||
|
startTime: start,
|
||||||
|
endTime: end,
|
||||||
|
color: color,
|
||||||
|
);
|
||||||
|
if (createExercise && exerciseName.isNotEmpty) {
|
||||||
|
await controller.createExerciseFromAnnotation(
|
||||||
|
name: exerciseName,
|
||||||
|
instructions: exerciseInstructions,
|
||||||
|
videoPath: videoPath,
|
||||||
|
startTime: start,
|
||||||
|
endTime: end,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showEditAnnotationDialog(
|
||||||
|
AnalysisController controller,
|
||||||
|
AnnotationEntity annotation,
|
||||||
|
) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => _AnnotationDialog(
|
||||||
|
initialName: annotation.name ?? '',
|
||||||
|
initialDescription: annotation.description ?? '',
|
||||||
|
initialColor: annotation.color ?? 'red',
|
||||||
|
startTime: annotation.startTime,
|
||||||
|
endTime: annotation.endTime,
|
||||||
|
videoPath: widget.session.videoPath ?? '',
|
||||||
|
isEditing: true,
|
||||||
|
onSave: ({
|
||||||
|
required String name,
|
||||||
|
required String description,
|
||||||
|
required String color,
|
||||||
|
required bool createExercise,
|
||||||
|
required String exerciseName,
|
||||||
|
required String exerciseInstructions,
|
||||||
|
}) async {
|
||||||
|
await controller.updateAnnotation(
|
||||||
|
annotation.copyWith(
|
||||||
|
name: name,
|
||||||
|
description: description,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -463,6 +521,343 @@ class _AnalysisViewerState extends ConsumerState<AnalysisViewer> {
|
|||||||
return Colors.blue;
|
return Colors.blue;
|
||||||
case 'yellow':
|
case 'yellow':
|
||||||
return Colors.yellow;
|
return Colors.yellow;
|
||||||
|
case 'orange':
|
||||||
|
return Colors.orange;
|
||||||
|
case 'purple':
|
||||||
|
return Colors.purple;
|
||||||
|
default:
|
||||||
|
return Colors.grey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AnnotationListItem extends StatelessWidget {
|
||||||
|
final AnnotationEntity annotation;
|
||||||
|
final bool isActive;
|
||||||
|
final VoidCallback onPlay;
|
||||||
|
final VoidCallback onEdit;
|
||||||
|
final VoidCallback onDelete;
|
||||||
|
|
||||||
|
const _AnnotationListItem({
|
||||||
|
required this.annotation,
|
||||||
|
required this.isActive,
|
||||||
|
required this.onPlay,
|
||||||
|
required this.onEdit,
|
||||||
|
required this.onDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
color: isActive
|
||||||
|
? Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.2)
|
||||||
|
: null,
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
Icons.label,
|
||||||
|
color: _parseColor(annotation.color ?? 'grey'),
|
||||||
|
),
|
||||||
|
title: Text(annotation.name ?? 'Untitled'),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${_formatDuration(annotation.startTime)} - ${_formatDuration(annotation.endTime)}',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
if (annotation.description != null &&
|
||||||
|
annotation.description!.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
annotation.description!,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.play_circle_outline),
|
||||||
|
onPressed: onPlay,
|
||||||
|
tooltip: 'Play Range',
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.edit_outlined),
|
||||||
|
onPressed: onEdit,
|
||||||
|
tooltip: 'Edit',
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
onPressed: onDelete,
|
||||||
|
tooltip: 'Delete',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDuration(double seconds) {
|
||||||
|
final duration = Duration(milliseconds: (seconds * 1000).toInt());
|
||||||
|
final mins = duration.inMinutes;
|
||||||
|
final secs = duration.inSeconds % 60;
|
||||||
|
return '$mins:${secs.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _parseColor(String colorName) {
|
||||||
|
switch (colorName.toLowerCase()) {
|
||||||
|
case 'red':
|
||||||
|
return Colors.red;
|
||||||
|
case 'green':
|
||||||
|
return Colors.green;
|
||||||
|
case 'blue':
|
||||||
|
return Colors.blue;
|
||||||
|
case 'yellow':
|
||||||
|
return Colors.yellow;
|
||||||
|
case 'orange':
|
||||||
|
return Colors.orange;
|
||||||
|
case 'purple':
|
||||||
|
return Colors.purple;
|
||||||
|
default:
|
||||||
|
return Colors.grey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef AnnotationSaveCallback = Future<void> Function({
|
||||||
|
required String name,
|
||||||
|
required String description,
|
||||||
|
required String color,
|
||||||
|
required bool createExercise,
|
||||||
|
required String exerciseName,
|
||||||
|
required String exerciseInstructions,
|
||||||
|
});
|
||||||
|
|
||||||
|
class _AnnotationDialog extends StatefulWidget {
|
||||||
|
final String initialName;
|
||||||
|
final String initialDescription;
|
||||||
|
final String initialColor;
|
||||||
|
final double startTime;
|
||||||
|
final double endTime;
|
||||||
|
final String videoPath;
|
||||||
|
final bool isEditing;
|
||||||
|
final AnnotationSaveCallback onSave;
|
||||||
|
|
||||||
|
const _AnnotationDialog({
|
||||||
|
required this.initialName,
|
||||||
|
required this.initialDescription,
|
||||||
|
required this.initialColor,
|
||||||
|
required this.startTime,
|
||||||
|
required this.endTime,
|
||||||
|
required this.videoPath,
|
||||||
|
required this.onSave,
|
||||||
|
this.isEditing = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_AnnotationDialog> createState() => _AnnotationDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AnnotationDialogState extends State<_AnnotationDialog> {
|
||||||
|
late final TextEditingController _nameController;
|
||||||
|
late final TextEditingController _descriptionController;
|
||||||
|
late final TextEditingController _exerciseNameController;
|
||||||
|
late final TextEditingController _exerciseInstructionsController;
|
||||||
|
late String _selectedColor;
|
||||||
|
bool _createExercise = false;
|
||||||
|
|
||||||
|
static const List<String> _colorOptions = [
|
||||||
|
'red',
|
||||||
|
'green',
|
||||||
|
'blue',
|
||||||
|
'yellow',
|
||||||
|
'orange',
|
||||||
|
'purple',
|
||||||
|
'grey',
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_nameController = TextEditingController(text: widget.initialName);
|
||||||
|
_descriptionController = TextEditingController(
|
||||||
|
text: widget.initialDescription,
|
||||||
|
);
|
||||||
|
_exerciseNameController = TextEditingController(text: widget.initialName);
|
||||||
|
_exerciseInstructionsController = TextEditingController();
|
||||||
|
_selectedColor = widget.initialColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nameController.dispose();
|
||||||
|
_descriptionController.dispose();
|
||||||
|
_exerciseNameController.dispose();
|
||||||
|
_exerciseInstructionsController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(widget.isEditing ? 'Edit Annotation' : 'New Annotation'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: UIConstants.dialogWidth,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${_formatDuration(widget.startTime)} — ${_formatDuration(widget.endTime)}',
|
||||||
|
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing12),
|
||||||
|
TextField(
|
||||||
|
controller: _nameController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Name *',
|
||||||
|
hintText: 'e.g. Knee cave on squat',
|
||||||
|
),
|
||||||
|
autofocus: true,
|
||||||
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing12),
|
||||||
|
TextField(
|
||||||
|
controller: _descriptionController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Description',
|
||||||
|
hintText: 'Additional notes...',
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing12),
|
||||||
|
const Text(
|
||||||
|
'Color',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_buildColorPicker(),
|
||||||
|
if (!widget.isEditing) ...[
|
||||||
|
const SizedBox(height: UIConstants.spacing16),
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: UIConstants.spacing8),
|
||||||
|
CheckboxListTile(
|
||||||
|
value: _createExercise,
|
||||||
|
onChanged: (value) =>
|
||||||
|
setState(() => _createExercise = value ?? false),
|
||||||
|
title: const Text('Also create an exercise from this clip'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Saves this video segment as an exercise',
|
||||||
|
style: TextStyle(fontSize: 11),
|
||||||
|
),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
if (_createExercise) ...[
|
||||||
|
const SizedBox(height: UIConstants.spacing8),
|
||||||
|
TextField(
|
||||||
|
controller: _exerciseNameController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Exercise Name *',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing8),
|
||||||
|
TextField(
|
||||||
|
controller: _exerciseInstructionsController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Exercise Instructions',
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _handleSave,
|
||||||
|
child: Text(widget.isEditing ? 'Update' : 'Save'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildColorPicker() {
|
||||||
|
return Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: _colorOptions.map((colorName) {
|
||||||
|
final color = _colorFromName(colorName);
|
||||||
|
final isSelected = _selectedColor == colorName;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => setState(() => _selectedColor = colorName),
|
||||||
|
child: Container(
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: isSelected
|
||||||
|
? Border.all(color: Colors.white, width: 2)
|
||||||
|
: null,
|
||||||
|
boxShadow: isSelected
|
||||||
|
? [
|
||||||
|
BoxShadow(
|
||||||
|
color: color.withValues(alpha: 0.5),
|
||||||
|
blurRadius: 4,
|
||||||
|
spreadRadius: 1,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleSave() async {
|
||||||
|
if (_nameController.text.isEmpty) return;
|
||||||
|
Navigator.pop(context);
|
||||||
|
await widget.onSave(
|
||||||
|
name: _nameController.text,
|
||||||
|
description: _descriptionController.text,
|
||||||
|
color: _selectedColor,
|
||||||
|
createExercise: _createExercise,
|
||||||
|
exerciseName: _exerciseNameController.text,
|
||||||
|
exerciseInstructions: _exerciseInstructionsController.text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDuration(double seconds) {
|
||||||
|
final duration = Duration(milliseconds: (seconds * 1000).toInt());
|
||||||
|
final mins = duration.inMinutes;
|
||||||
|
final secs = duration.inSeconds % 60;
|
||||||
|
return '$mins:${secs.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _colorFromName(String name) {
|
||||||
|
switch (name) {
|
||||||
|
case 'red':
|
||||||
|
return Colors.red;
|
||||||
|
case 'green':
|
||||||
|
return Colors.green;
|
||||||
|
case 'blue':
|
||||||
|
return Colors.blue;
|
||||||
|
case 'yellow':
|
||||||
|
return Colors.yellow;
|
||||||
|
case 'orange':
|
||||||
|
return Colors.orange;
|
||||||
|
case 'purple':
|
||||||
|
return Colors.purple;
|
||||||
default:
|
default:
|
||||||
return Colors.grey;
|
return Colors.grey;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ part of 'calendar_controller.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$calendarControllerHash() =>
|
String _$calendarControllerHash() =>
|
||||||
r'747a59ba47bf4d1b6a66e3bcc82276e4ad81eb1a';
|
r'd26afbe4d0a107aa6d0067e9b6f44e5ba079d37c';
|
||||||
|
|
||||||
/// See also [CalendarController].
|
/// See also [CalendarController].
|
||||||
@ProviderFor(CalendarController)
|
@ProviderFor(CalendarController)
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||||
|
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||||
import 'package:trainhub_flutter/core/utils/id_generator.dart';
|
import 'package:trainhub_flutter/core/utils/id_generator.dart';
|
||||||
import 'package:trainhub_flutter/domain/entities/program_week.dart';
|
import 'package:trainhub_flutter/domain/entities/program_week.dart';
|
||||||
import 'package:trainhub_flutter/domain/entities/program_workout.dart';
|
import 'package:trainhub_flutter/domain/entities/program_workout.dart';
|
||||||
@@ -31,12 +33,12 @@ class ProgramWeekView extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
"Week ${week.position}",
|
'Week ${week.position}',
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 500, // Fixed height for the week grid, or make it dynamic
|
height: 500,
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: List.generate(7, (dayIndex) {
|
children: List.generate(7, (dayIndex) {
|
||||||
@@ -46,14 +48,52 @@ class ProgramWeekView extends StatelessWidget {
|
|||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: Container(
|
child: _DayColumn(
|
||||||
|
dayNum: dayNum,
|
||||||
|
dayIndex: dayIndex,
|
||||||
|
dayWorkouts: dayWorkouts,
|
||||||
|
availablePlans: availablePlans,
|
||||||
|
week: week,
|
||||||
|
onAddWorkout: onAddWorkout,
|
||||||
|
onDeleteWorkout: onDeleteWorkout,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DayColumn extends StatelessWidget {
|
||||||
|
final int dayNum;
|
||||||
|
final int dayIndex;
|
||||||
|
final List<ProgramWorkoutEntity> dayWorkouts;
|
||||||
|
final List<TrainingPlanEntity> availablePlans;
|
||||||
|
final ProgramWeekEntity week;
|
||||||
|
final Function(ProgramWorkoutEntity) onAddWorkout;
|
||||||
|
final Function(String) onDeleteWorkout;
|
||||||
|
|
||||||
|
const _DayColumn({
|
||||||
|
required this.dayNum,
|
||||||
|
required this.dayIndex,
|
||||||
|
required this.dayWorkouts,
|
||||||
|
required this.availablePlans,
|
||||||
|
required this.week,
|
||||||
|
required this.onAddWorkout,
|
||||||
|
required this.onDeleteWorkout,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: dayIndex < 6
|
border: dayIndex < 6
|
||||||
? const Border(
|
? const Border(
|
||||||
right: BorderSide(
|
right: BorderSide(color: Colors.grey, width: 0.5),
|
||||||
color: Colors.grey,
|
|
||||||
width: 0.5,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
color: dayIndex % 2 == 0
|
color: dayIndex % 2 == 0
|
||||||
@@ -66,14 +106,9 @@ class ProgramWeekView extends StatelessWidget {
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(
|
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||||
context,
|
|
||||||
).colorScheme.surfaceContainerHigh,
|
|
||||||
border: const Border(
|
border: const Border(
|
||||||
bottom: BorderSide(
|
bottom: BorderSide(color: Colors.grey, width: 0.5),
|
||||||
color: Colors.grey,
|
|
||||||
width: 0.5,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -81,18 +116,12 @@ class ProgramWeekView extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
_getDayName(dayNum),
|
_getDayName(dayNum),
|
||||||
style: const TextStyle(
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: () =>
|
onTap: () => _showAddWorkoutSheet(context),
|
||||||
_showAddWorkoutSheet(context, dayNum),
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: const Icon(
|
child: const Icon(Icons.add_circle_outline, size: 20),
|
||||||
Icons.add_circle_outline,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -107,7 +136,7 @@ class ProgramWeekView extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.only(top: 16.0),
|
padding: const EdgeInsets.only(top: 16.0),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
"Rest",
|
'Rest',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(
|
color: Theme.of(
|
||||||
context,
|
context,
|
||||||
@@ -119,39 +148,9 @@ class ProgramWeekView extends StatelessWidget {
|
|||||||
)
|
)
|
||||||
else
|
else
|
||||||
...dayWorkouts.map(
|
...dayWorkouts.map(
|
||||||
(w) => Card(
|
(workout) => _WorkoutCard(
|
||||||
margin: const EdgeInsets.only(
|
workout: workout,
|
||||||
bottom: 4,
|
onDelete: () => onDeleteWorkout(workout.id),
|
||||||
),
|
|
||||||
child: ListTile(
|
|
||||||
contentPadding:
|
|
||||||
const EdgeInsets.symmetric(
|
|
||||||
horizontal: 8,
|
|
||||||
vertical: 0,
|
|
||||||
),
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
title: Text(
|
|
||||||
w.name ?? 'Untitled',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
trailing: InkWell(
|
|
||||||
onTap: () => onDeleteWorkout(w.id),
|
|
||||||
borderRadius: BorderRadius.circular(
|
|
||||||
12,
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.close,
|
|
||||||
size: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
// Optional: Edit on tap
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -160,64 +159,211 @@ class ProgramWeekView extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _getDayName(int day) {
|
String _getDayName(int day) {
|
||||||
const days = [
|
const days = [
|
||||||
'Monday',
|
'Mon',
|
||||||
'Tuesday',
|
'Tue',
|
||||||
'Wednesday',
|
'Wed',
|
||||||
'Thursday',
|
'Thu',
|
||||||
'Friday',
|
'Fri',
|
||||||
'Saturday',
|
'Sat',
|
||||||
'Sunday',
|
'Sun',
|
||||||
];
|
];
|
||||||
if (day >= 1 && day <= 7) return days[day - 1];
|
if (day >= 1 && day <= 7) return days[day - 1];
|
||||||
return 'Day $day';
|
return 'Day $day';
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showAddWorkoutSheet(BuildContext context, int dayNum) {
|
void _showAddWorkoutSheet(BuildContext context) {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => Container(
|
isScrollControlled: true,
|
||||||
|
builder: (context) => _AddWorkoutSheet(
|
||||||
|
availablePlans: availablePlans,
|
||||||
|
week: week,
|
||||||
|
dayNum: dayNum,
|
||||||
|
onAddWorkout: (workout) {
|
||||||
|
onAddWorkout(workout);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WorkoutCard extends StatelessWidget {
|
||||||
|
final ProgramWorkoutEntity workout;
|
||||||
|
final VoidCallback onDelete;
|
||||||
|
|
||||||
|
const _WorkoutCard({required this.workout, required this.onDelete});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isNote = workout.type == 'note';
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 4),
|
||||||
|
color: isNote
|
||||||
|
? AppColors.info.withValues(alpha: 0.08)
|
||||||
|
: null,
|
||||||
|
child: ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 0,
|
||||||
|
),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
leading: Icon(
|
||||||
|
isNote ? Icons.sticky_note_2_outlined : Icons.fitness_center,
|
||||||
|
size: 14,
|
||||||
|
color: isNote ? AppColors.info : AppColors.textMuted,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
workout.name ?? 'Untitled',
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: workout.description != null && workout.description!.isNotEmpty
|
||||||
|
? Text(
|
||||||
|
workout.description!,
|
||||||
|
style: const TextStyle(fontSize: 10),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
trailing: InkWell(
|
||||||
|
onTap: onDelete,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: const Icon(Icons.close, size: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddWorkoutSheet extends StatefulWidget {
|
||||||
|
final List<TrainingPlanEntity> availablePlans;
|
||||||
|
final ProgramWeekEntity week;
|
||||||
|
final int dayNum;
|
||||||
|
final Function(ProgramWorkoutEntity) onAddWorkout;
|
||||||
|
|
||||||
|
const _AddWorkoutSheet({
|
||||||
|
required this.availablePlans,
|
||||||
|
required this.week,
|
||||||
|
required this.dayNum,
|
||||||
|
required this.onAddWorkout,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_AddWorkoutSheet> createState() => _AddWorkoutSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddWorkoutSheetState extends State<_AddWorkoutSheet>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final TabController _tabController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_tabController = TabController(length: 2, vsync: this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tabController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxHeight: MediaQuery.of(context).size.height * 0.6,
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
|
||||||
horizontal: 16.0,
|
|
||||||
vertical: 8.0,
|
|
||||||
),
|
|
||||||
child: Text(
|
child: Text(
|
||||||
"Select Training Plan",
|
'Add to Schedule',
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(),
|
TabBar(
|
||||||
if (availablePlans.isEmpty)
|
controller: _tabController,
|
||||||
const Padding(
|
tabs: const [
|
||||||
|
Tab(
|
||||||
|
icon: Icon(Icons.fitness_center, size: 16),
|
||||||
|
text: 'Training Plan',
|
||||||
|
),
|
||||||
|
Tab(
|
||||||
|
icon: Icon(Icons.sticky_note_2_outlined, size: 16),
|
||||||
|
text: 'Note',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
Expanded(
|
||||||
|
child: TabBarView(
|
||||||
|
controller: _tabController,
|
||||||
|
children: [
|
||||||
|
_PlanPickerTab(
|
||||||
|
availablePlans: widget.availablePlans,
|
||||||
|
week: widget.week,
|
||||||
|
dayNum: widget.dayNum,
|
||||||
|
onAddWorkout: widget.onAddWorkout,
|
||||||
|
),
|
||||||
|
_NoteTab(
|
||||||
|
week: widget.week,
|
||||||
|
dayNum: widget.dayNum,
|
||||||
|
onAddWorkout: widget.onAddWorkout,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PlanPickerTab extends StatelessWidget {
|
||||||
|
final List<TrainingPlanEntity> availablePlans;
|
||||||
|
final ProgramWeekEntity week;
|
||||||
|
final int dayNum;
|
||||||
|
final Function(ProgramWorkoutEntity) onAddWorkout;
|
||||||
|
|
||||||
|
const _PlanPickerTab({
|
||||||
|
required this.availablePlans,
|
||||||
|
required this.week,
|
||||||
|
required this.dayNum,
|
||||||
|
required this.onAddWorkout,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (availablePlans.isEmpty) {
|
||||||
|
return const Padding(
|
||||||
padding: EdgeInsets.all(16.0),
|
padding: EdgeInsets.all(16.0),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text("No training plans available. Create one first!"),
|
child: Text('No training plans available. Create one first!'),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
...availablePlans
|
}
|
||||||
.map(
|
|
||||||
(plan) => ListTile(
|
return ListView.builder(
|
||||||
|
itemCount: availablePlans.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final plan = availablePlans[index];
|
||||||
|
return ListTile(
|
||||||
leading: const Icon(Icons.fitness_center),
|
leading: const Icon(Icons.fitness_center),
|
||||||
title: Text(plan.name),
|
title: Text(plan.name),
|
||||||
subtitle: Text("${plan.totalExercises} exercises"),
|
subtitle: Text('${plan.totalExercises} exercises'),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
final newWorkout = ProgramWorkoutEntity(
|
final newWorkout = ProgramWorkoutEntity(
|
||||||
id: IdGenerator.generate(),
|
id: IdGenerator.generate(),
|
||||||
@@ -227,18 +373,92 @@ class ProgramWeekView extends StatelessWidget {
|
|||||||
type: 'workout',
|
type: 'workout',
|
||||||
name: plan.name,
|
name: plan.name,
|
||||||
refId: plan.id,
|
refId: plan.id,
|
||||||
description: "${plan.sections.length} sections",
|
description: '${plan.sections.length} sections',
|
||||||
completed: false,
|
completed: false,
|
||||||
);
|
);
|
||||||
onAddWorkout(newWorkout);
|
onAddWorkout(newWorkout);
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NoteTab extends StatefulWidget {
|
||||||
|
final ProgramWeekEntity week;
|
||||||
|
final int dayNum;
|
||||||
|
final Function(ProgramWorkoutEntity) onAddWorkout;
|
||||||
|
|
||||||
|
const _NoteTab({
|
||||||
|
required this.week,
|
||||||
|
required this.dayNum,
|
||||||
|
required this.onAddWorkout,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_NoteTab> createState() => _NoteTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NoteTabState extends State<_NoteTab> {
|
||||||
|
final TextEditingController _titleController = TextEditingController();
|
||||||
|
final TextEditingController _contentController = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_titleController.dispose();
|
||||||
|
_contentController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(UIConstants.spacing16),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _titleController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Title',
|
||||||
|
hintText: 'e.g. Active Recovery',
|
||||||
|
),
|
||||||
|
autofocus: true,
|
||||||
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing12),
|
||||||
|
TextField(
|
||||||
|
controller: _contentController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Note (optional)',
|
||||||
|
hintText: 'Additional details...',
|
||||||
|
),
|
||||||
|
maxLines: 3,
|
||||||
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing16),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
if (_titleController.text.isEmpty) return;
|
||||||
|
final newWorkout = ProgramWorkoutEntity(
|
||||||
|
id: IdGenerator.generate(),
|
||||||
|
programId: widget.week.programId,
|
||||||
|
weekId: widget.week.id,
|
||||||
|
day: widget.dayNum.toString(),
|
||||||
|
type: 'note',
|
||||||
|
name: _titleController.text,
|
||||||
|
description: _contentController.text.isEmpty
|
||||||
|
? null
|
||||||
|
: _contentController.text,
|
||||||
|
completed: false,
|
||||||
|
);
|
||||||
|
widget.onAddWorkout(newWorkout);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('Add Note'),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,43 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:trainhub_flutter/injection.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/data/services/ai_process_manager.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';
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
AiProcessManager aiProcessManager(AiProcessManagerRef ref) {
|
||||||
|
final manager = getIt<AiProcessManager>();
|
||||||
|
manager.addListener(() => ref.notifyListeners());
|
||||||
|
return manager;
|
||||||
|
}
|
||||||
|
|
||||||
@riverpod
|
@riverpod
|
||||||
class ChatController extends _$ChatController {
|
class ChatController extends _$ChatController {
|
||||||
late ChatRepository _repo;
|
late ChatRepository _repo;
|
||||||
|
late NoteRepository _noteRepo;
|
||||||
|
|
||||||
|
final _dio = Dio(
|
||||||
|
BaseOptions(
|
||||||
|
connectTimeout: AiConstants.serverConnectTimeout,
|
||||||
|
receiveTimeout: AiConstants.serverReceiveTimeout,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<ChatState> build() async {
|
Future<ChatState> build() async {
|
||||||
_repo = getIt<ChatRepository>();
|
_repo = getIt<ChatRepository>();
|
||||||
|
_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);
|
||||||
}
|
}
|
||||||
@@ -51,17 +77,26 @@ class ChatController extends _$ChatController {
|
|||||||
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;
|
||||||
String sessionId;
|
final sessionId = await _resolveSession(current, content);
|
||||||
if (current.activeSession == null) {
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _resolveSession(ChatState current, String content) async {
|
||||||
|
if (current.activeSession != null) return current.activeSession!.id;
|
||||||
final session = await _repo.createSession();
|
final session = await _repo.createSession();
|
||||||
sessionId = session.id;
|
|
||||||
final sessions = await _repo.getAllSessions();
|
final sessions = await _repo.getAllSessions();
|
||||||
state = AsyncValue.data(
|
state = AsyncValue.data(
|
||||||
current.copyWith(sessions: sessions, activeSession: session),
|
current.copyWith(sessions: sessions, activeSession: session),
|
||||||
);
|
);
|
||||||
} else {
|
return session.id;
|
||||||
sessionId = current.activeSession!.id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _persistUserMessage(String sessionId, String content) async {
|
||||||
await _repo.addMessage(
|
await _repo.addMessage(
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
@@ -69,20 +104,143 @@ class ChatController extends _$ChatController {
|
|||||||
);
|
);
|
||||||
final messagesAfterUser = await _repo.getMessages(sessionId);
|
final messagesAfterUser = await _repo.getMessages(sessionId);
|
||||||
state = AsyncValue.data(
|
state = AsyncValue.data(
|
||||||
state.valueOrNull!.copyWith(messages: messagesAfterUser, isTyping: true),
|
state.valueOrNull!.copyWith(
|
||||||
|
messages: messagesAfterUser,
|
||||||
|
isTyping: true,
|
||||||
|
thinkingSteps: [],
|
||||||
|
streamingContent: '',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
await Future<void>.delayed(const Duration(seconds: 1));
|
}
|
||||||
final String response = _getMockResponse(content);
|
|
||||||
|
Future<List<String>> _searchKnowledgeBase(String query) async {
|
||||||
|
final searchStep = _createStep('Searching knowledge base...');
|
||||||
|
List<String> contextChunks = [];
|
||||||
|
try {
|
||||||
|
contextChunks = await _noteRepo.searchSimilar(query, topK: 3);
|
||||||
|
if (contextChunks.isNotEmpty) {
|
||||||
|
_updateStep(
|
||||||
|
searchStep.id,
|
||||||
|
status: ThinkingStepStatus.completed,
|
||||||
|
title: 'Found ${contextChunks.length} documents',
|
||||||
|
details: 'Context added for assistant.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_updateStep(
|
||||||
|
searchStep.id,
|
||||||
|
status: ThinkingStepStatus.completed,
|
||||||
|
title: 'No matching documents in knowledge base',
|
||||||
|
details: 'Responding based on general knowledge.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_updateStep(
|
||||||
|
searchStep.id,
|
||||||
|
status: ThinkingStepStatus.error,
|
||||||
|
title: 'Knowledge base search error',
|
||||||
|
details: e.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return contextChunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, String>> _buildHistory() {
|
||||||
|
final messages = state.valueOrNull?.messages ?? [];
|
||||||
|
return messages
|
||||||
|
.map((m) => <String, String>{
|
||||||
|
'role': m.isUser ? 'user' : 'assistant',
|
||||||
|
'content': m.content,
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _streamResponse(
|
||||||
|
String systemPrompt,
|
||||||
|
List<Map<String, String>> history,
|
||||||
|
) async {
|
||||||
|
final generateStep = _createStep('Generating response...');
|
||||||
|
String fullAiResponse = '';
|
||||||
|
try {
|
||||||
|
final response = await _dio.post<ResponseBody>(
|
||||||
|
AiConstants.chatApiUrl,
|
||||||
|
options: Options(responseType: ResponseType.stream),
|
||||||
|
data: {
|
||||||
|
'messages': [
|
||||||
|
{'role': 'system', 'content': systemPrompt},
|
||||||
|
...history,
|
||||||
|
],
|
||||||
|
'temperature': AiConstants.chatTemperature,
|
||||||
|
'stream': true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
_updateStep(
|
||||||
|
generateStep.id,
|
||||||
|
status: ThinkingStepStatus.running,
|
||||||
|
title: 'Writing...',
|
||||||
|
);
|
||||||
|
final stream = response.data!.stream;
|
||||||
|
await for (final chunk in stream) {
|
||||||
|
final textChunk = utf8.decode(chunk);
|
||||||
|
for (final line in textChunk.split('\n')) {
|
||||||
|
if (!line.startsWith('data: ')) continue;
|
||||||
|
final dataStr = line.substring(6).trim();
|
||||||
|
if (dataStr == '[DONE]') break;
|
||||||
|
if (dataStr.isEmpty) continue;
|
||||||
|
try {
|
||||||
|
final data = jsonDecode(dataStr);
|
||||||
|
final delta = data['choices']?[0]?['delta']?['content'] ?? '';
|
||||||
|
if (delta.isNotEmpty) {
|
||||||
|
fullAiResponse += delta;
|
||||||
|
final updatedState = state.valueOrNull;
|
||||||
|
if (updatedState != null) {
|
||||||
|
state = AsyncValue.data(
|
||||||
|
updatedState.copyWith(streamingContent: fullAiResponse),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_updateStep(
|
||||||
|
generateStep.id,
|
||||||
|
status: ThinkingStepStatus.completed,
|
||||||
|
title: 'Response generated',
|
||||||
|
);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
fullAiResponse += '\n\n[AI model communication error]';
|
||||||
|
_updateStep(
|
||||||
|
generateStep.id,
|
||||||
|
status: ThinkingStepStatus.error,
|
||||||
|
title: 'Generation failed',
|
||||||
|
details: '${e.message}',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
fullAiResponse += '\n\n[Unexpected error]';
|
||||||
|
_updateStep(
|
||||||
|
generateStep.id,
|
||||||
|
status: ThinkingStepStatus.error,
|
||||||
|
title: 'Generation failed',
|
||||||
|
details: e.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return fullAiResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: response,
|
content: aiResponse,
|
||||||
);
|
);
|
||||||
final messagesAfterAi = await _repo.getMessages(sessionId);
|
final messagesAfterAi = await _repo.getMessages(sessionId);
|
||||||
if (messagesAfterAi.length <= 2) {
|
if (messagesAfterAi.length <= 2) {
|
||||||
final title = content.length > 30
|
final title = userContent.length > 30
|
||||||
? '${content.substring(0, 30)}...'
|
? '${userContent.substring(0, 30)}…'
|
||||||
: content;
|
: userContent;
|
||||||
await _repo.updateSessionTitle(sessionId, title);
|
await _repo.updateSessionTitle(sessionId, title);
|
||||||
}
|
}
|
||||||
final sessions = await _repo.getAllSessions();
|
final sessions = await _repo.getAllSessions();
|
||||||
@@ -90,20 +248,59 @@ class ChatController extends _$ChatController {
|
|||||||
state.valueOrNull!.copyWith(
|
state.valueOrNull!.copyWith(
|
||||||
messages: messagesAfterAi,
|
messages: messagesAfterAi,
|
||||||
isTyping: false,
|
isTyping: false,
|
||||||
|
streamingContent: null,
|
||||||
|
thinkingSteps: [],
|
||||||
sessions: sessions,
|
sessions: sessions,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _getMockResponse(String input) {
|
ThinkingStep _createStep(String title) {
|
||||||
final String lower = input.toLowerCase();
|
final step = ThinkingStep(
|
||||||
if (lower.contains('plan') || lower.contains('program')) {
|
id: const Uuid().v4(),
|
||||||
return "I can help you design a training plan! What are your goals? Strength, hypertrophy, or endurance?";
|
title: title,
|
||||||
} else if (lower.contains('squat') || lower.contains('bench')) {
|
status: ThinkingStepStatus.pending,
|
||||||
return "Compound movements are great. Remember to maintain proper form. For squats, keep your chest up and knees tracking over toes.";
|
);
|
||||||
} else if (lower.contains('nutrition') || lower.contains('eat')) {
|
final current = state.valueOrNull;
|
||||||
return "Nutrition is key. Aim for 1.6-2.2g of protein per kg of bodyweight if you're training hard.";
|
if (current != null) {
|
||||||
|
state = AsyncValue.data(
|
||||||
|
current.copyWith(thinkingSteps: [...current.thinkingSteps, step]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return "I'm your AI training assistant. How can I help you today?";
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _buildSystemPrompt(List<String> chunks) {
|
||||||
|
if (chunks.isEmpty) return AiConstants.baseSystemPrompt;
|
||||||
|
final contextBlock = chunks
|
||||||
|
.asMap()
|
||||||
|
.entries
|
||||||
|
.map((e) => '[${e.key + 1}] ${e.value}')
|
||||||
|
.join('\n\n');
|
||||||
|
return '${AiConstants.baseSystemPrompt}\n\n'
|
||||||
|
'### Relevant notes from the trainer\'s knowledge base:\n'
|
||||||
|
'$contextBlock\n\n'
|
||||||
|
'Use the above context to inform your response when relevant. '
|
||||||
|
'If the context is not directly applicable, rely on your general '
|
||||||
|
'fitness knowledge.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,24 @@ part of 'chat_controller.dart';
|
|||||||
// RiverpodGenerator
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$chatControllerHash() => r'44a3d0e906eaad16f7a9c292fe847b8bd144c835';
|
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,14 +1,19 @@
|
|||||||
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:trainhub_flutter/core/constants/ui_constants.dart';
|
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||||
import 'package:trainhub_flutter/domain/entities/chat_message.dart';
|
import 'package:trainhub_flutter/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';
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
class ChatPage extends ConsumerStatefulWidget {
|
class ChatPage extends ConsumerStatefulWidget {
|
||||||
@@ -46,12 +51,11 @@ 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) {
|
||||||
try {
|
try {
|
||||||
@@ -72,39 +76,39 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final modelsValidated =
|
||||||
|
ref.watch(aiModelSettingsControllerProvider).areModelsValidated;
|
||||||
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) <
|
||||||
next.value!.messages.length) {
|
next.value!.messages.length) {
|
||||||
_scrollToBottom();
|
_scrollToBottom();
|
||||||
}
|
}
|
||||||
if (next.hasValue && next.value!.isTyping && !(prev?.value?.isTyping ?? false)) {
|
if (next.hasValue &&
|
||||||
|
next.value!.isTyping &&
|
||||||
|
!(prev?.value?.isTyping ?? false)) {
|
||||||
_scrollToBottom();
|
_scrollToBottom();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if (!modelsValidated) {
|
||||||
|
return const Scaffold(
|
||||||
|
backgroundColor: AppColors.surface,
|
||||||
|
body: MissingModelsState(),
|
||||||
|
);
|
||||||
|
}
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.surface,
|
backgroundColor: AppColors.surface,
|
||||||
body: Row(
|
body: Row(
|
||||||
children: [
|
children: [
|
||||||
// --- Side Panel ---
|
|
||||||
_buildSidePanel(state, controller),
|
_buildSidePanel(state, controller),
|
||||||
|
Expanded(child: _buildChatArea(state, controller)),
|
||||||
// --- Main Chat Area ---
|
|
||||||
Expanded(
|
|
||||||
child: _buildChatArea(state, controller),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Side Panel
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
Widget _buildSidePanel(
|
Widget _buildSidePanel(
|
||||||
AsyncValue<ChatState> asyncState,
|
AsyncValue<ChatState> asyncState,
|
||||||
ChatController controller,
|
ChatController controller,
|
||||||
@@ -113,31 +117,25 @@ 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: [
|
||||||
// New Chat button
|
|
||||||
Padding(
|
Padding(
|
||||||
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),
|
||||||
|
|
||||||
// Session list
|
|
||||||
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(
|
||||||
@@ -155,17 +153,15 @@ 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(color: AppColors.textMuted, fontSize: 13),
|
style: TextStyle(color: AppColors.textMuted, fontSize: 13),
|
||||||
@@ -194,13 +190,10 @@ 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(() {
|
||||||
if (_hoveredSessionId == session.id) {
|
if (_hoveredSessionId == session.id) _hoveredSessionId = null;
|
||||||
_hoveredSessionId = null;
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => controller.loadSession(session.id),
|
onTap: () => controller.loadSession(session.id),
|
||||||
@@ -220,11 +213,11 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
: isHovered
|
: isHovered
|
||||||
? AppColors.zinc800.withValues(alpha: 0.6)
|
? AppColors.zinc800.withValues(alpha: 0.6)
|
||||||
: Colors.transparent,
|
: Colors.transparent,
|
||||||
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
borderRadius:
|
||||||
|
BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||||
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,
|
||||||
),
|
),
|
||||||
@@ -251,7 +244,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Delete button appears on hover
|
|
||||||
AnimatedOpacity(
|
AnimatedOpacity(
|
||||||
duration: const Duration(milliseconds: 150),
|
duration: const Duration(milliseconds: 150),
|
||||||
opacity: isHovered ? 1.0 : 0.0,
|
opacity: isHovered ? 1.0 : 0.0,
|
||||||
@@ -268,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',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -281,22 +274,16 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Chat Area
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
Widget _buildChatArea(
|
Widget _buildChatArea(
|
||||||
AsyncValue<ChatState> asyncState,
|
AsyncValue<ChatState> asyncState,
|
||||||
ChatController controller,
|
ChatController controller,
|
||||||
) {
|
) {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
// Messages
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: asyncState.when(
|
child: asyncState.when(
|
||||||
data: (data) {
|
data: (data) {
|
||||||
if (data.messages.isEmpty) {
|
if (data.messages.isEmpty) return _buildEmptyState();
|
||||||
return _buildEmptyState();
|
|
||||||
}
|
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
@@ -307,26 +294,34 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
data.messages.length + (data.isTyping ? 1 : 0),
|
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(
|
||||||
@@ -349,8 +344,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Input area
|
|
||||||
_buildInputBar(asyncState, controller),
|
_buildInputBar(asyncState, controller),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -386,37 +379,100 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
const SizedBox(height: UIConstants.spacing8),
|
const SizedBox(height: UIConstants.spacing8),
|
||||||
const Text(
|
const Text(
|
||||||
'Start a conversation to get personalized advice.',
|
'Start a conversation to get personalized advice.',
|
||||||
style: TextStyle(
|
style: TextStyle(color: AppColors.textMuted, fontSize: 13),
|
||||||
color: AppColors.textMuted,
|
|
||||||
fontSize: 13,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 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,
|
||||||
border: Border(
|
border: Border(top: BorderSide(color: AppColors.border, width: 1)),
|
||||||
top: BorderSide(color: AppColors.border, width: 1),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: UIConstants.spacing16,
|
horizontal: UIConstants.spacing16,
|
||||||
vertical: UIConstants.spacing12,
|
vertical: UIConstants.spacing12,
|
||||||
),
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
if (statusMessage != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: UIConstants.spacing8),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (isStarting)
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(right: 8),
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
child: const CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: AppColors.info,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (isError)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(right: 8),
|
||||||
|
child: Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
size: 14,
|
||||||
|
color: AppColors.destructive,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
statusMessage,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 12,
|
||||||
|
color: statusColor,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isError)
|
||||||
|
TextButton(
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
minimumSize: Size.zero,
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
),
|
||||||
|
onPressed: () => aiManager.startServers(),
|
||||||
|
child: Text(
|
||||||
|
'Retry',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.accent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -425,20 +481,26 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
color: AppColors.surfaceContainer,
|
color: AppColors.surfaceContainer,
|
||||||
borderRadius:
|
borderRadius:
|
||||||
BorderRadius.circular(UIConstants.borderRadius),
|
BorderRadius.circular(UIConstants.borderRadius),
|
||||||
border: Border.all(color: AppColors.border, width: 1),
|
border:
|
||||||
|
Border.all(color: AppColors.border, width: 1),
|
||||||
),
|
),
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _inputController,
|
controller: _inputController,
|
||||||
focusNode: _inputFocusNode,
|
focusNode: _inputFocusNode,
|
||||||
style: const TextStyle(
|
enabled: isReady,
|
||||||
color: AppColors.textPrimary,
|
style: TextStyle(
|
||||||
|
color: isReady
|
||||||
|
? AppColors.textPrimary
|
||||||
|
: AppColors.textMuted,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
),
|
),
|
||||||
maxLines: 4,
|
maxLines: 4,
|
||||||
minLines: 1,
|
minLines: 1,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Type a message...',
|
hintText: isReady
|
||||||
hintStyle: TextStyle(
|
? 'Type a message...'
|
||||||
|
: 'Waiting for AI...',
|
||||||
|
hintStyle: const TextStyle(
|
||||||
color: AppColors.textMuted,
|
color: AppColors.textMuted,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
),
|
),
|
||||||
@@ -449,8 +511,11 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
enabledBorder: InputBorder.none,
|
enabledBorder: InputBorder.none,
|
||||||
focusedBorder: InputBorder.none,
|
focusedBorder: InputBorder.none,
|
||||||
|
disabledBorder: InputBorder.none,
|
||||||
),
|
),
|
||||||
onSubmitted: (_) => _sendMessage(controller),
|
onSubmitted: isReady
|
||||||
|
? (_) => _sendMessage(controller)
|
||||||
|
: null,
|
||||||
textInputAction: TextInputAction.send,
|
textInputAction: TextInputAction.send,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -460,16 +525,22 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: isTyping ? AppColors.zinc700 : AppColors.accent,
|
color: (isTyping || !isReady)
|
||||||
|
? AppColors.zinc700
|
||||||
|
: AppColors.accent,
|
||||||
borderRadius:
|
borderRadius:
|
||||||
BorderRadius.circular(UIConstants.borderRadius),
|
BorderRadius.circular(UIConstants.borderRadius),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius:
|
borderRadius:
|
||||||
BorderRadius.circular(UIConstants.borderRadius),
|
BorderRadius.circular(UIConstants.borderRadius),
|
||||||
onTap: isTyping ? null : () => _sendMessage(controller),
|
onTap: (isTyping || !isReady)
|
||||||
|
? null
|
||||||
|
: () => _sendMessage(controller),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.arrow_upward_rounded,
|
Icons.arrow_upward_rounded,
|
||||||
color: isTyping ? AppColors.textMuted : AppColors.zinc950,
|
color: (isTyping || !isReady)
|
||||||
|
? AppColors.textMuted
|
||||||
|
: AppColors.zinc950,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -477,288 +548,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// New Chat Button
|
|
||||||
// =============================================================================
|
|
||||||
class _NewChatButton extends StatefulWidget {
|
|
||||||
const _NewChatButton({required this.onPressed});
|
|
||||||
|
|
||||||
final VoidCallback onPressed;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_NewChatButton> createState() => _NewChatButtonState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _NewChatButtonState extends State<_NewChatButton> {
|
|
||||||
bool _isHovered = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return MouseRegion(
|
|
||||||
onEnter: (_) => setState(() => _isHovered = true),
|
|
||||||
onExit: (_) => setState(() => _isHovered = false),
|
|
||||||
child: AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 150),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: _isHovered
|
|
||||||
? AppColors.zinc700
|
|
||||||
: AppColors.surfaceContainerHigh,
|
|
||||||
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
|
||||||
border: Border.all(color: AppColors.border, width: 1),
|
|
||||||
),
|
|
||||||
child: Material(
|
|
||||||
color: Colors.transparent,
|
|
||||||
child: InkWell(
|
|
||||||
borderRadius:
|
|
||||||
BorderRadius.circular(UIConstants.smallBorderRadius),
|
|
||||||
onTap: widget.onPressed,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: UIConstants.spacing12,
|
|
||||||
vertical: UIConstants.spacing8,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: const [
|
|
||||||
Icon(
|
|
||||||
Icons.add_rounded,
|
|
||||||
size: 16,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
SizedBox(width: UIConstants.spacing8),
|
|
||||||
Text(
|
|
||||||
'New Chat',
|
|
||||||
style: TextStyle(
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Message Bubble
|
|
||||||
// =============================================================================
|
|
||||||
class _MessageBubble extends StatelessWidget {
|
|
||||||
const _MessageBubble({
|
|
||||||
required this.message,
|
|
||||||
required this.formattedTime,
|
|
||||||
});
|
|
||||||
|
|
||||||
final ChatMessageEntity message;
|
|
||||||
final String formattedTime;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final isUser = message.isUser;
|
|
||||||
final maxWidth = MediaQuery.of(context).size.width * 0.55;
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: UIConstants.spacing12),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment:
|
|
||||||
isUser ? MainAxisAlignment.end : MainAxisAlignment.start,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
if (!isUser) ...[
|
|
||||||
Container(
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.surfaceContainerHigh,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.auto_awesome_rounded,
|
|
||||||
size: 14,
|
|
||||||
color: AppColors.accent,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: UIConstants.spacing8),
|
|
||||||
],
|
|
||||||
Flexible(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment:
|
|
||||||
isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: UIConstants.spacing16,
|
|
||||||
vertical: UIConstants.spacing12,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isUser
|
|
||||||
? AppColors.zinc700
|
|
||||||
: AppColors.surfaceContainer,
|
|
||||||
borderRadius: BorderRadius.only(
|
|
||||||
topLeft: const Radius.circular(16),
|
|
||||||
topRight: const Radius.circular(16),
|
|
||||||
bottomLeft:
|
|
||||||
isUser ? const Radius.circular(16) : const Radius.circular(4),
|
|
||||||
bottomRight:
|
|
||||||
isUser ? const Radius.circular(4) : const Radius.circular(16),
|
|
||||||
),
|
|
||||||
border: isUser
|
|
||||||
? null
|
|
||||||
: Border.all(color: AppColors.border, width: 1),
|
|
||||||
),
|
|
||||||
child: SelectableText(
|
|
||||||
message.content,
|
|
||||||
style: 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) {
|
|
||||||
// Stagger each dot by 0.2 of the animation cycle
|
|
||||||
final delay = index * 0.2;
|
|
||||||
final t = (_controller.value - delay) % 1.0;
|
|
||||||
// Bounce: use a sin curve over the first half, rest at 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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,13 +23,42 @@ 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(
|
||||||
|
error: e,
|
||||||
|
onRetry: () => ref.read(homeControllerProvider.notifier).refresh(),
|
||||||
|
),
|
||||||
|
data: (data) {
|
||||||
|
if (data.activeProgramName == null) {
|
||||||
|
return AppEmptyState(
|
||||||
|
icon: Icons.calendar_today_outlined,
|
||||||
|
title: 'No active program',
|
||||||
|
subtitle:
|
||||||
|
'Head to Calendar to create or select a training program to get started.',
|
||||||
|
actionLabel: 'Go to Calendar',
|
||||||
|
onAction: () => AutoTabsRouter.of(context).setActiveIndex(3),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _HomeContent(data: data);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ErrorView extends StatelessWidget {
|
||||||
|
const _ErrorView({required this.error, required this.onRetry});
|
||||||
|
|
||||||
|
final Object error;
|
||||||
|
final VoidCallback onRetry;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(UIConstants.pagePadding),
|
padding: const EdgeInsets.all(UIConstants.pagePadding),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
const Icon(
|
||||||
Icons.error_outline,
|
Icons.error_outline,
|
||||||
size: 48,
|
size: 48,
|
||||||
color: AppColors.destructive,
|
color: AppColors.destructive,
|
||||||
@@ -42,7 +74,7 @@ class HomePage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: UIConstants.spacing8),
|
const SizedBox(height: UIConstants.spacing8),
|
||||||
Text(
|
Text(
|
||||||
'$e',
|
'$error',
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: AppColors.textMuted,
|
color: AppColors.textMuted,
|
||||||
@@ -51,39 +83,22 @@ class HomePage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: UIConstants.spacing24),
|
const SizedBox(height: UIConstants.spacing24),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: () =>
|
onPressed: onRetry,
|
||||||
ref.read(homeControllerProvider.notifier).refresh(),
|
|
||||||
icon: const Icon(Icons.refresh, size: 18),
|
icon: const Icon(Icons.refresh, size: 18),
|
||||||
label: const Text('Retry'),
|
label: const Text('Retry'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
data: (data) {
|
|
||||||
if (data.activeProgramName == null) {
|
|
||||||
return AppEmptyState(
|
|
||||||
icon: Icons.calendar_today_outlined,
|
|
||||||
title: 'No active program',
|
|
||||||
subtitle:
|
|
||||||
'Head to Calendar to create or select a training program to get started.',
|
|
||||||
actionLabel: 'Go to Calendar',
|
|
||||||
onAction: () {
|
|
||||||
AutoTabsRouter.of(context).setActiveIndex(3);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return _HomeContent(data: data);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HomeContent extends StatelessWidget {
|
class _HomeContent extends StatelessWidget {
|
||||||
final HomeState data;
|
|
||||||
|
|
||||||
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
lib/presentation/plan_editor/models/exercise_drag_data.dart
Normal file
13
lib/presentation/plan_editor/models/exercise_drag_data.dart
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import 'package:trainhub_flutter/domain/entities/training_exercise.dart';
|
||||||
|
|
||||||
|
class ExerciseDragData {
|
||||||
|
const ExerciseDragData({
|
||||||
|
required this.fromSectionIndex,
|
||||||
|
required this.exerciseIndex,
|
||||||
|
required this.exercise,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int fromSectionIndex;
|
||||||
|
final int exerciseIndex;
|
||||||
|
final TrainingExerciseEntity exercise;
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ import 'package:trainhub_flutter/core/utils/id_generator.dart';
|
|||||||
import 'package:trainhub_flutter/injection.dart';
|
import 'package:trainhub_flutter/injection.dart';
|
||||||
import 'package:trainhub_flutter/domain/entities/exercise.dart';
|
import 'package:trainhub_flutter/domain/entities/exercise.dart';
|
||||||
import 'package:trainhub_flutter/domain/entities/training_exercise.dart';
|
import 'package:trainhub_flutter/domain/entities/training_exercise.dart';
|
||||||
import 'package:trainhub_flutter/domain/entities/training_plan.dart';
|
|
||||||
import 'package:trainhub_flutter/domain/entities/training_section.dart';
|
import 'package:trainhub_flutter/domain/entities/training_section.dart';
|
||||||
import 'package:trainhub_flutter/domain/repositories/exercise_repository.dart';
|
import 'package:trainhub_flutter/domain/repositories/exercise_repository.dart';
|
||||||
import 'package:trainhub_flutter/domain/repositories/training_plan_repository.dart';
|
import 'package:trainhub_flutter/domain/repositories/training_plan_repository.dart';
|
||||||
@@ -14,14 +13,15 @@ part 'plan_editor_controller.g.dart';
|
|||||||
@riverpod
|
@riverpod
|
||||||
class PlanEditorController extends _$PlanEditorController {
|
class PlanEditorController extends _$PlanEditorController {
|
||||||
late TrainingPlanRepository _planRepo;
|
late TrainingPlanRepository _planRepo;
|
||||||
|
late ExerciseRepository _exerciseRepo;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<PlanEditorState> build(String planId) async {
|
Future<PlanEditorState> build(String planId) async {
|
||||||
_planRepo = getIt<TrainingPlanRepository>();
|
_planRepo = getIt<TrainingPlanRepository>();
|
||||||
final ExerciseRepository exerciseRepo = getIt<ExerciseRepository>();
|
_exerciseRepo = getIt<ExerciseRepository>();
|
||||||
|
|
||||||
final plan = await _planRepo.getById(planId);
|
final plan = await _planRepo.getById(planId);
|
||||||
final exercises = await exerciseRepo.getAll();
|
final exercises = await _exerciseRepo.getAll();
|
||||||
|
|
||||||
return PlanEditorState(plan: plan, availableExercises: exercises);
|
return PlanEditorState(plan: plan, availableExercises: exercises);
|
||||||
}
|
}
|
||||||
@@ -64,6 +64,21 @@ class PlanEditorController extends _$PlanEditorController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void reorderSection(int oldIndex, int newIndex) {
|
||||||
|
final current = state.valueOrNull;
|
||||||
|
if (current == null) return;
|
||||||
|
final sections = List<TrainingSectionEntity>.from(current.plan.sections);
|
||||||
|
if (oldIndex < newIndex) newIndex -= 1;
|
||||||
|
final item = sections.removeAt(oldIndex);
|
||||||
|
sections.insert(newIndex, item);
|
||||||
|
state = AsyncValue.data(
|
||||||
|
current.copyWith(
|
||||||
|
plan: current.plan.copyWith(sections: sections),
|
||||||
|
isDirty: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void updateSectionName(int sectionIndex, String name) {
|
void updateSectionName(int sectionIndex, String name) {
|
||||||
final current = state.valueOrNull;
|
final current = state.valueOrNull;
|
||||||
if (current == null) return;
|
if (current == null) return;
|
||||||
@@ -116,6 +131,37 @@ class PlanEditorController extends _$PlanEditorController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void moveExerciseBetweenSections({
|
||||||
|
required int fromSectionIndex,
|
||||||
|
required int exerciseIndex,
|
||||||
|
required int toSectionIndex,
|
||||||
|
}) {
|
||||||
|
final current = state.valueOrNull;
|
||||||
|
if (current == null) return;
|
||||||
|
if (fromSectionIndex == toSectionIndex) return;
|
||||||
|
final sections = List<TrainingSectionEntity>.from(current.plan.sections);
|
||||||
|
final fromExercises = List<TrainingExerciseEntity>.from(
|
||||||
|
sections[fromSectionIndex].exercises,
|
||||||
|
);
|
||||||
|
final toExercises = List<TrainingExerciseEntity>.from(
|
||||||
|
sections[toSectionIndex].exercises,
|
||||||
|
);
|
||||||
|
final exercise = fromExercises.removeAt(exerciseIndex);
|
||||||
|
toExercises.add(exercise);
|
||||||
|
sections[fromSectionIndex] = sections[fromSectionIndex].copyWith(
|
||||||
|
exercises: fromExercises,
|
||||||
|
);
|
||||||
|
sections[toSectionIndex] = sections[toSectionIndex].copyWith(
|
||||||
|
exercises: toExercises,
|
||||||
|
);
|
||||||
|
state = AsyncValue.data(
|
||||||
|
current.copyWith(
|
||||||
|
plan: current.plan.copyWith(sections: sections),
|
||||||
|
isDirty: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void updateExerciseParams(
|
void updateExerciseParams(
|
||||||
int sectionIndex,
|
int sectionIndex,
|
||||||
int exerciseIndex, {
|
int exerciseIndex, {
|
||||||
@@ -168,6 +214,25 @@ class PlanEditorController extends _$PlanEditorController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<ExerciseEntity> createExercise({
|
||||||
|
required String name,
|
||||||
|
String? instructions,
|
||||||
|
String? tags,
|
||||||
|
String? videoUrl,
|
||||||
|
}) async {
|
||||||
|
final current = state.valueOrNull;
|
||||||
|
if (current == null) throw StateError('Controller state not loaded');
|
||||||
|
final exercise = await _exerciseRepo.create(
|
||||||
|
name: name,
|
||||||
|
instructions: instructions,
|
||||||
|
tags: tags,
|
||||||
|
videoUrl: videoUrl,
|
||||||
|
);
|
||||||
|
final exercises = await _exerciseRepo.getAll();
|
||||||
|
state = AsyncValue.data(current.copyWith(availableExercises: exercises));
|
||||||
|
return exercise;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> save() async {
|
Future<void> save() async {
|
||||||
final current = state.valueOrNull;
|
final current = state.valueOrNull;
|
||||||
if (current == null) return;
|
if (current == null) return;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ part of 'plan_editor_controller.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$planEditorControllerHash() =>
|
String _$planEditorControllerHash() =>
|
||||||
r'4045493829126f28b3a58695b68ade53519c1412';
|
r'6c6c2f74725e250bd41401cab12c1a62306d10ea';
|
||||||
|
|
||||||
/// Copied from Dart SDK
|
/// Copied from Dart SDK
|
||||||
class _SystemHash {
|
class _SystemHash {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
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/theme/app_colors.dart';
|
||||||
import 'package:trainhub_flutter/presentation/plan_editor/plan_editor_controller.dart';
|
import 'package:trainhub_flutter/presentation/plan_editor/plan_editor_controller.dart';
|
||||||
import 'package:trainhub_flutter/presentation/plan_editor/widgets/plan_section_card.dart';
|
import 'package:trainhub_flutter/presentation/plan_editor/widgets/plan_section_card.dart';
|
||||||
|
|
||||||
@@ -16,8 +19,59 @@ class PlanEditorPage extends ConsumerWidget {
|
|||||||
final controller = ref.read(planEditorControllerProvider(planId).notifier);
|
final controller = ref.read(planEditorControllerProvider(planId).notifier);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
backgroundColor: AppColors.surface,
|
||||||
title: state.when(
|
body: Column(
|
||||||
|
children: [
|
||||||
|
// --- Custom header bar ---
|
||||||
|
Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AppColors.surfaceContainer,
|
||||||
|
border: Border(bottom: BorderSide(color: AppColors.border)),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UIConstants.spacing24,
|
||||||
|
vertical: UIConstants.spacing12,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Back button
|
||||||
|
Material(
|
||||||
|
color: AppColors.zinc800,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => context.router.maybePop(),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.all(8),
|
||||||
|
child: Icon(
|
||||||
|
Icons.arrow_back,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: UIConstants.spacing16),
|
||||||
|
|
||||||
|
// Plan icon badge
|
||||||
|
Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.accentMuted,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.description_outlined,
|
||||||
|
color: AppColors.accent,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: UIConstants.spacing12),
|
||||||
|
|
||||||
|
// Plan name text field
|
||||||
|
Expanded(
|
||||||
|
child: state.maybeWhen(
|
||||||
data: (data) => TextField(
|
data: (data) => TextField(
|
||||||
controller: TextEditingController(text: data.plan.name)
|
controller: TextEditingController(text: data.plan.name)
|
||||||
..selection = TextSelection.fromPosition(
|
..selection = TextSelection.fromPosition(
|
||||||
@@ -25,46 +79,105 @@ class PlanEditorPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
hintText: 'Plan Name',
|
enabledBorder: InputBorder.none,
|
||||||
|
focusedBorder: InputBorder.none,
|
||||||
|
filled: false,
|
||||||
|
hintText: 'Untitled Plan',
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
letterSpacing: -0.3,
|
||||||
),
|
),
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
|
||||||
onChanged: controller.updatePlanName,
|
onChanged: controller.updatePlanName,
|
||||||
),
|
),
|
||||||
error: (_, __) => const Text('Error'),
|
orElse: () => Text(
|
||||||
loading: () => const Text('Loading...'),
|
'Loading...',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textMuted,
|
||||||
),
|
),
|
||||||
actions: [
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Unsaved changes badge + save button
|
||||||
state.maybeWhen(
|
state.maybeWhen(
|
||||||
data: (data) => IconButton(
|
data: (data) => data.isDirty
|
||||||
icon: Icon(
|
? Row(
|
||||||
Icons.save,
|
children: [
|
||||||
color: data.isDirty ? Theme.of(context).primaryColor : null,
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UIConstants.spacing12,
|
||||||
|
vertical: 4,
|
||||||
),
|
),
|
||||||
onPressed: data.isDirty ? () => controller.save() : null,
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.warning.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.warning.withValues(
|
||||||
|
alpha: 0.3,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Unsaved changes',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppColors.warning,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: UIConstants.spacing12),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: controller.save,
|
||||||
|
icon: const Icon(Icons.save_outlined, size: 16),
|
||||||
|
label: const Text('Save'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
orElse: () => const SizedBox.shrink(),
|
orElse: () => const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: state.when(
|
),
|
||||||
data: (data) => ListView.builder(
|
|
||||||
padding: const EdgeInsets.all(16),
|
// --- Body ---
|
||||||
itemCount: data.plan.sections.length + 1,
|
Expanded(
|
||||||
itemBuilder: (context, index) {
|
child: state.when(
|
||||||
if (index == data.plan.sections.length) {
|
data: (data) => ReorderableListView.builder(
|
||||||
return Center(
|
padding: const EdgeInsets.all(UIConstants.spacing24),
|
||||||
|
onReorder: controller.reorderSection,
|
||||||
|
footer: Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(
|
||||||
child: ElevatedButton.icon(
|
vertical: UIConstants.spacing16,
|
||||||
|
),
|
||||||
|
child: OutlinedButton.icon(
|
||||||
onPressed: controller.addSection,
|
onPressed: controller.addSection,
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add, size: 16),
|
||||||
label: const Text('Add Section'),
|
label: const Text('Add Section'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: AppColors.textSecondary,
|
||||||
|
side: const BorderSide(color: AppColors.border),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UIConstants.spacing24,
|
||||||
|
vertical: UIConstants.spacing12,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}
|
),
|
||||||
|
),
|
||||||
|
itemCount: data.plan.sections.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
final section = data.plan.sections[index];
|
final section = data.plan.sections[index];
|
||||||
return PlanSectionCard(
|
return PlanSectionCard(
|
||||||
|
key: ValueKey(section.id),
|
||||||
section: section,
|
section: section,
|
||||||
sectionIndex: index,
|
sectionIndex: index,
|
||||||
plan: data.plan,
|
plan: data.plan,
|
||||||
@@ -72,9 +185,17 @@ class PlanEditorPage extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
error: (e, s) => Center(child: Text('Error: $e')),
|
error: (e, s) => Center(
|
||||||
|
child: Text(
|
||||||
|
'Error: $e',
|
||||||
|
style: const TextStyle(color: AppColors.destructive),
|
||||||
|
),
|
||||||
|
),
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
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/theme/app_colors.dart';
|
||||||
import 'package:trainhub_flutter/domain/entities/training_exercise.dart';
|
import 'package:trainhub_flutter/domain/entities/training_exercise.dart';
|
||||||
import 'package:trainhub_flutter/domain/entities/training_plan.dart';
|
import 'package:trainhub_flutter/domain/entities/training_plan.dart';
|
||||||
|
import 'package:trainhub_flutter/presentation/plan_editor/models/exercise_drag_data.dart';
|
||||||
import 'package:trainhub_flutter/presentation/plan_editor/plan_editor_controller.dart';
|
import 'package:trainhub_flutter/presentation/plan_editor/plan_editor_controller.dart';
|
||||||
|
|
||||||
class PlanExerciseTile extends ConsumerWidget {
|
class PlanExerciseTile extends ConsumerStatefulWidget {
|
||||||
final TrainingExerciseEntity exercise;
|
final TrainingExerciseEntity exercise;
|
||||||
final int sectionIndex;
|
final int sectionIndex;
|
||||||
final int exerciseIndex;
|
final int exerciseIndex;
|
||||||
@@ -19,100 +23,192 @@ class PlanExerciseTile extends ConsumerWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<PlanExerciseTile> createState() => _PlanExerciseTileState();
|
||||||
final controller = ref.read(planEditorControllerProvider(plan.id).notifier);
|
}
|
||||||
|
|
||||||
return Card(
|
class _PlanExerciseTileState extends ConsumerState<PlanExerciseTile> {
|
||||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
bool _isHovered = false;
|
||||||
elevation: 0,
|
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
@override
|
||||||
shape: RoundedRectangleBorder(
|
Widget build(BuildContext context) {
|
||||||
side: BorderSide(color: Theme.of(context).colorScheme.outlineVariant),
|
final controller = ref.read(
|
||||||
|
planEditorControllerProvider(widget.plan.id).notifier,
|
||||||
|
);
|
||||||
|
final dragData = ExerciseDragData(
|
||||||
|
fromSectionIndex: widget.sectionIndex,
|
||||||
|
exerciseIndex: widget.exerciseIndex,
|
||||||
|
exercise: widget.exercise,
|
||||||
|
);
|
||||||
|
|
||||||
|
return LongPressDraggable<ExerciseDragData>(
|
||||||
|
data: dragData,
|
||||||
|
feedback: Material(
|
||||||
|
elevation: 8,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Container(
|
||||||
|
width: 220,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.zinc800,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: AppColors.accent.withValues(alpha: 0.5)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.drag_indicator,
|
||||||
|
size: 15,
|
||||||
|
color: AppColors.accent,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
widget.exercise.name,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
childWhenDragging: Opacity(
|
||||||
|
opacity: 0.35,
|
||||||
|
child: _buildContent(controller),
|
||||||
|
),
|
||||||
|
child: MouseRegion(
|
||||||
|
onEnter: (_) => setState(() => _isHovered = true),
|
||||||
|
onExit: (_) => setState(() => _isHovered = false),
|
||||||
|
child: _buildContent(controller),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent(PlanEditorController controller) {
|
||||||
|
return AnimatedContainer(
|
||||||
|
duration: UIConstants.animationDuration,
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _isHovered ? AppColors.zinc800 : AppColors.zinc900,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: _isHovered ? AppColors.zinc700 : AppColors.border,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
// --- Header: drag handle + name + delete ---
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.drag_handle,
|
||||||
|
size: 15,
|
||||||
|
color: AppColors.zinc600,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
exercise.name,
|
widget.exercise.name,
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
AnimatedOpacity(
|
||||||
|
opacity: _isHovered ? 1.0 : 0.0,
|
||||||
|
duration: UIConstants.animationDuration,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => controller.removeExerciseFromSection(
|
||||||
|
widget.sectionIndex,
|
||||||
|
widget.exerciseIndex,
|
||||||
|
),
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.all(2),
|
||||||
|
child: Icon(
|
||||||
|
Icons.close,
|
||||||
|
size: 14,
|
||||||
|
color: AppColors.textMuted,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.close, size: 18),
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
constraints: const BoxConstraints(),
|
|
||||||
onPressed: () => controller.removeExerciseFromSection(
|
|
||||||
sectionIndex,
|
|
||||||
exerciseIndex,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// --- Fields row ---
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
_buildNumberInput(
|
_FieldBox(
|
||||||
context,
|
|
||||||
label: 'Sets',
|
label: 'Sets',
|
||||||
value: exercise.sets,
|
value: widget.exercise.sets,
|
||||||
onChanged: (val) => controller.updateExerciseParams(
|
onChanged: (val) => controller.updateExerciseParams(
|
||||||
sectionIndex,
|
widget.sectionIndex,
|
||||||
exerciseIndex,
|
widget.exerciseIndex,
|
||||||
sets: val,
|
sets: val,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_buildNumberInput(
|
_FieldBox(
|
||||||
context,
|
label: widget.exercise.isTime ? 'Secs' : 'Reps',
|
||||||
label: exercise.isTime ? 'Secs' : 'Reps',
|
value: widget.exercise.value,
|
||||||
value: exercise.value,
|
|
||||||
onChanged: (val) => controller.updateExerciseParams(
|
onChanged: (val) => controller.updateExerciseParams(
|
||||||
sectionIndex,
|
widget.sectionIndex,
|
||||||
exerciseIndex,
|
widget.exerciseIndex,
|
||||||
value: val,
|
value: val,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_buildNumberInput(
|
_FieldBox(
|
||||||
context,
|
|
||||||
label: 'Rest(s)',
|
label: 'Rest(s)',
|
||||||
value: exercise.rest,
|
value: widget.exercise.rest,
|
||||||
onChanged: (val) => controller.updateExerciseParams(
|
onChanged: (val) => controller.updateExerciseParams(
|
||||||
sectionIndex,
|
widget.sectionIndex,
|
||||||
exerciseIndex,
|
widget.exerciseIndex,
|
||||||
rest: val,
|
rest: val,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
// Toggle Time/Reps
|
// Time toggle pill
|
||||||
InkWell(
|
GestureDetector(
|
||||||
onTap: () => controller.updateExerciseParams(
|
onTap: () => controller.updateExerciseParams(
|
||||||
sectionIndex,
|
widget.sectionIndex,
|
||||||
exerciseIndex,
|
widget.exerciseIndex,
|
||||||
isTime: !exercise.isTime,
|
isTime: !widget.exercise.isTime,
|
||||||
|
),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: UIConstants.animationDuration,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 6,
|
||||||
),
|
),
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
color: widget.exercise.isTime
|
||||||
|
? AppColors.accentMuted
|
||||||
|
: AppColors.zinc800,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: Theme.of(context).colorScheme.outline,
|
color: widget.exercise.isTime
|
||||||
|
? AppColors.accent.withValues(alpha: 0.4)
|
||||||
|
: AppColors.border,
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
color: exercise.isTime
|
|
||||||
? Theme.of(context).colorScheme.primaryContainer
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.timer,
|
Icons.timer_outlined,
|
||||||
size: 16,
|
size: 15,
|
||||||
color: exercise.isTime
|
color: widget.exercise.isTime
|
||||||
? Theme.of(context).colorScheme.onPrimaryContainer
|
? AppColors.accent
|
||||||
: null,
|
: AppColors.textMuted,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -123,38 +219,75 @@ class PlanExerciseTile extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildNumberInput(
|
// ---------------------------------------------------------------------------
|
||||||
BuildContext context, {
|
// Compact field box (label + number input)
|
||||||
required String label,
|
// ---------------------------------------------------------------------------
|
||||||
required int value,
|
class _FieldBox extends StatelessWidget {
|
||||||
required Function(int) onChanged,
|
final String label;
|
||||||
}) {
|
final int value;
|
||||||
|
final Function(int) onChanged;
|
||||||
|
|
||||||
|
const _FieldBox({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(label, style: Theme.of(context).textTheme.labelSmall),
|
Text(
|
||||||
|
label,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 3),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 40,
|
height: 32,
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: TextEditingController(text: value.toString())
|
controller: TextEditingController(text: value.toString())
|
||||||
..selection = TextSelection.fromPosition(
|
..selection = TextSelection.fromPosition(
|
||||||
TextPosition(offset: value.toString().length),
|
TextPosition(offset: value.toString().length),
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
decoration: const InputDecoration(
|
textAlign: TextAlign.center,
|
||||||
border: OutlineInputBorder(),
|
style: GoogleFonts.inter(
|
||||||
contentPadding: EdgeInsets.symmetric(
|
fontSize: 13,
|
||||||
horizontal: 8,
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
filled: true,
|
||||||
|
fillColor: AppColors.zinc950,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
vertical: 0,
|
vertical: 0,
|
||||||
),
|
),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
borderSide: const BorderSide(color: AppColors.border),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
borderSide: const BorderSide(color: AppColors.border),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
borderSide: const BorderSide(color: AppColors.zinc500),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
onChanged: (val) {
|
onChanged: (val) {
|
||||||
final intVal = int.tryParse(val);
|
final intVal = int.tryParse(val);
|
||||||
if (intVal != null) {
|
if (intVal != null) onChanged(intVal);
|
||||||
onChanged(intVal);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
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/theme/app_colors.dart';
|
||||||
import 'package:trainhub_flutter/domain/entities/exercise.dart';
|
import 'package:trainhub_flutter/domain/entities/exercise.dart';
|
||||||
import 'package:trainhub_flutter/domain/entities/training_plan.dart';
|
import 'package:trainhub_flutter/domain/entities/training_plan.dart';
|
||||||
import 'package:trainhub_flutter/domain/entities/training_section.dart';
|
import 'package:trainhub_flutter/domain/entities/training_section.dart';
|
||||||
|
import 'package:trainhub_flutter/presentation/plan_editor/models/exercise_drag_data.dart';
|
||||||
import 'package:trainhub_flutter/presentation/plan_editor/plan_editor_controller.dart';
|
import 'package:trainhub_flutter/presentation/plan_editor/plan_editor_controller.dart';
|
||||||
import 'package:trainhub_flutter/presentation/plan_editor/widgets/plan_exercise_tile.dart';
|
import 'package:trainhub_flutter/presentation/plan_editor/widgets/plan_exercise_tile.dart';
|
||||||
|
|
||||||
class PlanSectionCard extends ConsumerWidget {
|
class PlanSectionCard extends ConsumerStatefulWidget {
|
||||||
final TrainingSectionEntity section;
|
final TrainingSectionEntity section;
|
||||||
final int sectionIndex;
|
final int sectionIndex;
|
||||||
final TrainingPlanEntity plan;
|
final TrainingPlanEntity plan;
|
||||||
@@ -21,74 +25,252 @@ class PlanSectionCard extends ConsumerWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<PlanSectionCard> createState() => _PlanSectionCardState();
|
||||||
final controller = ref.read(planEditorControllerProvider(plan.id).notifier);
|
}
|
||||||
|
|
||||||
return Card(
|
class _PlanSectionCardState extends ConsumerState<PlanSectionCard> {
|
||||||
margin: const EdgeInsets.only(bottom: 16),
|
bool _isDragOver = false;
|
||||||
child: Padding(
|
bool _isHovered = false;
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final controller = ref.read(
|
||||||
|
planEditorControllerProvider(widget.plan.id).notifier,
|
||||||
|
);
|
||||||
|
|
||||||
|
return DragTarget<ExerciseDragData>(
|
||||||
|
onWillAcceptWithDetails: (details) =>
|
||||||
|
details.data.fromSectionIndex != widget.sectionIndex,
|
||||||
|
onAcceptWithDetails: (details) {
|
||||||
|
controller.moveExerciseBetweenSections(
|
||||||
|
fromSectionIndex: details.data.fromSectionIndex,
|
||||||
|
exerciseIndex: details.data.exerciseIndex,
|
||||||
|
toSectionIndex: widget.sectionIndex,
|
||||||
|
);
|
||||||
|
setState(() => _isDragOver = false);
|
||||||
|
},
|
||||||
|
onLeave: (_) => setState(() => _isDragOver = false),
|
||||||
|
onMove: (_) => setState(() => _isDragOver = true),
|
||||||
|
builder: (context, candidateData, rejectedData) {
|
||||||
|
return MouseRegion(
|
||||||
|
onEnter: (_) => setState(() => _isHovered = true),
|
||||||
|
onExit: (_) => setState(() => _isHovered = false),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: UIConstants.animationDuration,
|
||||||
|
margin: const EdgeInsets.only(bottom: UIConstants.spacing16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.surfaceContainer,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: _isDragOver ? AppColors.accent : AppColors.border,
|
||||||
|
width: _isDragOver ? 2 : 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
// --- Section header ---
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
UIConstants.spacing16,
|
||||||
|
UIConstants.spacing12,
|
||||||
|
UIConstants.spacing8,
|
||||||
|
UIConstants.spacing12,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.drag_handle,
|
||||||
|
color: AppColors.zinc600,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
const SizedBox(width: UIConstants.spacing8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: TextEditingController(text: section.name)
|
controller: TextEditingController(
|
||||||
..selection = TextSelection.fromPosition(
|
text: widget.section.name,
|
||||||
TextPosition(offset: section.name.length),
|
)..selection = TextSelection.fromPosition(
|
||||||
|
TextPosition(
|
||||||
|
offset: widget.section.name.length,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
hintText: 'Section Name',
|
enabledBorder: InputBorder.none,
|
||||||
|
focusedBorder: InputBorder.none,
|
||||||
|
filled: false,
|
||||||
|
hintText: 'Section name',
|
||||||
isDense: true,
|
isDense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: GoogleFonts.inter(
|
||||||
onChanged: (val) =>
|
fontSize: 14,
|
||||||
controller.updateSectionName(sectionIndex, val),
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
onChanged: (val) => controller.updateSectionName(
|
||||||
|
widget.sectionIndex,
|
||||||
|
val,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
),
|
||||||
icon: const Icon(Icons.delete_outline),
|
// Exercise count badge
|
||||||
onPressed: () => controller.deleteSection(sectionIndex),
|
if (widget.section.exercises.isNotEmpty)
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(
|
||||||
|
right: UIConstants.spacing8,
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.zinc800,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${widget.section.exercises.length}',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Delete button — shows on hover
|
||||||
|
AnimatedOpacity(
|
||||||
|
opacity: _isHovered ? 1.0 : 0.0,
|
||||||
|
duration: UIConstants.animationDuration,
|
||||||
|
child: Material(
|
||||||
|
color: AppColors.destructive.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () =>
|
||||||
|
controller.deleteSection(widget.sectionIndex),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.all(6),
|
||||||
|
child: Icon(
|
||||||
|
Icons.delete_outline,
|
||||||
|
color: AppColors.destructive,
|
||||||
|
size: 15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: UIConstants.spacing4),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const Divider(height: 1),
|
||||||
|
|
||||||
|
// --- Drop zone indicator ---
|
||||||
|
if (_isDragOver)
|
||||||
|
Container(
|
||||||
|
height: 48,
|
||||||
|
margin: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UIConstants.spacing12,
|
||||||
|
vertical: UIConstants.spacing8,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.accentMuted,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.accent.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.add_circle_outline,
|
||||||
|
color: AppColors.accent,
|
||||||
|
size: 15,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'Drop exercise here',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
color: AppColors.accent,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const Divider(),
|
),
|
||||||
ReorderableListView.builder(
|
),
|
||||||
|
|
||||||
|
// --- Exercise list ---
|
||||||
|
if (widget.section.exercises.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UIConstants.spacing12,
|
||||||
|
vertical: UIConstants.spacing8,
|
||||||
|
),
|
||||||
|
child: ReorderableListView.builder(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
itemCount: section.exercises.length,
|
itemCount: widget.section.exercises.length,
|
||||||
onReorder: (oldIndex, newIndex) =>
|
onReorder: (oldIndex, newIndex) =>
|
||||||
controller.reorderExercise(sectionIndex, oldIndex, newIndex),
|
controller.reorderExercise(
|
||||||
|
widget.sectionIndex,
|
||||||
|
oldIndex,
|
||||||
|
newIndex,
|
||||||
|
),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final exercise = section.exercises[index];
|
final exercise = widget.section.exercises[index];
|
||||||
return PlanExerciseTile(
|
return PlanExerciseTile(
|
||||||
key: ValueKey(exercise.instanceId),
|
key: ValueKey(exercise.instanceId),
|
||||||
exercise: exercise,
|
exercise: exercise,
|
||||||
sectionIndex: sectionIndex,
|
sectionIndex: widget.sectionIndex,
|
||||||
exerciseIndex: index,
|
exerciseIndex: index,
|
||||||
plan: plan,
|
plan: widget.plan,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
),
|
||||||
Center(
|
|
||||||
child: TextButton.icon(
|
// --- Bottom actions ---
|
||||||
onPressed: () {
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
UIConstants.spacing12,
|
||||||
|
UIConstants.spacing4,
|
||||||
|
UIConstants.spacing12,
|
||||||
|
UIConstants.spacing12,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_ActionButton(
|
||||||
|
icon: Icons.add,
|
||||||
|
label: 'Add Exercise',
|
||||||
|
onTap: () =>
|
||||||
_showExercisePicker(context, (exercise) {
|
_showExercisePicker(context, (exercise) {
|
||||||
controller.addExerciseToSection(sectionIndex, exercise);
|
controller.addExerciseToSection(
|
||||||
});
|
widget.sectionIndex,
|
||||||
},
|
exercise,
|
||||||
icon: const Icon(Icons.add),
|
);
|
||||||
label: const Text('Add Exercise'),
|
}),
|
||||||
|
),
|
||||||
|
const SizedBox(width: UIConstants.spacing8),
|
||||||
|
_ActionButton(
|
||||||
|
icon: Icons.create_outlined,
|
||||||
|
label: 'Create New',
|
||||||
|
onTap: () =>
|
||||||
|
_showCreateExerciseDialog(context, controller),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showExercisePicker(
|
void _showExercisePicker(
|
||||||
@@ -107,22 +289,77 @@ class PlanSectionCard extends ConsumerWidget {
|
|||||||
builder: (context, scrollController) {
|
builder: (context, scrollController) {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Container(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.fromLTRB(24, 20, 24, 12),
|
||||||
child: Text(
|
decoration: const BoxDecoration(
|
||||||
|
border: Border(bottom: BorderSide(color: AppColors.border)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
'Select Exercise',
|
'Select Exercise',
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
'${widget.availableExercises.length} available',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
controller: scrollController,
|
controller: scrollController,
|
||||||
itemCount: availableExercises.length,
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: UIConstants.spacing8,
|
||||||
|
),
|
||||||
|
itemCount: widget.availableExercises.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final exercise = availableExercises[index];
|
final exercise = widget.availableExercises[index];
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Text(exercise.name),
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
subtitle: Text(exercise.muscleGroup ?? ''),
|
horizontal: UIConstants.spacing24,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
leading: Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.zinc800,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.fitness_center,
|
||||||
|
size: 16,
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
exercise.name,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: exercise.muscleGroup != null &&
|
||||||
|
exercise.muscleGroup!.isNotEmpty
|
||||||
|
? Text(
|
||||||
|
exercise.muscleGroup!,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
onSelect(exercise);
|
onSelect(exercise);
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
@@ -137,4 +374,136 @@ class PlanSectionCard extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showCreateExerciseDialog(
|
||||||
|
BuildContext context,
|
||||||
|
PlanEditorController controller,
|
||||||
|
) {
|
||||||
|
final nameCtrl = TextEditingController();
|
||||||
|
final instructionsCtrl = TextEditingController();
|
||||||
|
final tagsCtrl = TextEditingController();
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text('Create New Exercise'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: UIConstants.dialogWidth,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: nameCtrl,
|
||||||
|
decoration: const InputDecoration(labelText: 'Name *'),
|
||||||
|
autofocus: true,
|
||||||
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing12),
|
||||||
|
TextField(
|
||||||
|
controller: instructionsCtrl,
|
||||||
|
decoration: const InputDecoration(labelText: 'Instructions'),
|
||||||
|
maxLines: 2,
|
||||||
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing12),
|
||||||
|
TextField(
|
||||||
|
controller: tagsCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Tags (comma separated)',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () async {
|
||||||
|
if (nameCtrl.text.isEmpty) return;
|
||||||
|
Navigator.pop(dialogContext);
|
||||||
|
final exercise = await controller.createExercise(
|
||||||
|
name: nameCtrl.text,
|
||||||
|
instructions: instructionsCtrl.text.isEmpty
|
||||||
|
? null
|
||||||
|
: instructionsCtrl.text,
|
||||||
|
tags: tagsCtrl.text.isEmpty ? null : tagsCtrl.text,
|
||||||
|
);
|
||||||
|
controller.addExerciseToSection(widget.sectionIndex, exercise);
|
||||||
|
},
|
||||||
|
child: const Text('Create & Add'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Small action button (Add Exercise / Create New)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
class _ActionButton extends StatefulWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _ActionButton({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ActionButton> createState() => _ActionButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ActionButtonState extends State<_ActionButton> {
|
||||||
|
bool _isHovered = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MouseRegion(
|
||||||
|
onEnter: (_) => setState(() => _isHovered = true),
|
||||||
|
onExit: (_) => setState(() => _isHovered = false),
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: widget.onTap,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: UIConstants.animationDuration,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _isHovered ? AppColors.zinc800 : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
border: Border.all(
|
||||||
|
color: _isHovered ? AppColors.zinc600 : AppColors.border,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
widget.icon,
|
||||||
|
size: 13,
|
||||||
|
color: _isHovered
|
||||||
|
? AppColors.textSecondary
|
||||||
|
: AppColors.textMuted,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 5),
|
||||||
|
Text(
|
||||||
|
widget.label,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: _isHovered
|
||||||
|
? AppColors.textSecondary
|
||||||
|
: AppColors.textMuted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
214
lib/presentation/settings/ai_model_settings_controller.dart
Normal file
214
lib/presentation/settings/ai_model_settings_controller.dart
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:archive/archive_io.dart';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:trainhub_flutter/core/constants/ai_constants.dart';
|
||||||
|
import 'package:trainhub_flutter/data/services/ai_process_manager.dart';
|
||||||
|
import 'package:trainhub_flutter/injection.dart' as di;
|
||||||
|
import 'package:trainhub_flutter/presentation/settings/ai_model_settings_state.dart';
|
||||||
|
|
||||||
|
part 'ai_model_settings_controller.g.dart';
|
||||||
|
|
||||||
|
Future<String> _llamaArchiveUrl() async {
|
||||||
|
final build = AiConstants.llamaBuild;
|
||||||
|
if (Platform.isMacOS) {
|
||||||
|
final result = await Process.run('uname', ['-m']);
|
||||||
|
final arch = (result.stdout as String).trim();
|
||||||
|
final suffix = arch == 'arm64' ? 'macos-arm64' : 'macos-x64';
|
||||||
|
return 'https://github.com/ggml-org/llama.cpp/releases/download/$build/llama-$build-bin-$suffix.tar.gz';
|
||||||
|
} else if (Platform.isWindows) {
|
||||||
|
return 'https://github.com/ggml-org/llama.cpp/releases/download/$build/llama-$build-bin-win-vulkan-x64.zip';
|
||||||
|
} else if (Platform.isLinux) {
|
||||||
|
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}');
|
||||||
|
}
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
class AiModelSettingsController extends _$AiModelSettingsController {
|
||||||
|
final _dio = Dio();
|
||||||
|
|
||||||
|
@override
|
||||||
|
AiModelSettingsState build() => const AiModelSettingsState();
|
||||||
|
|
||||||
|
Future<void> validateModels() async {
|
||||||
|
state = state.copyWith(
|
||||||
|
currentTask: 'Checking installed files…',
|
||||||
|
errorMessage: null,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
|
final base = dir.path;
|
||||||
|
final serverBin = File(p.join(base, AiConstants.serverBinaryName));
|
||||||
|
final nomicModel = File(p.join(base, AiConstants.nomicModelFile));
|
||||||
|
final qwenModel = File(p.join(base, AiConstants.qwenModelFile));
|
||||||
|
final validated =
|
||||||
|
serverBin.existsSync() &&
|
||||||
|
nomicModel.existsSync() &&
|
||||||
|
qwenModel.existsSync();
|
||||||
|
state = state.copyWith(
|
||||||
|
areModelsValidated: validated,
|
||||||
|
currentTask: validated ? 'All files present.' : 'Files missing.',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
state = state.copyWith(
|
||||||
|
areModelsValidated: false,
|
||||||
|
currentTask: 'Validation failed.',
|
||||||
|
errorMessage: e.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> downloadAll() async {
|
||||||
|
if (state.isDownloading) return;
|
||||||
|
try {
|
||||||
|
await di.getIt<AiProcessManager>().stopServers();
|
||||||
|
} catch (_) {}
|
||||||
|
state = state.copyWith(
|
||||||
|
isDownloading: true,
|
||||||
|
progress: 0.0,
|
||||||
|
areModelsValidated: false,
|
||||||
|
errorMessage: null,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
|
final archiveUrl = await _llamaArchiveUrl();
|
||||||
|
final archiveExt = archiveUrl.endsWith('.zip') ? '.zip' : '.tar.gz';
|
||||||
|
final archivePath = p.join(dir.path, 'llama_binary$archiveExt');
|
||||||
|
await _downloadFile(
|
||||||
|
url: archiveUrl,
|
||||||
|
savePath: archivePath,
|
||||||
|
taskLabel: 'Downloading llama.cpp binary…',
|
||||||
|
overallStart: 0.0,
|
||||||
|
overallEnd: 0.2,
|
||||||
|
);
|
||||||
|
state = state.copyWith(
|
||||||
|
currentTask: 'Extracting llama.cpp binary…',
|
||||||
|
progress: 0.2,
|
||||||
|
);
|
||||||
|
await _extractBinary(archivePath, dir.path);
|
||||||
|
final archiveFile = File(archivePath);
|
||||||
|
if (archiveFile.existsSync()) archiveFile.deleteSync();
|
||||||
|
await _downloadFile(
|
||||||
|
url: AiConstants.nomicModelUrl,
|
||||||
|
savePath: p.join(dir.path, AiConstants.nomicModelFile),
|
||||||
|
taskLabel: 'Downloading Nomic embedding model…',
|
||||||
|
overallStart: 0.2,
|
||||||
|
overallEnd: 0.55,
|
||||||
|
);
|
||||||
|
await _downloadFile(
|
||||||
|
url: AiConstants.qwenModelUrl,
|
||||||
|
savePath: p.join(dir.path, AiConstants.qwenModelFile),
|
||||||
|
taskLabel: 'Downloading Qwen 2.5 7B model…',
|
||||||
|
overallStart: 0.55,
|
||||||
|
overallEnd: 1.0,
|
||||||
|
);
|
||||||
|
state = state.copyWith(
|
||||||
|
isDownloading: false,
|
||||||
|
progress: 1.0,
|
||||||
|
currentTask: 'Download complete.',
|
||||||
|
);
|
||||||
|
await validateModels();
|
||||||
|
} on DioException catch (e) {
|
||||||
|
state = state.copyWith(
|
||||||
|
isDownloading: false,
|
||||||
|
currentTask: 'Download failed.',
|
||||||
|
errorMessage: 'Network error: ${e.message}',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
state = state.copyWith(
|
||||||
|
isDownloading: false,
|
||||||
|
currentTask: 'Download failed.',
|
||||||
|
errorMessage: e.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _downloadFile({
|
||||||
|
required String url,
|
||||||
|
required String savePath,
|
||||||
|
required String taskLabel,
|
||||||
|
required double overallStart,
|
||||||
|
required double overallEnd,
|
||||||
|
}) async {
|
||||||
|
state = state.copyWith(currentTask: taskLabel, progress: overallStart);
|
||||||
|
await _dio.download(
|
||||||
|
url,
|
||||||
|
savePath,
|
||||||
|
onReceiveProgress: (received, total) {
|
||||||
|
if (total <= 0) return;
|
||||||
|
final fileProgress = received / total;
|
||||||
|
final overall =
|
||||||
|
overallStart + fileProgress * (overallEnd - overallStart);
|
||||||
|
state = state.copyWith(progress: overall);
|
||||||
|
},
|
||||||
|
options: Options(
|
||||||
|
followRedirects: true,
|
||||||
|
maxRedirects: 5,
|
||||||
|
receiveTimeout: const Duration(hours: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _extractBinary(String archivePath, String destDir) async {
|
||||||
|
final extractDir = p.join(destDir, '_llama_extract_tmp');
|
||||||
|
final extractDirObj = Directory(extractDir);
|
||||||
|
if (extractDirObj.existsSync()) extractDirObj.deleteSync(recursive: true);
|
||||||
|
extractDirObj.createSync(recursive: true);
|
||||||
|
try {
|
||||||
|
await extractFileToDisk(archivePath, extractDir);
|
||||||
|
bool foundServer = false;
|
||||||
|
final binaryName = AiConstants.serverBinaryName;
|
||||||
|
for (final entity in extractDirObj.listSync(recursive: true)) {
|
||||||
|
if (entity is File) {
|
||||||
|
final ext = p.extension(entity.path).toLowerCase();
|
||||||
|
final name = p.basename(entity.path);
|
||||||
|
if (name == binaryName ||
|
||||||
|
ext == '.dll' ||
|
||||||
|
ext == '.so' ||
|
||||||
|
ext == '.dylib') {
|
||||||
|
final destFile = p.join(destDir, name);
|
||||||
|
int retryCount = 0;
|
||||||
|
bool success = false;
|
||||||
|
while (!success && retryCount < 5) {
|
||||||
|
try {
|
||||||
|
if (File(destFile).existsSync()) {
|
||||||
|
File(destFile).deleteSync();
|
||||||
|
}
|
||||||
|
entity.copySync(destFile);
|
||||||
|
success = true;
|
||||||
|
} on FileSystemException catch (_) {
|
||||||
|
if (retryCount >= 4) {
|
||||||
|
throw Exception(
|
||||||
|
'Failed to overwrite $name. Ensure no other applications are using it.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
retryCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (name == binaryName) {
|
||||||
|
foundServer = true;
|
||||||
|
if (Platform.isMacOS || Platform.isLinux) {
|
||||||
|
await Process.run('chmod', ['+x', destFile]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!foundServer) {
|
||||||
|
throw FileSystemException(
|
||||||
|
'llama-server binary not found in archive.',
|
||||||
|
archivePath,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (extractDirObj.existsSync()) {
|
||||||
|
extractDirObj.deleteSync(recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'ai_model_settings_controller.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
String _$aiModelSettingsControllerHash() =>
|
||||||
|
r'27a37c3fafb21b93a8b5523718f1537419bd382a';
|
||||||
|
|
||||||
|
/// See also [AiModelSettingsController].
|
||||||
|
@ProviderFor(AiModelSettingsController)
|
||||||
|
final aiModelSettingsControllerProvider =
|
||||||
|
AutoDisposeNotifierProvider<
|
||||||
|
AiModelSettingsController,
|
||||||
|
AiModelSettingsState
|
||||||
|
>.internal(
|
||||||
|
AiModelSettingsController.new,
|
||||||
|
name: r'aiModelSettingsControllerProvider',
|
||||||
|
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||||
|
? null
|
||||||
|
: _$aiModelSettingsControllerHash,
|
||||||
|
dependencies: null,
|
||||||
|
allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
typedef _$AiModelSettingsController = AutoDisposeNotifier<AiModelSettingsState>;
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||||
14
lib/presentation/settings/ai_model_settings_state.dart
Normal file
14
lib/presentation/settings/ai_model_settings_state.dart
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'ai_model_settings_state.freezed.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class AiModelSettingsState with _$AiModelSettingsState {
|
||||||
|
const factory AiModelSettingsState({
|
||||||
|
@Default(false) bool isDownloading,
|
||||||
|
@Default(0.0) double progress,
|
||||||
|
@Default('') String currentTask,
|
||||||
|
@Default(false) bool areModelsValidated,
|
||||||
|
String? errorMessage,
|
||||||
|
}) = _AiModelSettingsState;
|
||||||
|
}
|
||||||
263
lib/presentation/settings/ai_model_settings_state.freezed.dart
Normal file
263
lib/presentation/settings/ai_model_settings_state.freezed.dart
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
// coverage:ignore-file
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||||
|
|
||||||
|
part of 'ai_model_settings_state.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// FreezedGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
T _$identity<T>(T value) => value;
|
||||||
|
|
||||||
|
final _privateConstructorUsedError = UnsupportedError(
|
||||||
|
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
mixin _$AiModelSettingsState {
|
||||||
|
bool get isDownloading => throw _privateConstructorUsedError;
|
||||||
|
double get progress => throw _privateConstructorUsedError;
|
||||||
|
String get currentTask => throw _privateConstructorUsedError;
|
||||||
|
bool get areModelsValidated => throw _privateConstructorUsedError;
|
||||||
|
String? get errorMessage => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// Create a copy of AiModelSettingsState
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
$AiModelSettingsStateCopyWith<AiModelSettingsState> get copyWith =>
|
||||||
|
throw _privateConstructorUsedError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract class $AiModelSettingsStateCopyWith<$Res> {
|
||||||
|
factory $AiModelSettingsStateCopyWith(
|
||||||
|
AiModelSettingsState value,
|
||||||
|
$Res Function(AiModelSettingsState) then,
|
||||||
|
) = _$AiModelSettingsStateCopyWithImpl<$Res, AiModelSettingsState>;
|
||||||
|
@useResult
|
||||||
|
$Res call({
|
||||||
|
bool isDownloading,
|
||||||
|
double progress,
|
||||||
|
String currentTask,
|
||||||
|
bool areModelsValidated,
|
||||||
|
String? errorMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
class _$AiModelSettingsStateCopyWithImpl<
|
||||||
|
$Res,
|
||||||
|
$Val extends AiModelSettingsState
|
||||||
|
>
|
||||||
|
implements $AiModelSettingsStateCopyWith<$Res> {
|
||||||
|
_$AiModelSettingsStateCopyWithImpl(this._value, this._then);
|
||||||
|
|
||||||
|
// ignore: unused_field
|
||||||
|
final $Val _value;
|
||||||
|
// ignore: unused_field
|
||||||
|
final $Res Function($Val) _then;
|
||||||
|
|
||||||
|
/// Create a copy of AiModelSettingsState
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
@override
|
||||||
|
$Res call({
|
||||||
|
Object? isDownloading = null,
|
||||||
|
Object? progress = null,
|
||||||
|
Object? currentTask = null,
|
||||||
|
Object? areModelsValidated = null,
|
||||||
|
Object? errorMessage = freezed,
|
||||||
|
}) {
|
||||||
|
return _then(
|
||||||
|
_value.copyWith(
|
||||||
|
isDownloading: null == isDownloading
|
||||||
|
? _value.isDownloading
|
||||||
|
: isDownloading // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
|
progress: null == progress
|
||||||
|
? _value.progress
|
||||||
|
: progress // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double,
|
||||||
|
currentTask: null == currentTask
|
||||||
|
? _value.currentTask
|
||||||
|
: currentTask // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
areModelsValidated: null == areModelsValidated
|
||||||
|
? _value.areModelsValidated
|
||||||
|
: areModelsValidated // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
|
errorMessage: freezed == errorMessage
|
||||||
|
? _value.errorMessage
|
||||||
|
: errorMessage // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
)
|
||||||
|
as $Val,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract class _$$AiModelSettingsStateImplCopyWith<$Res>
|
||||||
|
implements $AiModelSettingsStateCopyWith<$Res> {
|
||||||
|
factory _$$AiModelSettingsStateImplCopyWith(
|
||||||
|
_$AiModelSettingsStateImpl value,
|
||||||
|
$Res Function(_$AiModelSettingsStateImpl) then,
|
||||||
|
) = __$$AiModelSettingsStateImplCopyWithImpl<$Res>;
|
||||||
|
@override
|
||||||
|
@useResult
|
||||||
|
$Res call({
|
||||||
|
bool isDownloading,
|
||||||
|
double progress,
|
||||||
|
String currentTask,
|
||||||
|
bool areModelsValidated,
|
||||||
|
String? errorMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
class __$$AiModelSettingsStateImplCopyWithImpl<$Res>
|
||||||
|
extends _$AiModelSettingsStateCopyWithImpl<$Res, _$AiModelSettingsStateImpl>
|
||||||
|
implements _$$AiModelSettingsStateImplCopyWith<$Res> {
|
||||||
|
__$$AiModelSettingsStateImplCopyWithImpl(
|
||||||
|
_$AiModelSettingsStateImpl _value,
|
||||||
|
$Res Function(_$AiModelSettingsStateImpl) _then,
|
||||||
|
) : super(_value, _then);
|
||||||
|
|
||||||
|
/// Create a copy of AiModelSettingsState
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
@override
|
||||||
|
$Res call({
|
||||||
|
Object? isDownloading = null,
|
||||||
|
Object? progress = null,
|
||||||
|
Object? currentTask = null,
|
||||||
|
Object? areModelsValidated = null,
|
||||||
|
Object? errorMessage = freezed,
|
||||||
|
}) {
|
||||||
|
return _then(
|
||||||
|
_$AiModelSettingsStateImpl(
|
||||||
|
isDownloading: null == isDownloading
|
||||||
|
? _value.isDownloading
|
||||||
|
: isDownloading // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
|
progress: null == progress
|
||||||
|
? _value.progress
|
||||||
|
: progress // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double,
|
||||||
|
currentTask: null == currentTask
|
||||||
|
? _value.currentTask
|
||||||
|
: currentTask // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
areModelsValidated: null == areModelsValidated
|
||||||
|
? _value.areModelsValidated
|
||||||
|
: areModelsValidated // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
|
errorMessage: freezed == errorMessage
|
||||||
|
? _value.errorMessage
|
||||||
|
: errorMessage // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
|
||||||
|
class _$AiModelSettingsStateImpl implements _AiModelSettingsState {
|
||||||
|
const _$AiModelSettingsStateImpl({
|
||||||
|
this.isDownloading = false,
|
||||||
|
this.progress = 0.0,
|
||||||
|
this.currentTask = '',
|
||||||
|
this.areModelsValidated = false,
|
||||||
|
this.errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
@JsonKey()
|
||||||
|
final bool isDownloading;
|
||||||
|
@override
|
||||||
|
@JsonKey()
|
||||||
|
final double progress;
|
||||||
|
@override
|
||||||
|
@JsonKey()
|
||||||
|
final String currentTask;
|
||||||
|
@override
|
||||||
|
@JsonKey()
|
||||||
|
final bool areModelsValidated;
|
||||||
|
@override
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'AiModelSettingsState(isDownloading: $isDownloading, progress: $progress, currentTask: $currentTask, areModelsValidated: $areModelsValidated, errorMessage: $errorMessage)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) ||
|
||||||
|
(other.runtimeType == runtimeType &&
|
||||||
|
other is _$AiModelSettingsStateImpl &&
|
||||||
|
(identical(other.isDownloading, isDownloading) ||
|
||||||
|
other.isDownloading == isDownloading) &&
|
||||||
|
(identical(other.progress, progress) ||
|
||||||
|
other.progress == progress) &&
|
||||||
|
(identical(other.currentTask, currentTask) ||
|
||||||
|
other.currentTask == currentTask) &&
|
||||||
|
(identical(other.areModelsValidated, areModelsValidated) ||
|
||||||
|
other.areModelsValidated == areModelsValidated) &&
|
||||||
|
(identical(other.errorMessage, errorMessage) ||
|
||||||
|
other.errorMessage == errorMessage));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(
|
||||||
|
runtimeType,
|
||||||
|
isDownloading,
|
||||||
|
progress,
|
||||||
|
currentTask,
|
||||||
|
areModelsValidated,
|
||||||
|
errorMessage,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Create a copy of AiModelSettingsState
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
_$$AiModelSettingsStateImplCopyWith<_$AiModelSettingsStateImpl>
|
||||||
|
get copyWith =>
|
||||||
|
__$$AiModelSettingsStateImplCopyWithImpl<_$AiModelSettingsStateImpl>(
|
||||||
|
this,
|
||||||
|
_$identity,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _AiModelSettingsState implements AiModelSettingsState {
|
||||||
|
const factory _AiModelSettingsState({
|
||||||
|
final bool isDownloading,
|
||||||
|
final double progress,
|
||||||
|
final String currentTask,
|
||||||
|
final bool areModelsValidated,
|
||||||
|
final String? errorMessage,
|
||||||
|
}) = _$AiModelSettingsStateImpl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isDownloading;
|
||||||
|
@override
|
||||||
|
double get progress;
|
||||||
|
@override
|
||||||
|
String get currentTask;
|
||||||
|
@override
|
||||||
|
bool get areModelsValidated;
|
||||||
|
@override
|
||||||
|
String? get errorMessage;
|
||||||
|
|
||||||
|
/// Create a copy of AiModelSettingsState
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
_$$AiModelSettingsStateImplCopyWith<_$AiModelSettingsStateImpl>
|
||||||
|
get copyWith => throw _privateConstructorUsedError;
|
||||||
|
}
|
||||||
102
lib/presentation/settings/knowledge_base_controller.dart
Normal file
102
lib/presentation/settings/knowledge_base_controller.dart
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:trainhub_flutter/domain/repositories/note_repository.dart';
|
||||||
|
import 'package:trainhub_flutter/injection.dart';
|
||||||
|
import 'package:trainhub_flutter/presentation/settings/knowledge_base_state.dart';
|
||||||
|
|
||||||
|
part 'knowledge_base_controller.g.dart';
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
class KnowledgeBaseController extends _$KnowledgeBaseController {
|
||||||
|
late NoteRepository _repo;
|
||||||
|
|
||||||
|
@override
|
||||||
|
KnowledgeBaseState build() {
|
||||||
|
_repo = getIt<NoteRepository>();
|
||||||
|
// Load the current chunk count asynchronously after first build.
|
||||||
|
_loadCount();
|
||||||
|
return const KnowledgeBaseState();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Load chunk count
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Future<void> _loadCount() async {
|
||||||
|
try {
|
||||||
|
final count = await _repo.getChunkCount();
|
||||||
|
state = state.copyWith(chunkCount: count);
|
||||||
|
} catch (_) {
|
||||||
|
// Non-fatal — UI stays at 0.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Save note
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Chunks [text], generates embeddings via Nomic, and stores the result.
|
||||||
|
Future<void> saveNote(String text) async {
|
||||||
|
if (text.trim().isEmpty) return;
|
||||||
|
|
||||||
|
state = state.copyWith(
|
||||||
|
isLoading: true,
|
||||||
|
successMessage: null,
|
||||||
|
errorMessage: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _repo.addNote(text.trim());
|
||||||
|
final count = await _repo.getChunkCount();
|
||||||
|
state = state.copyWith(
|
||||||
|
isLoading: false,
|
||||||
|
chunkCount: count,
|
||||||
|
successMessage: 'Saved! Knowledge base now has $count chunks.',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
state = state.copyWith(
|
||||||
|
isLoading: false,
|
||||||
|
errorMessage: _friendlyError(e),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Clear knowledge base
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Future<void> clearKnowledgeBase() async {
|
||||||
|
state = state.copyWith(
|
||||||
|
isLoading: true,
|
||||||
|
successMessage: null,
|
||||||
|
errorMessage: null,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await _repo.clearAll();
|
||||||
|
state = state.copyWith(
|
||||||
|
isLoading: false,
|
||||||
|
chunkCount: 0,
|
||||||
|
successMessage: 'Knowledge base cleared.',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
state = state.copyWith(
|
||||||
|
isLoading: false,
|
||||||
|
errorMessage: _friendlyError(e),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Helper
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
String _friendlyError(Object e) {
|
||||||
|
final msg = e.toString();
|
||||||
|
if (msg.contains('Connection refused') ||
|
||||||
|
msg.contains('SocketException')) {
|
||||||
|
return 'Cannot reach the embedding server. '
|
||||||
|
'Make sure AI models are downloaded and the app has had time to '
|
||||||
|
'start the inference servers.';
|
||||||
|
}
|
||||||
|
return 'Error: $msg';
|
||||||
|
}
|
||||||
|
}
|
||||||
30
lib/presentation/settings/knowledge_base_controller.g.dart
Normal file
30
lib/presentation/settings/knowledge_base_controller.g.dart
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'knowledge_base_controller.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
String _$knowledgeBaseControllerHash() =>
|
||||||
|
r'1b18418c3e7a66c6517dbbd7167e7406e16c8748';
|
||||||
|
|
||||||
|
/// See also [KnowledgeBaseController].
|
||||||
|
@ProviderFor(KnowledgeBaseController)
|
||||||
|
final knowledgeBaseControllerProvider =
|
||||||
|
AutoDisposeNotifierProvider<
|
||||||
|
KnowledgeBaseController,
|
||||||
|
KnowledgeBaseState
|
||||||
|
>.internal(
|
||||||
|
KnowledgeBaseController.new,
|
||||||
|
name: r'knowledgeBaseControllerProvider',
|
||||||
|
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||||
|
? null
|
||||||
|
: _$knowledgeBaseControllerHash,
|
||||||
|
dependencies: null,
|
||||||
|
allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
typedef _$KnowledgeBaseController = AutoDisposeNotifier<KnowledgeBaseState>;
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||||
726
lib/presentation/settings/knowledge_base_page.dart
Normal file
726
lib/presentation/settings/knowledge_base_page.dart
Normal file
@@ -0,0 +1,726 @@
|
|||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||||
|
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||||
|
import 'package:trainhub_flutter/presentation/settings/knowledge_base_controller.dart';
|
||||||
|
import 'package:trainhub_flutter/presentation/settings/knowledge_base_state.dart';
|
||||||
|
|
||||||
|
@RoutePage()
|
||||||
|
class KnowledgeBasePage extends ConsumerStatefulWidget {
|
||||||
|
const KnowledgeBasePage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<KnowledgeBasePage> createState() => _KnowledgeBasePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _KnowledgeBasePageState extends ConsumerState<KnowledgeBasePage> {
|
||||||
|
final _textController = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_textController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save(KnowledgeBaseController controller) async {
|
||||||
|
await controller.saveNote(_textController.text);
|
||||||
|
// Only clear the field if save succeeded (no error in state).
|
||||||
|
if (!mounted) return;
|
||||||
|
final s = ref.read(knowledgeBaseControllerProvider);
|
||||||
|
if (s.successMessage != null) _textController.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _clear(KnowledgeBaseController controller) async {
|
||||||
|
final confirmed = await _showConfirmDialog();
|
||||||
|
if (!confirmed) return;
|
||||||
|
await controller.clearKnowledgeBase();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _showConfirmDialog() async {
|
||||||
|
return await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
backgroundColor: AppColors.surfaceContainer,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(UIConstants.borderRadius),
|
||||||
|
side: const BorderSide(color: AppColors.border),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
'Clear knowledge base?',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
content: Text(
|
||||||
|
'This will permanently delete all stored chunks and embeddings. '
|
||||||
|
'This action cannot be undone.',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(false),
|
||||||
|
child: Text(
|
||||||
|
'Cancel',
|
||||||
|
style: GoogleFonts.inter(color: AppColors.textMuted),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(true),
|
||||||
|
child: Text(
|
||||||
|
'Clear',
|
||||||
|
style: GoogleFonts.inter(color: AppColors.destructive),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
) ??
|
||||||
|
false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final kbState = ref.watch(knowledgeBaseControllerProvider);
|
||||||
|
final controller = ref.read(knowledgeBaseControllerProvider.notifier);
|
||||||
|
|
||||||
|
// Show success SnackBar when a note is saved successfully.
|
||||||
|
ref.listen<KnowledgeBaseState>(knowledgeBaseControllerProvider,
|
||||||
|
(prev, next) {
|
||||||
|
if (next.successMessage != null &&
|
||||||
|
next.successMessage != prev?.successMessage) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
next.successMessage!,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
backgroundColor: AppColors.surfaceContainerHigh,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius:
|
||||||
|
BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||||
|
side: const BorderSide(
|
||||||
|
color: AppColors.success,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
duration: const Duration(seconds: 3),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppColors.surface,
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
// ── Top bar ──────────────────────────────────────────────────────
|
||||||
|
_TopBar(onBack: () => context.router.maybePop()),
|
||||||
|
|
||||||
|
// ── Content ──────────────────────────────────────────────────────
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(UIConstants.pagePadding),
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 680),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Heading
|
||||||
|
Text(
|
||||||
|
'Knowledge Base',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
letterSpacing: -0.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing8),
|
||||||
|
Text(
|
||||||
|
'Paste your fitness or university notes below. '
|
||||||
|
'They will be split into chunks, embedded with the '
|
||||||
|
'Nomic model, and used as context when you chat with '
|
||||||
|
'the AI — no internet required.',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
height: 1.6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: UIConstants.spacing24),
|
||||||
|
|
||||||
|
// ── Status card ──────────────────────────────────────
|
||||||
|
_StatusCard(chunkCount: kbState.chunkCount),
|
||||||
|
|
||||||
|
const SizedBox(height: UIConstants.spacing24),
|
||||||
|
|
||||||
|
// ── Text input ───────────────────────────────────────
|
||||||
|
_SectionLabel('Paste Notes'),
|
||||||
|
const SizedBox(height: UIConstants.spacing8),
|
||||||
|
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.surfaceContainer,
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
UIConstants.borderRadius,
|
||||||
|
),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
),
|
||||||
|
child: TextField(
|
||||||
|
controller: _textController,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
height: 1.6,
|
||||||
|
),
|
||||||
|
maxLines: 14,
|
||||||
|
minLines: 8,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText:
|
||||||
|
'Paste lecture notes, programming guides, '
|
||||||
|
'exercise descriptions, research summaries…',
|
||||||
|
hintStyle: GoogleFonts.inter(
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
height: 1.6,
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.all(
|
||||||
|
UIConstants.cardPadding,
|
||||||
|
),
|
||||||
|
border: InputBorder.none,
|
||||||
|
enabledBorder: InputBorder.none,
|
||||||
|
focusedBorder: InputBorder.none,
|
||||||
|
),
|
||||||
|
enabled: !kbState.isLoading,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Error message ────────────────────────────────────
|
||||||
|
if (kbState.errorMessage != null) ...[
|
||||||
|
const SizedBox(height: UIConstants.spacing12),
|
||||||
|
_ErrorBanner(message: kbState.errorMessage!),
|
||||||
|
],
|
||||||
|
|
||||||
|
const SizedBox(height: UIConstants.spacing16),
|
||||||
|
|
||||||
|
// ── Action buttons ───────────────────────────────────
|
||||||
|
if (kbState.isLoading)
|
||||||
|
_LoadingIndicator()
|
||||||
|
else
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _SaveButton(
|
||||||
|
onPressed: () => _save(controller),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (kbState.chunkCount > 0) ...[
|
||||||
|
const SizedBox(width: UIConstants.spacing12),
|
||||||
|
_ClearButton(
|
||||||
|
onPressed: () => _clear(controller),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: UIConstants.spacing32),
|
||||||
|
|
||||||
|
// ── How it works ─────────────────────────────────────
|
||||||
|
_HowItWorksCard(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 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, onTap: onBack),
|
||||||
|
const SizedBox(width: UIConstants.spacing12),
|
||||||
|
Text(
|
||||||
|
'Knowledge Base',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Status card
|
||||||
|
// =============================================================================
|
||||||
|
class _StatusCard extends StatelessWidget {
|
||||||
|
const _StatusCard({required this.chunkCount});
|
||||||
|
|
||||||
|
final int chunkCount;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final hasChunks = chunkCount > 0;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UIConstants.spacing16,
|
||||||
|
vertical: UIConstants.spacing12,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: hasChunks ? AppColors.successMuted : AppColors.surfaceContainer,
|
||||||
|
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||||
|
border: Border.all(
|
||||||
|
color: hasChunks
|
||||||
|
? AppColors.success.withValues(alpha: 0.3)
|
||||||
|
: AppColors.border,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
hasChunks
|
||||||
|
? Icons.check_circle_outline_rounded
|
||||||
|
: Icons.info_outline_rounded,
|
||||||
|
size: 16,
|
||||||
|
color: hasChunks ? AppColors.success : AppColors.textMuted,
|
||||||
|
),
|
||||||
|
const SizedBox(width: UIConstants.spacing12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
hasChunks
|
||||||
|
? '$chunkCount chunk${chunkCount == 1 ? '' : 's'} stored — '
|
||||||
|
'AI chat will use these as context.'
|
||||||
|
: 'No notes added yet. The AI chat will use only its base '
|
||||||
|
'training knowledge.',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 13,
|
||||||
|
color: hasChunks ? AppColors.success : AppColors.textMuted,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Loading indicator
|
||||||
|
// =============================================================================
|
||||||
|
class _LoadingIndicator extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: UIConstants.spacing16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: AppColors.accent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: UIConstants.spacing12),
|
||||||
|
Text(
|
||||||
|
'Generating embeddings… this may take a moment.',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Save button
|
||||||
|
// =============================================================================
|
||||||
|
class _SaveButton extends StatefulWidget {
|
||||||
|
const _SaveButton({required this.onPressed});
|
||||||
|
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_SaveButton> createState() => _SaveButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SaveButtonState extends State<_SaveButton> {
|
||||||
|
bool _hovered = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MouseRegion(
|
||||||
|
onEnter: (_) => setState(() => _hovered = true),
|
||||||
|
onExit: (_) => setState(() => _hovered = false),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: UIConstants.animationDuration,
|
||||||
|
height: 42,
|
||||||
|
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: [
|
||||||
|
const Icon(
|
||||||
|
Icons.save_outlined,
|
||||||
|
color: AppColors.zinc950,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
|
const SizedBox(width: UIConstants.spacing8),
|
||||||
|
Text(
|
||||||
|
'Save to Knowledge Base',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.zinc950,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Clear button
|
||||||
|
// =============================================================================
|
||||||
|
class _ClearButton extends StatefulWidget {
|
||||||
|
const _ClearButton({required this.onPressed});
|
||||||
|
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ClearButton> createState() => _ClearButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ClearButtonState extends State<_ClearButton> {
|
||||||
|
bool _hovered = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MouseRegion(
|
||||||
|
onEnter: (_) => setState(() => _hovered = true),
|
||||||
|
onExit: (_) => setState(() => _hovered = false),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: UIConstants.animationDuration,
|
||||||
|
height: 42,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _hovered ? AppColors.destructiveMuted : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||||
|
border: Border.all(
|
||||||
|
color: _hovered
|
||||||
|
? AppColors.destructive.withValues(alpha: 0.4)
|
||||||
|
: AppColors.border,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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(
|
||||||
|
Icons.delete_outline_rounded,
|
||||||
|
size: 15,
|
||||||
|
color:
|
||||||
|
_hovered ? AppColors.destructive : AppColors.textMuted,
|
||||||
|
),
|
||||||
|
const SizedBox(width: UIConstants.spacing8),
|
||||||
|
Text(
|
||||||
|
'Clear All',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: _hovered
|
||||||
|
? AppColors.destructive
|
||||||
|
: AppColors.textMuted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Error banner
|
||||||
|
// =============================================================================
|
||||||
|
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: 15,
|
||||||
|
),
|
||||||
|
const SizedBox(width: UIConstants.spacing8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
message,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.destructive,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// How it works info card
|
||||||
|
// =============================================================================
|
||||||
|
class _HowItWorksCard extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(UIConstants.cardPadding),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.surfaceContainer,
|
||||||
|
borderRadius: BorderRadius.circular(UIConstants.borderRadius),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.info_outline_rounded,
|
||||||
|
size: 15,
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
),
|
||||||
|
const SizedBox(width: UIConstants.spacing8),
|
||||||
|
Text(
|
||||||
|
'How it works',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing12),
|
||||||
|
_Step(
|
||||||
|
n: '1',
|
||||||
|
text: 'Your text is split into ~500-character chunks at paragraph '
|
||||||
|
'and sentence boundaries.',
|
||||||
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing8),
|
||||||
|
_Step(
|
||||||
|
n: '2',
|
||||||
|
text:
|
||||||
|
'Each chunk is embedded by the local Nomic model into a 768-dim '
|
||||||
|
'vector and stored in the database.',
|
||||||
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing8),
|
||||||
|
_Step(
|
||||||
|
n: '3',
|
||||||
|
text:
|
||||||
|
'When you ask a question, the 3 most similar chunks are retrieved '
|
||||||
|
'and injected into the AI system prompt as context.',
|
||||||
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing8),
|
||||||
|
_Step(
|
||||||
|
n: '4',
|
||||||
|
text:
|
||||||
|
'Everything stays on your device — no data leaves the machine.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Step extends StatelessWidget {
|
||||||
|
const _Step({required this.n, required this.text});
|
||||||
|
|
||||||
|
final String n;
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
n,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.accent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: UIConstants.spacing8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Small reusable widgets
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
class _SectionLabel extends StatelessWidget {
|
||||||
|
const _SectionLabel(this.label);
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Text(
|
||||||
|
label,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _IconBtn extends StatefulWidget {
|
||||||
|
const _IconBtn({required this.icon, required this.onTap});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_IconBtn> createState() => _IconBtnState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _IconBtnState extends State<_IconBtn> {
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
lib/presentation/settings/knowledge_base_state.dart
Normal file
13
lib/presentation/settings/knowledge_base_state.dart
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'knowledge_base_state.freezed.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class KnowledgeBaseState with _$KnowledgeBaseState {
|
||||||
|
const factory KnowledgeBaseState({
|
||||||
|
@Default(false) bool isLoading,
|
||||||
|
@Default(0) int chunkCount,
|
||||||
|
String? successMessage,
|
||||||
|
String? errorMessage,
|
||||||
|
}) = _KnowledgeBaseState;
|
||||||
|
}
|
||||||
235
lib/presentation/settings/knowledge_base_state.freezed.dart
Normal file
235
lib/presentation/settings/knowledge_base_state.freezed.dart
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
// coverage:ignore-file
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||||
|
|
||||||
|
part of 'knowledge_base_state.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// FreezedGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
T _$identity<T>(T value) => value;
|
||||||
|
|
||||||
|
final _privateConstructorUsedError = UnsupportedError(
|
||||||
|
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
mixin _$KnowledgeBaseState {
|
||||||
|
bool get isLoading => throw _privateConstructorUsedError;
|
||||||
|
int get chunkCount => throw _privateConstructorUsedError;
|
||||||
|
String? get successMessage => throw _privateConstructorUsedError;
|
||||||
|
String? get errorMessage => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// Create a copy of KnowledgeBaseState
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
$KnowledgeBaseStateCopyWith<KnowledgeBaseState> get copyWith =>
|
||||||
|
throw _privateConstructorUsedError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract class $KnowledgeBaseStateCopyWith<$Res> {
|
||||||
|
factory $KnowledgeBaseStateCopyWith(
|
||||||
|
KnowledgeBaseState value,
|
||||||
|
$Res Function(KnowledgeBaseState) then,
|
||||||
|
) = _$KnowledgeBaseStateCopyWithImpl<$Res, KnowledgeBaseState>;
|
||||||
|
@useResult
|
||||||
|
$Res call({
|
||||||
|
bool isLoading,
|
||||||
|
int chunkCount,
|
||||||
|
String? successMessage,
|
||||||
|
String? errorMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
class _$KnowledgeBaseStateCopyWithImpl<$Res, $Val extends KnowledgeBaseState>
|
||||||
|
implements $KnowledgeBaseStateCopyWith<$Res> {
|
||||||
|
_$KnowledgeBaseStateCopyWithImpl(this._value, this._then);
|
||||||
|
|
||||||
|
// ignore: unused_field
|
||||||
|
final $Val _value;
|
||||||
|
// ignore: unused_field
|
||||||
|
final $Res Function($Val) _then;
|
||||||
|
|
||||||
|
/// Create a copy of KnowledgeBaseState
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
@override
|
||||||
|
$Res call({
|
||||||
|
Object? isLoading = null,
|
||||||
|
Object? chunkCount = null,
|
||||||
|
Object? successMessage = freezed,
|
||||||
|
Object? errorMessage = freezed,
|
||||||
|
}) {
|
||||||
|
return _then(
|
||||||
|
_value.copyWith(
|
||||||
|
isLoading: null == isLoading
|
||||||
|
? _value.isLoading
|
||||||
|
: isLoading // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
|
chunkCount: null == chunkCount
|
||||||
|
? _value.chunkCount
|
||||||
|
: chunkCount // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,
|
||||||
|
successMessage: freezed == successMessage
|
||||||
|
? _value.successMessage
|
||||||
|
: successMessage // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
errorMessage: freezed == errorMessage
|
||||||
|
? _value.errorMessage
|
||||||
|
: errorMessage // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
)
|
||||||
|
as $Val,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract class _$$KnowledgeBaseStateImplCopyWith<$Res>
|
||||||
|
implements $KnowledgeBaseStateCopyWith<$Res> {
|
||||||
|
factory _$$KnowledgeBaseStateImplCopyWith(
|
||||||
|
_$KnowledgeBaseStateImpl value,
|
||||||
|
$Res Function(_$KnowledgeBaseStateImpl) then,
|
||||||
|
) = __$$KnowledgeBaseStateImplCopyWithImpl<$Res>;
|
||||||
|
@override
|
||||||
|
@useResult
|
||||||
|
$Res call({
|
||||||
|
bool isLoading,
|
||||||
|
int chunkCount,
|
||||||
|
String? successMessage,
|
||||||
|
String? errorMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
class __$$KnowledgeBaseStateImplCopyWithImpl<$Res>
|
||||||
|
extends _$KnowledgeBaseStateCopyWithImpl<$Res, _$KnowledgeBaseStateImpl>
|
||||||
|
implements _$$KnowledgeBaseStateImplCopyWith<$Res> {
|
||||||
|
__$$KnowledgeBaseStateImplCopyWithImpl(
|
||||||
|
_$KnowledgeBaseStateImpl _value,
|
||||||
|
$Res Function(_$KnowledgeBaseStateImpl) _then,
|
||||||
|
) : super(_value, _then);
|
||||||
|
|
||||||
|
/// Create a copy of KnowledgeBaseState
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
@override
|
||||||
|
$Res call({
|
||||||
|
Object? isLoading = null,
|
||||||
|
Object? chunkCount = null,
|
||||||
|
Object? successMessage = freezed,
|
||||||
|
Object? errorMessage = freezed,
|
||||||
|
}) {
|
||||||
|
return _then(
|
||||||
|
_$KnowledgeBaseStateImpl(
|
||||||
|
isLoading: null == isLoading
|
||||||
|
? _value.isLoading
|
||||||
|
: isLoading // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
|
chunkCount: null == chunkCount
|
||||||
|
? _value.chunkCount
|
||||||
|
: chunkCount // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,
|
||||||
|
successMessage: freezed == successMessage
|
||||||
|
? _value.successMessage
|
||||||
|
: successMessage // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
errorMessage: freezed == errorMessage
|
||||||
|
? _value.errorMessage
|
||||||
|
: errorMessage // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
|
||||||
|
class _$KnowledgeBaseStateImpl implements _KnowledgeBaseState {
|
||||||
|
const _$KnowledgeBaseStateImpl({
|
||||||
|
this.isLoading = false,
|
||||||
|
this.chunkCount = 0,
|
||||||
|
this.successMessage,
|
||||||
|
this.errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
@JsonKey()
|
||||||
|
final bool isLoading;
|
||||||
|
@override
|
||||||
|
@JsonKey()
|
||||||
|
final int chunkCount;
|
||||||
|
@override
|
||||||
|
final String? successMessage;
|
||||||
|
@override
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'KnowledgeBaseState(isLoading: $isLoading, chunkCount: $chunkCount, successMessage: $successMessage, errorMessage: $errorMessage)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) ||
|
||||||
|
(other.runtimeType == runtimeType &&
|
||||||
|
other is _$KnowledgeBaseStateImpl &&
|
||||||
|
(identical(other.isLoading, isLoading) ||
|
||||||
|
other.isLoading == isLoading) &&
|
||||||
|
(identical(other.chunkCount, chunkCount) ||
|
||||||
|
other.chunkCount == chunkCount) &&
|
||||||
|
(identical(other.successMessage, successMessage) ||
|
||||||
|
other.successMessage == successMessage) &&
|
||||||
|
(identical(other.errorMessage, errorMessage) ||
|
||||||
|
other.errorMessage == errorMessage));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(
|
||||||
|
runtimeType,
|
||||||
|
isLoading,
|
||||||
|
chunkCount,
|
||||||
|
successMessage,
|
||||||
|
errorMessage,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Create a copy of KnowledgeBaseState
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
_$$KnowledgeBaseStateImplCopyWith<_$KnowledgeBaseStateImpl> get copyWith =>
|
||||||
|
__$$KnowledgeBaseStateImplCopyWithImpl<_$KnowledgeBaseStateImpl>(
|
||||||
|
this,
|
||||||
|
_$identity,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _KnowledgeBaseState implements KnowledgeBaseState {
|
||||||
|
const factory _KnowledgeBaseState({
|
||||||
|
final bool isLoading,
|
||||||
|
final int chunkCount,
|
||||||
|
final String? successMessage,
|
||||||
|
final String? errorMessage,
|
||||||
|
}) = _$KnowledgeBaseStateImpl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isLoading;
|
||||||
|
@override
|
||||||
|
int get chunkCount;
|
||||||
|
@override
|
||||||
|
String? get successMessage;
|
||||||
|
@override
|
||||||
|
String? get errorMessage;
|
||||||
|
|
||||||
|
/// Create a copy of KnowledgeBaseState
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
_$$KnowledgeBaseStateImplCopyWith<_$KnowledgeBaseStateImpl> get copyWith =>
|
||||||
|
throw _privateConstructorUsedError;
|
||||||
|
}
|
||||||
66
lib/presentation/settings/settings_page.dart
Normal file
66
lib/presentation/settings/settings_page.dart
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||||
|
import 'package:trainhub_flutter/core/router/app_router.dart';
|
||||||
|
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||||
|
import 'package:trainhub_flutter/presentation/settings/ai_model_settings_controller.dart';
|
||||||
|
import 'package:trainhub_flutter/presentation/settings/widgets/ai_models_section.dart';
|
||||||
|
import 'package:trainhub_flutter/presentation/settings/widgets/knowledge_base_section.dart';
|
||||||
|
import 'package:trainhub_flutter/presentation/settings/widgets/settings_top_bar.dart';
|
||||||
|
|
||||||
|
@RoutePage()
|
||||||
|
class SettingsPage extends ConsumerWidget {
|
||||||
|
const SettingsPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final modelState = ref.watch(aiModelSettingsControllerProvider);
|
||||||
|
final controller = ref.read(aiModelSettingsControllerProvider.notifier);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppColors.surface,
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
SettingsTopBar(onBack: () => context.router.maybePop()),
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(UIConstants.pagePadding),
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 680),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Settings',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
letterSpacing: -0.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing32),
|
||||||
|
AiModelsSection(
|
||||||
|
modelState: modelState,
|
||||||
|
onDownload: controller.downloadAll,
|
||||||
|
onValidate: controller.validateModels,
|
||||||
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing32),
|
||||||
|
KnowledgeBaseSection(
|
||||||
|
onTap: () =>
|
||||||
|
context.router.push(const KnowledgeBaseRoute()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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,7 +1,10 @@
|
|||||||
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:trainhub_flutter/core/theme/app_colors.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import 'package:trainhub_flutter/core/constants/app_constants.dart';
|
||||||
|
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||||
import 'package:trainhub_flutter/core/router/app_router.dart';
|
import 'package:trainhub_flutter/core/router/app_router.dart';
|
||||||
|
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
class ShellPage extends StatelessWidget {
|
class ShellPage extends StatelessWidget {
|
||||||
@@ -22,50 +25,10 @@ class ShellPage extends StatelessWidget {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Row(
|
body: Row(
|
||||||
children: [
|
children: [
|
||||||
// NavigationRail with logo area at top
|
_Sidebar(
|
||||||
NavigationRail(
|
activeIndex: tabsRouter.activeIndex,
|
||||||
selectedIndex: tabsRouter.activeIndex,
|
|
||||||
onDestinationSelected: tabsRouter.setActiveIndex,
|
onDestinationSelected: tabsRouter.setActiveIndex,
|
||||||
labelType: NavigationRailLabelType.all,
|
|
||||||
leading: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.fitness_center, color: AppColors.accent, size: 28),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text('TrainHub', style: TextStyle(color: AppColors.textPrimary, fontSize: 10, fontWeight: FontWeight.w600)),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
destinations: const [
|
|
||||||
NavigationRailDestination(
|
|
||||||
icon: Icon(Icons.dashboard_outlined),
|
|
||||||
selectedIcon: Icon(Icons.dashboard),
|
|
||||||
label: Text('Home'),
|
|
||||||
),
|
|
||||||
NavigationRailDestination(
|
|
||||||
icon: Icon(Icons.fitness_center_outlined),
|
|
||||||
selectedIcon: Icon(Icons.fitness_center),
|
|
||||||
label: Text('Trainings'),
|
|
||||||
),
|
|
||||||
NavigationRailDestination(
|
|
||||||
icon: Icon(Icons.video_library_outlined),
|
|
||||||
selectedIcon: Icon(Icons.video_library),
|
|
||||||
label: Text('Analysis'),
|
|
||||||
),
|
|
||||||
NavigationRailDestination(
|
|
||||||
icon: Icon(Icons.calendar_today_outlined),
|
|
||||||
selectedIcon: Icon(Icons.calendar_today),
|
|
||||||
label: Text('Calendar'),
|
|
||||||
),
|
|
||||||
NavigationRailDestination(
|
|
||||||
icon: Icon(Icons.chat_bubble_outline),
|
|
||||||
selectedIcon: Icon(Icons.chat_bubble),
|
|
||||||
label: Text('AI Chat'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const VerticalDivider(thickness: 1, width: 1),
|
|
||||||
Expanded(child: child),
|
Expanded(child: child),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -74,3 +37,259 @@ class ShellPage extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sidebar
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _NavItemData {
|
||||||
|
final IconData icon;
|
||||||
|
final IconData activeIcon;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const _NavItemData({
|
||||||
|
required this.icon,
|
||||||
|
required this.activeIcon,
|
||||||
|
required this.label,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Sidebar extends StatelessWidget {
|
||||||
|
final int activeIndex;
|
||||||
|
final ValueChanged<int> onDestinationSelected;
|
||||||
|
|
||||||
|
const _Sidebar({
|
||||||
|
required this.activeIndex,
|
||||||
|
required this.onDestinationSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
static const _items = [
|
||||||
|
_NavItemData(
|
||||||
|
icon: Icons.dashboard_outlined,
|
||||||
|
activeIcon: Icons.dashboard_rounded,
|
||||||
|
label: 'Home',
|
||||||
|
),
|
||||||
|
_NavItemData(
|
||||||
|
icon: Icons.fitness_center_outlined,
|
||||||
|
activeIcon: Icons.fitness_center,
|
||||||
|
label: 'Trainings',
|
||||||
|
),
|
||||||
|
_NavItemData(
|
||||||
|
icon: Icons.video_library_outlined,
|
||||||
|
activeIcon: Icons.video_library,
|
||||||
|
label: 'Analysis',
|
||||||
|
),
|
||||||
|
_NavItemData(
|
||||||
|
icon: Icons.calendar_today_outlined,
|
||||||
|
activeIcon: Icons.calendar_today,
|
||||||
|
label: 'Calendar',
|
||||||
|
),
|
||||||
|
_NavItemData(
|
||||||
|
icon: Icons.chat_bubble_outline,
|
||||||
|
activeIcon: Icons.chat_bubble,
|
||||||
|
label: 'AI Chat',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: 200,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AppColors.surfaceContainer,
|
||||||
|
border: Border(right: BorderSide(color: AppColors.border)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// --- Logo ---
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 20, 16, 20),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.accentMuted,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.fitness_center,
|
||||||
|
color: AppColors.accent,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
'TrainHub',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
letterSpacing: -0.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Divider(height: 1),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing8),
|
||||||
|
|
||||||
|
// --- Nav items ---
|
||||||
|
for (int i = 0; i < _items.length; i++)
|
||||||
|
_NavItem(
|
||||||
|
data: _items[i],
|
||||||
|
isActive: activeIndex == i,
|
||||||
|
onTap: () => onDestinationSelected(i),
|
||||||
|
),
|
||||||
|
|
||||||
|
const Spacer(),
|
||||||
|
|
||||||
|
// --- Footer ---
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'v${AppConstants.appVersion}',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 11,
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
const _SettingsButton(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NavItem extends StatefulWidget {
|
||||||
|
final _NavItemData data;
|
||||||
|
final bool isActive;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _NavItem({
|
||||||
|
required this.data,
|
||||||
|
required this.isActive,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_NavItem> createState() => _NavItemState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NavItemState extends State<_NavItem> {
|
||||||
|
bool _isHovered = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final active = widget.isActive;
|
||||||
|
|
||||||
|
return MouseRegion(
|
||||||
|
onEnter: (_) => setState(() => _isHovered = true),
|
||||||
|
onExit: (_) => setState(() => _isHovered = false),
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: widget.onTap,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: UIConstants.animationDuration,
|
||||||
|
height: 40,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 1),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: active
|
||||||
|
? AppColors.zinc800
|
||||||
|
: _isHovered
|
||||||
|
? AppColors.zinc900
|
||||||
|
: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Left accent indicator
|
||||||
|
AnimatedContainer(
|
||||||
|
duration: UIConstants.animationDuration,
|
||||||
|
width: 3,
|
||||||
|
height: active ? 20 : 0,
|
||||||
|
margin: const EdgeInsets.only(left: 4, right: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.accent,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!active) const SizedBox(width: 15),
|
||||||
|
Icon(
|
||||||
|
active ? widget.data.activeIcon : widget.data.icon,
|
||||||
|
size: 17,
|
||||||
|
color: active ? AppColors.textPrimary : AppColors.textMuted,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 9),
|
||||||
|
Text(
|
||||||
|
widget.data.label,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: active ? FontWeight.w600 : FontWeight.w400,
|
||||||
|
color: active
|
||||||
|
? AppColors.textPrimary
|
||||||
|
: _isHovered
|
||||||
|
? AppColors.textSecondary
|
||||||
|
: AppColors.textMuted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Settings icon button in sidebar footer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
class _SettingsButton extends StatefulWidget {
|
||||||
|
const _SettingsButton();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_SettingsButton> createState() => _SettingsButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SettingsButtonState extends State<_SettingsButton> {
|
||||||
|
bool _hovered = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Tooltip(
|
||||||
|
message: 'Settings',
|
||||||
|
child: MouseRegion(
|
||||||
|
onEnter: (_) => setState(() => _hovered = true),
|
||||||
|
onExit: (_) => setState(() => _hovered = false),
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => context.router.push(const SettingsRoute()),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: UIConstants.animationDuration,
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _hovered ? AppColors.zinc800 : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.settings_outlined,
|
||||||
|
size: 16,
|
||||||
|
color: _hovered ? AppColors.textSecondary : AppColors.textMuted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ part of 'trainings_controller.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$trainingsControllerHash() =>
|
String _$trainingsControllerHash() =>
|
||||||
r'15c54eb8211e3b2549af6ef25a9cb451a7a9988a';
|
r'2da51cdda3db5f186bc32980544a6aeeab268274';
|
||||||
|
|
||||||
/// See also [TrainingsController].
|
/// See also [TrainingsController].
|
||||||
@ProviderFor(TrainingsController)
|
@ProviderFor(TrainingsController)
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
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 'dart:async';
|
||||||
|
|
||||||
|
import 'package:media_kit/media_kit.dart';
|
||||||
|
import 'package:media_kit_video/media_kit_video.dart';
|
||||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
import 'package:trainhub_flutter/core/theme/app_colors.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/domain/entities/training_plan.dart';
|
|
||||||
import 'package:trainhub_flutter/domain/entities/exercise.dart';
|
import 'package:trainhub_flutter/domain/entities/exercise.dart';
|
||||||
|
import 'package:trainhub_flutter/domain/entities/training_plan.dart';
|
||||||
import 'package:trainhub_flutter/presentation/trainings/trainings_controller.dart';
|
import 'package:trainhub_flutter/presentation/trainings/trainings_controller.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/dialogs/text_input_dialog.dart';
|
import 'package:trainhub_flutter/presentation/common/dialogs/text_input_dialog.dart';
|
||||||
@@ -260,24 +264,22 @@ class _ExercisesTab extends StatelessWidget {
|
|||||||
title: 'No exercises yet',
|
title: 'No exercises yet',
|
||||||
subtitle: 'Add exercises to use in your training plans',
|
subtitle: 'Add exercises to use in your training plans',
|
||||||
)
|
)
|
||||||
: GridView.builder(
|
: ListView.builder(
|
||||||
padding: const EdgeInsets.all(UIConstants.spacing16),
|
padding: const EdgeInsets.symmetric(
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
horizontal: UIConstants.spacing16,
|
||||||
crossAxisCount: 2,
|
vertical: UIConstants.spacing8,
|
||||||
childAspectRatio: 3.0,
|
|
||||||
crossAxisSpacing: UIConstants.spacing12,
|
|
||||||
mainAxisSpacing: UIConstants.spacing12,
|
|
||||||
),
|
),
|
||||||
itemCount: exercises.length,
|
itemCount: exercises.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final exercise = exercises[index];
|
final exercise = exercises[index];
|
||||||
return _ExerciseCard(
|
return _ExerciseListItem(
|
||||||
exercise: exercise,
|
exercise: exercise,
|
||||||
onEdit: () =>
|
onEdit: () =>
|
||||||
_showExerciseDialog(context, exercise: exercise),
|
_showExerciseDialog(context, exercise: exercise),
|
||||||
onDelete: () => ref
|
onDelete: () => ref
|
||||||
.read(trainingsControllerProvider.notifier)
|
.read(trainingsControllerProvider.notifier)
|
||||||
.deleteExercise(exercise.id),
|
.deleteExercise(exercise.id),
|
||||||
|
onPreview: () => _showExercisePreview(context, exercise),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -311,7 +313,7 @@ class _ExercisesTab extends StatelessWidget {
|
|||||||
TextField(
|
TextField(
|
||||||
controller: instructionsCtrl,
|
controller: instructionsCtrl,
|
||||||
decoration: const InputDecoration(labelText: 'Instructions'),
|
decoration: const InputDecoration(labelText: 'Instructions'),
|
||||||
maxLines: 2,
|
maxLines: 3,
|
||||||
),
|
),
|
||||||
const SizedBox(height: UIConstants.spacing12),
|
const SizedBox(height: UIConstants.spacing12),
|
||||||
TextField(
|
TextField(
|
||||||
@@ -323,7 +325,9 @@ class _ExercisesTab extends StatelessWidget {
|
|||||||
const SizedBox(height: UIConstants.spacing12),
|
const SizedBox(height: UIConstants.spacing12),
|
||||||
TextField(
|
TextField(
|
||||||
controller: videoUrlCtrl,
|
controller: videoUrlCtrl,
|
||||||
decoration: const InputDecoration(labelText: 'Video URL'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Video path or URL',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -335,10 +339,10 @@ class _ExercisesTab extends StatelessWidget {
|
|||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () {
|
onPressed: () async {
|
||||||
if (nameCtrl.text.isEmpty) return;
|
if (nameCtrl.text.isEmpty) return;
|
||||||
if (exercise == null) {
|
if (exercise == null) {
|
||||||
ref
|
await ref
|
||||||
.read(trainingsControllerProvider.notifier)
|
.read(trainingsControllerProvider.notifier)
|
||||||
.addExercise(
|
.addExercise(
|
||||||
name: nameCtrl.text,
|
name: nameCtrl.text,
|
||||||
@@ -347,7 +351,7 @@ class _ExercisesTab extends StatelessWidget {
|
|||||||
videoUrl: videoUrlCtrl.text,
|
videoUrl: videoUrlCtrl.text,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
ref
|
await ref
|
||||||
.read(trainingsControllerProvider.notifier)
|
.read(trainingsControllerProvider.notifier)
|
||||||
.updateExercise(
|
.updateExercise(
|
||||||
exercise.copyWith(
|
exercise.copyWith(
|
||||||
@@ -358,7 +362,7 @@ class _ExercisesTab extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Navigator.pop(context);
|
if (context.mounted) Navigator.pop(context);
|
||||||
},
|
},
|
||||||
child: const Text('Save'),
|
child: const Text('Save'),
|
||||||
),
|
),
|
||||||
@@ -366,43 +370,74 @@ class _ExercisesTab extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showExercisePreview(BuildContext context, ExerciseEntity exercise) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => _ExercisePreviewDialog(exercise: exercise),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ExerciseCard extends StatefulWidget {
|
class _ExerciseListItem extends StatefulWidget {
|
||||||
final ExerciseEntity exercise;
|
final ExerciseEntity exercise;
|
||||||
final VoidCallback onEdit;
|
final VoidCallback onEdit;
|
||||||
final VoidCallback onDelete;
|
final VoidCallback onDelete;
|
||||||
|
final VoidCallback onPreview;
|
||||||
|
|
||||||
const _ExerciseCard({
|
const _ExerciseListItem({
|
||||||
required this.exercise,
|
required this.exercise,
|
||||||
required this.onEdit,
|
required this.onEdit,
|
||||||
required this.onDelete,
|
required this.onDelete,
|
||||||
|
required this.onPreview,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_ExerciseCard> createState() => _ExerciseCardState();
|
State<_ExerciseListItem> createState() => _ExerciseListItemState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ExerciseCardState extends State<_ExerciseCard> {
|
class _ExerciseListItemState extends State<_ExerciseListItem> {
|
||||||
bool _isHovered = false;
|
bool _isHovered = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final hasVideo = widget.exercise.videoUrl != null &&
|
||||||
|
widget.exercise.videoUrl!.isNotEmpty;
|
||||||
|
|
||||||
return MouseRegion(
|
return MouseRegion(
|
||||||
onEnter: (_) => setState(() => _isHovered = true),
|
onEnter: (_) => setState(() => _isHovered = true),
|
||||||
onExit: (_) => setState(() => _isHovered = false),
|
onExit: (_) => setState(() => _isHovered = false),
|
||||||
child: Card(
|
child: Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: UIConstants.spacing8),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: widget.onEdit,
|
onTap: widget.onPreview,
|
||||||
borderRadius: UIConstants.cardBorderRadius,
|
borderRadius: UIConstants.cardBorderRadius,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(UIConstants.spacing12),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UIConstants.spacing16,
|
||||||
|
vertical: UIConstants.spacing12,
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: hasVideo
|
||||||
|
? AppColors.info.withValues(alpha: 0.15)
|
||||||
|
: AppColors.zinc800,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
hasVideo ? Icons.videocam : Icons.fitness_center,
|
||||||
|
color: hasVideo ? AppColors.info : AppColors.textMuted,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: UIConstants.spacing12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
widget.exercise.name,
|
widget.exercise.name,
|
||||||
@@ -410,12 +445,10 @@ class _ExerciseCardState extends State<_ExerciseCard> {
|
|||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
if (widget.exercise.instructions != null &&
|
if (widget.exercise.instructions != null &&
|
||||||
widget.exercise.instructions!.isNotEmpty) ...[
|
widget.exercise.instructions!.isNotEmpty) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
widget.exercise.instructions!,
|
widget.exercise.instructions!,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
@@ -433,7 +466,7 @@ class _ExerciseCardState extends State<_ExerciseCard> {
|
|||||||
spacing: 4,
|
spacing: 4,
|
||||||
children: widget.exercise.tags!
|
children: widget.exercise.tags!
|
||||||
.split(',')
|
.split(',')
|
||||||
.take(3)
|
.take(4)
|
||||||
.map(
|
.map(
|
||||||
(tag) => Container(
|
(tag) => Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
@@ -459,20 +492,20 @@ class _ExerciseCardState extends State<_ExerciseCard> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (widget.exercise.videoUrl != null &&
|
|
||||||
widget.exercise.videoUrl!.isNotEmpty)
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.only(right: 8),
|
|
||||||
child: Icon(
|
|
||||||
Icons.videocam,
|
|
||||||
size: 16,
|
|
||||||
color: AppColors.info,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (_isHovered) ...[
|
if (_isHovered) ...[
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.open_in_new,
|
||||||
|
size: 16,
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
),
|
||||||
|
onPressed: widget.onPreview,
|
||||||
|
tooltip: 'Preview',
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.edit, size: 16),
|
icon: const Icon(Icons.edit, size: 16),
|
||||||
onPressed: widget.onEdit,
|
onPressed: widget.onEdit,
|
||||||
|
tooltip: 'Edit',
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
@@ -481,6 +514,7 @@ class _ExerciseCardState extends State<_ExerciseCard> {
|
|||||||
color: AppColors.destructive,
|
color: AppColors.destructive,
|
||||||
),
|
),
|
||||||
onPressed: widget.onDelete,
|
onPressed: widget.onDelete,
|
||||||
|
tooltip: 'Delete',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -491,3 +525,384 @@ class _ExerciseCardState extends State<_ExerciseCard> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ExercisePreviewDialog extends StatelessWidget {
|
||||||
|
final ExerciseEntity exercise;
|
||||||
|
|
||||||
|
const _ExercisePreviewDialog({required this.exercise});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final hasVideo = exercise.videoUrl != null && exercise.videoUrl!.isNotEmpty;
|
||||||
|
|
||||||
|
return Dialog(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 600, maxHeight: 600),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (hasVideo)
|
||||||
|
_ExerciseVideoPreview(videoPath: exercise.videoUrl!),
|
||||||
|
Flexible(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(UIConstants.spacing24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
exercise.name,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (exercise.muscleGroup != null &&
|
||||||
|
exercise.muscleGroup!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: UIConstants.spacing8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.accessibility_new,
|
||||||
|
size: 14,
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
exercise.muscleGroup!,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (exercise.tags != null &&
|
||||||
|
exercise.tags!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: UIConstants.spacing12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 6,
|
||||||
|
runSpacing: 6,
|
||||||
|
children: exercise.tags!
|
||||||
|
.split(',')
|
||||||
|
.map(
|
||||||
|
(tag) => Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.accentMuted,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
tag.trim(),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: AppColors.accent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (exercise.instructions != null &&
|
||||||
|
exercise.instructions!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: UIConstants.spacing16),
|
||||||
|
const Text(
|
||||||
|
'Instructions',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing8),
|
||||||
|
Text(
|
||||||
|
exercise.instructions!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (exercise.enrichment != null &&
|
||||||
|
exercise.enrichment!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: UIConstants.spacing16),
|
||||||
|
const Text(
|
||||||
|
'Notes',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing8),
|
||||||
|
Text(
|
||||||
|
exercise.enrichment!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(UIConstants.spacing16),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Close'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ExerciseVideoPreview extends StatefulWidget {
|
||||||
|
final String videoPath;
|
||||||
|
|
||||||
|
const _ExerciseVideoPreview({required this.videoPath});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ExerciseVideoPreview> createState() => _ExerciseVideoPreviewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ExerciseVideoPreviewState extends State<_ExerciseVideoPreview> {
|
||||||
|
late final Player _player;
|
||||||
|
late final VideoController _videoController;
|
||||||
|
|
||||||
|
bool _isInitialized = false;
|
||||||
|
String? _error;
|
||||||
|
bool _isPlaying = false;
|
||||||
|
|
||||||
|
// Clip boundaries parsed from the '#t=start,end' fragment.
|
||||||
|
double _clipStart = 0.0;
|
||||||
|
double _clipEnd = double.infinity; // infinity means play to end of file
|
||||||
|
double _position = 0.0;
|
||||||
|
|
||||||
|
StreamSubscription<Duration>? _positionSub;
|
||||||
|
StreamSubscription<Duration>? _durationSub;
|
||||||
|
StreamSubscription<bool>? _playingSub;
|
||||||
|
StreamSubscription<String>? _errorSub;
|
||||||
|
bool _initialSeekDone = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_player = Player();
|
||||||
|
_videoController = VideoController(
|
||||||
|
_player,
|
||||||
|
configuration: const VideoControllerConfiguration(
|
||||||
|
enableHardwareAcceleration: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_parseClipTimes();
|
||||||
|
_setupListeners();
|
||||||
|
_initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _parseClipTimes() {
|
||||||
|
final parts = widget.videoPath.split('#');
|
||||||
|
if (parts.length > 1 && parts[1].startsWith('t=')) {
|
||||||
|
final times = parts[1].substring(2).split(',');
|
||||||
|
_clipStart = double.tryParse(times[0]) ?? 0.0;
|
||||||
|
if (times.length > 1) {
|
||||||
|
_clipEnd = double.tryParse(times[1]) ?? double.infinity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setupListeners() {
|
||||||
|
_errorSub = _player.stream.error.listen((error) {
|
||||||
|
if (mounted) setState(() => _error = error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for the file to load (duration > 0), seek to clip start, then
|
||||||
|
// mark as initialized. Doing it in one chain prevents the Video widget
|
||||||
|
// from rendering frame 0 before the seek completes.
|
||||||
|
_durationSub = _player.stream.duration.listen((duration) {
|
||||||
|
if (!_initialSeekDone && duration > Duration.zero) {
|
||||||
|
_initialSeekDone = true;
|
||||||
|
if (_clipStart > 0) {
|
||||||
|
_player
|
||||||
|
.seek(Duration(milliseconds: (_clipStart * 1000).round()))
|
||||||
|
.then((_) {
|
||||||
|
if (mounted) setState(() => _isInitialized = true);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (mounted) setState(() => _isInitialized = true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_positionSub = _player.stream.position.listen((pos) {
|
||||||
|
final secs = pos.inMilliseconds / 1000.0;
|
||||||
|
if (_clipEnd != double.infinity && secs >= _clipEnd) {
|
||||||
|
// Loop: seek back to clip start without pausing.
|
||||||
|
_player.seek(Duration(milliseconds: (_clipStart * 1000).round()));
|
||||||
|
} else if (mounted) {
|
||||||
|
setState(() => _position = secs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_playingSub = _player.stream.playing.listen((playing) {
|
||||||
|
if (mounted) setState(() => _isPlaying = playing);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initialize() async {
|
||||||
|
try {
|
||||||
|
final rawPath = widget.videoPath.split('#').first;
|
||||||
|
await _player.open(Media(rawPath), play: false);
|
||||||
|
// _isInitialized is set in _durationSub after the seek to _clipStart
|
||||||
|
// completes, so the Video widget never renders frame 0.
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) setState(() => _error = e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_positionSub?.cancel();
|
||||||
|
_durationSub?.cancel();
|
||||||
|
_playingSub?.cancel();
|
||||||
|
_errorSub?.cancel();
|
||||||
|
_player.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _togglePlay() {
|
||||||
|
if (_isPlaying) {
|
||||||
|
_player.pause();
|
||||||
|
} else {
|
||||||
|
if (_clipEnd != double.infinity && _position >= _clipEnd - 0.1) {
|
||||||
|
_player.seek(Duration(milliseconds: (_clipStart * 1000).round()));
|
||||||
|
}
|
||||||
|
_player.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_error != null) {
|
||||||
|
return Container(
|
||||||
|
height: 180,
|
||||||
|
color: Colors.black,
|
||||||
|
child: const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.videocam_off, color: Colors.grey, size: 32),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Unable to load video',
|
||||||
|
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_isInitialized) {
|
||||||
|
return const SizedBox(
|
||||||
|
height: 180,
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final hasClip = _clipEnd != double.infinity;
|
||||||
|
final clipDuration = hasClip ? (_clipEnd - _clipStart) : 0.0;
|
||||||
|
final clipPosition = (_position - _clipStart).clamp(0.0, clipDuration);
|
||||||
|
final progress = (hasClip && clipDuration > 0) ? clipPosition / clipDuration : 0.0;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
height: 220,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Video(
|
||||||
|
controller: _videoController,
|
||||||
|
controls: NoVideoControls,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Container(
|
||||||
|
color: Colors.black54,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _togglePlay,
|
||||||
|
child: Icon(
|
||||||
|
_isPlaying ? Icons.pause : Icons.play_arrow,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: progress.clamp(0.0, 1.0),
|
||||||
|
backgroundColor: Colors.white24,
|
||||||
|
valueColor: const AlwaysStoppedAnimation(
|
||||||
|
Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (hasClip) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'${_fmt(clipPosition)} / ${_fmt(clipDuration)}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (hasClip)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Text(
|
||||||
|
'Clip ${_fmt(_clipStart)} — ${_fmt(_clipEnd)}',
|
||||||
|
style: const TextStyle(color: Colors.grey, fontSize: 11),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _fmt(double seconds) {
|
||||||
|
final m = seconds ~/ 60;
|
||||||
|
final s = (seconds % 60).toInt();
|
||||||
|
return '$m:${s.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
79
lib/presentation/welcome/welcome_screen.dart
Normal file
79
lib/presentation/welcome/welcome_screen.dart
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||||
|
import 'package:trainhub_flutter/core/router/app_router.dart';
|
||||||
|
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||||
|
import 'package:trainhub_flutter/presentation/settings/ai_model_settings_controller.dart';
|
||||||
|
import 'package:trainhub_flutter/presentation/settings/ai_model_settings_state.dart';
|
||||||
|
import 'package:trainhub_flutter/presentation/welcome/widgets/download_progress.dart';
|
||||||
|
import 'package:trainhub_flutter/presentation/welcome/widgets/initial_prompt.dart';
|
||||||
|
|
||||||
|
@RoutePage()
|
||||||
|
class WelcomeScreen extends ConsumerStatefulWidget {
|
||||||
|
const WelcomeScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<WelcomeScreen> createState() => _WelcomeScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WelcomeScreenState extends ConsumerState<WelcomeScreen> {
|
||||||
|
bool _hasNavigated = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
ref
|
||||||
|
.read(aiModelSettingsControllerProvider.notifier)
|
||||||
|
.validateModels()
|
||||||
|
.then((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final validated = ref
|
||||||
|
.read(aiModelSettingsControllerProvider)
|
||||||
|
.areModelsValidated;
|
||||||
|
if (validated) _navigateToApp();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _navigateToApp() {
|
||||||
|
if (_hasNavigated) return;
|
||||||
|
_hasNavigated = true;
|
||||||
|
context.router.replace(const ShellRoute());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final modelState = ref.watch(aiModelSettingsControllerProvider);
|
||||||
|
|
||||||
|
ref.listen<AiModelSettingsState>(aiModelSettingsControllerProvider,
|
||||||
|
(prev, next) {
|
||||||
|
if (!_hasNavigated &&
|
||||||
|
next.areModelsValidated &&
|
||||||
|
!next.isDownloading) {
|
||||||
|
_navigateToApp();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppColors.surface,
|
||||||
|
body: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 560,
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: UIConstants.animationDuration,
|
||||||
|
child: modelState.isDownloading
|
||||||
|
? DownloadProgress(modelState: modelState)
|
||||||
|
: InitialPrompt(
|
||||||
|
onDownload: () => ref
|
||||||
|
.read(aiModelSettingsControllerProvider.notifier)
|
||||||
|
.downloadAll(),
|
||||||
|
onSkip: _navigateToApp,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,19 +5,25 @@ import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
|||||||
class SessionControls extends StatelessWidget {
|
class SessionControls extends StatelessWidget {
|
||||||
final bool isRunning;
|
final bool isRunning;
|
||||||
final bool isFinished;
|
final bool isFinished;
|
||||||
|
final bool isTimeBased;
|
||||||
final VoidCallback onPause;
|
final VoidCallback onPause;
|
||||||
final VoidCallback onPlay;
|
final VoidCallback onPlay;
|
||||||
final VoidCallback onNext;
|
final VoidCallback onNext;
|
||||||
final VoidCallback onPrevious;
|
final VoidCallback onPrevious;
|
||||||
|
final VoidCallback onRewind;
|
||||||
|
final VoidCallback onFastForward;
|
||||||
|
|
||||||
const SessionControls({
|
const SessionControls({
|
||||||
super.key,
|
super.key,
|
||||||
required this.isRunning,
|
required this.isRunning,
|
||||||
required this.isFinished,
|
required this.isFinished,
|
||||||
|
required this.isTimeBased,
|
||||||
required this.onPause,
|
required this.onPause,
|
||||||
required this.onPlay,
|
required this.onPlay,
|
||||||
required this.onNext,
|
required this.onNext,
|
||||||
required this.onPrevious,
|
required this.onPrevious,
|
||||||
|
required this.onRewind,
|
||||||
|
required this.onFastForward,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -39,13 +45,20 @@ class SessionControls extends StatelessWidget {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
if (isTimeBased) ...[
|
||||||
|
_ControlButton(
|
||||||
|
icon: Icons.replay_10,
|
||||||
|
onTap: onRewind,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
const SizedBox(width: UIConstants.spacing8),
|
||||||
|
],
|
||||||
_ControlButton(
|
_ControlButton(
|
||||||
icon: Icons.skip_previous_rounded,
|
icon: Icons.skip_previous_rounded,
|
||||||
onTap: onPrevious,
|
onTap: onPrevious,
|
||||||
size: 28,
|
size: 28,
|
||||||
),
|
),
|
||||||
const SizedBox(width: UIConstants.spacing24),
|
const SizedBox(width: UIConstants.spacing24),
|
||||||
// Play/Pause - larger main button
|
|
||||||
Container(
|
Container(
|
||||||
width: 56,
|
width: 56,
|
||||||
height: 56,
|
height: 56,
|
||||||
@@ -79,6 +92,14 @@ class SessionControls extends StatelessWidget {
|
|||||||
onTap: onNext,
|
onTap: onNext,
|
||||||
size: 28,
|
size: 28,
|
||||||
),
|
),
|
||||||
|
if (isTimeBased) ...[
|
||||||
|
const SizedBox(width: UIConstants.spacing8),
|
||||||
|
_ControlButton(
|
||||||
|
icon: Icons.forward_10,
|
||||||
|
onTap: onFastForward,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,12 +20,20 @@ class WorkoutSessionController extends _$WorkoutSessionController {
|
|||||||
final activities = _buildSequence(plan);
|
final activities = _buildSequence(plan);
|
||||||
ref.onDispose(() => _timer?.cancel());
|
ref.onDispose(() => _timer?.cancel());
|
||||||
|
|
||||||
final initialState = WorkoutSessionState(activities: activities);
|
if (activities.isEmpty) {
|
||||||
|
return WorkoutSessionState(activities: activities);
|
||||||
if (activities.isNotEmpty) {
|
|
||||||
final first = activities.first;
|
|
||||||
return initialState.copyWith(timeRemaining: first.duration);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final first = activities.first;
|
||||||
|
final initialState = WorkoutSessionState(
|
||||||
|
activities: activities,
|
||||||
|
timeRemaining: first.duration,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (first.isTimeBased) {
|
||||||
|
Future.microtask(startTimer);
|
||||||
|
}
|
||||||
|
|
||||||
return initialState;
|
return initialState;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +93,19 @@ class WorkoutSessionController extends _$WorkoutSessionController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void rewindSeconds(int amount) {
|
||||||
|
final currentState = state.value;
|
||||||
|
if (currentState == null) return;
|
||||||
|
final maxDuration = currentState.currentActivity?.duration ?? 0;
|
||||||
|
final newRemaining = (currentState.timeRemaining + amount).clamp(
|
||||||
|
0,
|
||||||
|
maxDuration,
|
||||||
|
);
|
||||||
|
state = AsyncValue.data(
|
||||||
|
currentState.copyWith(timeRemaining: newRemaining),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _tick(Timer timer) {
|
void _tick(Timer timer) {
|
||||||
if (state.value?.isFinished ?? true) return;
|
if (state.value?.isFinished ?? true) return;
|
||||||
var currentState = state.value!;
|
var currentState = state.value!;
|
||||||
@@ -98,7 +119,7 @@ class WorkoutSessionController extends _$WorkoutSessionController {
|
|||||||
if (newState.timeRemaining > 0) {
|
if (newState.timeRemaining > 0) {
|
||||||
newState = newState.copyWith(timeRemaining: newState.timeRemaining - 1);
|
newState = newState.copyWith(timeRemaining: newState.timeRemaining - 1);
|
||||||
} else {
|
} else {
|
||||||
state = AsyncValue.data(newState); // update interim state before next
|
state = AsyncValue.data(newState);
|
||||||
_goNext(newState);
|
_goNext(newState);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -123,7 +144,7 @@ class WorkoutSessionController extends _$WorkoutSessionController {
|
|||||||
|
|
||||||
state = AsyncValue.data(newState);
|
state = AsyncValue.data(newState);
|
||||||
|
|
||||||
if (nextActivity.isRest) {
|
if (nextActivity.isTimeBased) {
|
||||||
startTimer();
|
startTimer();
|
||||||
} else {
|
} else {
|
||||||
pauseTimer();
|
pauseTimer();
|
||||||
@@ -152,9 +173,8 @@ class WorkoutSessionController extends _$WorkoutSessionController {
|
|||||||
|
|
||||||
void jumpTo(int index) {
|
void jumpTo(int index) {
|
||||||
final currentState = state.value;
|
final currentState = state.value;
|
||||||
if (currentState != null &&
|
if (currentState == null) return;
|
||||||
index >= 0 &&
|
if (index < 0 || index >= currentState.activities.length) return;
|
||||||
index < currentState.activities.length) {
|
|
||||||
final activity = currentState.activities[index];
|
final activity = currentState.activities[index];
|
||||||
|
|
||||||
state = AsyncValue.data(
|
state = AsyncValue.data(
|
||||||
@@ -164,13 +184,12 @@ class WorkoutSessionController extends _$WorkoutSessionController {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (activity.isRest) {
|
if (activity.isTimeBased) {
|
||||||
startTimer();
|
startTimer();
|
||||||
} else {
|
} else {
|
||||||
pauseTimer();
|
pauseTimer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
void _finish() {
|
void _finish() {
|
||||||
pauseTimer();
|
pauseTimer();
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ part of 'workout_session_controller.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$workoutSessionControllerHash() =>
|
String _$workoutSessionControllerHash() =>
|
||||||
r'd3f53d72c80963634c6edaeb44aa5b04c9ffba6d';
|
r'ba4c44e3bc2de98cced4eef96f8a337fd1e43665';
|
||||||
|
|
||||||
/// Copied from Dart SDK
|
/// Copied from Dart SDK
|
||||||
class _SystemHash {
|
class _SystemHash {
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import 'dart:math';
|
import 'dart:math' as math;
|
||||||
import 'dart:ui';
|
|
||||||
|
|
||||||
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: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/workout_activity.dart';
|
||||||
import 'package:trainhub_flutter/presentation/workout_session/workout_session_controller.dart';
|
import 'package:trainhub_flutter/presentation/workout_session/workout_session_controller.dart';
|
||||||
import 'package:trainhub_flutter/presentation/workout_session/workout_session_state.dart';
|
import 'package:trainhub_flutter/presentation/workout_session/workout_session_state.dart';
|
||||||
import 'package:trainhub_flutter/presentation/workout_session/widgets/activity_card.dart';
|
import 'package:trainhub_flutter/presentation/workout_session/widgets/activity_card.dart';
|
||||||
@@ -55,12 +54,14 @@ class WorkoutSessionPage extends ConsumerWidget {
|
|||||||
const SizedBox(height: UIConstants.spacing8),
|
const SizedBox(height: UIConstants.spacing8),
|
||||||
Text(
|
Text(
|
||||||
'$err',
|
'$err',
|
||||||
style: TextStyle(
|
style: TextStyle(color: AppColors.textMuted, fontSize: 14),
|
||||||
color: AppColors.textMuted,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing16),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: () => context.router.maybePop(),
|
||||||
|
child: const Text('Go Back'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -75,11 +76,8 @@ class WorkoutSessionPage extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final isRest = state.currentActivity?.isRest ?? false;
|
|
||||||
|
|
||||||
return _ActiveSessionView(
|
return _ActiveSessionView(
|
||||||
state: state,
|
state: state,
|
||||||
isRest: isRest,
|
|
||||||
controller: controller,
|
controller: controller,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -88,27 +86,32 @@ class WorkoutSessionPage extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
class _ActiveSessionView extends StatefulWidget {
|
||||||
// Active session view (gradient background + timer + controls)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
class _ActiveSessionView extends StatelessWidget {
|
|
||||||
final WorkoutSessionState state;
|
final WorkoutSessionState state;
|
||||||
final bool isRest;
|
|
||||||
final WorkoutSessionController controller;
|
final WorkoutSessionController controller;
|
||||||
|
|
||||||
const _ActiveSessionView({
|
const _ActiveSessionView({
|
||||||
required this.state,
|
required this.state,
|
||||||
required this.isRest,
|
|
||||||
required this.controller,
|
required this.controller,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ActiveSessionView> createState() => _ActiveSessionViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ActiveSessionViewState extends State<_ActiveSessionView> {
|
||||||
|
bool _showActivitiesList = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Compute the time progress for the circular ring.
|
final activity = widget.state.currentActivity;
|
||||||
final activity = state.currentActivity;
|
final isRest = activity?.isRest ?? false;
|
||||||
|
final isTimeBased = activity?.isTimeBased ?? false;
|
||||||
|
|
||||||
final double timeProgress;
|
final double timeProgress;
|
||||||
if (activity != null && activity.duration > 0) {
|
if (activity != null && activity.duration > 0) {
|
||||||
timeProgress = 1.0 - (state.timeRemaining / activity.duration);
|
timeProgress =
|
||||||
|
1.0 - (widget.state.timeRemaining / activity.duration);
|
||||||
} else {
|
} else {
|
||||||
timeProgress = 0.0;
|
timeProgress = 0.0;
|
||||||
}
|
}
|
||||||
@@ -118,7 +121,9 @@ class _ActiveSessionView extends StatelessWidget {
|
|||||||
: AppColors.accent.withValues(alpha: 0.06);
|
: AppColors.accent.withValues(alpha: 0.06);
|
||||||
final ringColor = isRest ? AppColors.info : AppColors.accent;
|
final ringColor = isRest ? AppColors.info : AppColors.accent;
|
||||||
|
|
||||||
return Container(
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
@@ -133,18 +138,110 @@ class _ActiveSessionView extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// -- Top progress bar --
|
SessionProgressBar(progress: widget.state.progress),
|
||||||
SessionProgressBar(progress: state.progress),
|
_buildTopBar(context),
|
||||||
|
Expanded(
|
||||||
// -- Elapsed time badge --
|
child: Center(
|
||||||
Padding(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.only(
|
padding: const EdgeInsets.symmetric(
|
||||||
top: UIConstants.spacing16,
|
horizontal: UIConstants.spacing24,
|
||||||
right: UIConstants.spacing24,
|
|
||||||
),
|
),
|
||||||
child: Align(
|
child: Column(
|
||||||
alignment: Alignment.centerRight,
|
mainAxisSize: MainAxisSize.min,
|
||||||
child: Container(
|
children: [
|
||||||
|
if (activity != null)
|
||||||
|
ActivityCard(activity: activity),
|
||||||
|
const SizedBox(height: UIConstants.spacing32),
|
||||||
|
if (isTimeBased)
|
||||||
|
_CircularTimerDisplay(
|
||||||
|
timeRemaining: widget.state.timeRemaining,
|
||||||
|
progress: timeProgress,
|
||||||
|
ringColor: ringColor,
|
||||||
|
isRunning: widget.state.isRunning,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
_RepsDisplay(
|
||||||
|
reps: activity?.originalExercise?.value ?? 0,
|
||||||
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing24),
|
||||||
|
if (widget.state.nextActivity != null)
|
||||||
|
_UpNextPill(
|
||||||
|
nextActivityName: widget.state.nextActivity!.name,
|
||||||
|
isNextRest: widget.state.nextActivity!.isRest,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SessionControls(
|
||||||
|
isRunning: widget.state.isRunning,
|
||||||
|
isFinished: widget.state.isFinished,
|
||||||
|
isTimeBased: isTimeBased,
|
||||||
|
onPause: widget.controller.pauseTimer,
|
||||||
|
onPlay: widget.controller.startTimer,
|
||||||
|
onNext: widget.controller.next,
|
||||||
|
onPrevious: widget.controller.previous,
|
||||||
|
onRewind: () => widget.controller.rewindSeconds(10),
|
||||||
|
onFastForward: () => widget.controller.rewindSeconds(-10),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UIConstants.spacing24),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_showActivitiesList)
|
||||||
|
_ActivitiesListPanel(
|
||||||
|
activities: widget.state.activities,
|
||||||
|
currentIndex: widget.state.currentIndex,
|
||||||
|
onJumpTo: (index) {
|
||||||
|
widget.controller.jumpTo(index);
|
||||||
|
setState(() => _showActivitiesList = false);
|
||||||
|
},
|
||||||
|
onClose: () => setState(() => _showActivitiesList = false),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTopBar(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UIConstants.spacing16,
|
||||||
|
vertical: UIConstants.spacing8,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => _confirmExit(context),
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.arrow_back_rounded,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
tooltip: 'Exit workout',
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
_buildElapsedTimeBadge(),
|
||||||
|
const SizedBox(width: UIConstants.spacing8),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () =>
|
||||||
|
setState(() => _showActivitiesList = !_showActivitiesList),
|
||||||
|
icon: Icon(
|
||||||
|
_showActivitiesList
|
||||||
|
? Icons.list_rounded
|
||||||
|
: Icons.format_list_bulleted_rounded,
|
||||||
|
color: _showActivitiesList
|
||||||
|
? AppColors.accent
|
||||||
|
: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
tooltip: 'Exercise list',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildElapsedTimeBadge() {
|
||||||
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: UIConstants.spacing12,
|
horizontal: UIConstants.spacing12,
|
||||||
vertical: UIConstants.spacing4,
|
vertical: UIConstants.spacing4,
|
||||||
@@ -166,7 +263,7 @@ class _ActiveSessionView extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
_formatDuration(state.totalTimeElapsed),
|
_formatDuration(widget.state.totalTimeElapsed),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
@@ -176,63 +273,36 @@ class _ActiveSessionView extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
),
|
}
|
||||||
),
|
|
||||||
|
|
||||||
// -- Central content --
|
Future<void> _confirmExit(BuildContext context) async {
|
||||||
Expanded(
|
final shouldExit = await showDialog<bool>(
|
||||||
child: Center(
|
context: context,
|
||||||
child: SingleChildScrollView(
|
builder: (dialogContext) => AlertDialog(
|
||||||
padding: const EdgeInsets.symmetric(
|
title: const Text('Exit Workout?'),
|
||||||
horizontal: UIConstants.spacing24,
|
content: const Text(
|
||||||
|
'Your progress will not be saved. Are you sure you want to exit?',
|
||||||
),
|
),
|
||||||
child: Column(
|
actions: [
|
||||||
mainAxisSize: MainAxisSize.min,
|
TextButton(
|
||||||
children: [
|
onPressed: () => Navigator.pop(dialogContext, false),
|
||||||
// Activity info card
|
child: const Text('Cancel'),
|
||||||
if (activity != null)
|
|
||||||
ActivityCard(activity: activity),
|
|
||||||
|
|
||||||
const SizedBox(height: UIConstants.spacing32),
|
|
||||||
|
|
||||||
// Circular progress ring + timer
|
|
||||||
_CircularTimerDisplay(
|
|
||||||
timeRemaining: state.timeRemaining,
|
|
||||||
progress: timeProgress,
|
|
||||||
ringColor: ringColor,
|
|
||||||
isRunning: state.isRunning,
|
|
||||||
isTimeBased: activity?.isTimeBased ?? false,
|
|
||||||
),
|
),
|
||||||
|
FilledButton(
|
||||||
const SizedBox(height: UIConstants.spacing24),
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
// "Up next" pill
|
backgroundColor: AppColors.destructive,
|
||||||
if (state.nextActivity != null)
|
|
||||||
_UpNextPill(
|
|
||||||
nextActivityName: state.nextActivity!.name,
|
|
||||||
isNextRest: state.nextActivity!.isRest,
|
|
||||||
),
|
),
|
||||||
],
|
child: const Text('Exit'),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// -- Bottom controls --
|
|
||||||
SessionControls(
|
|
||||||
isRunning: state.isRunning,
|
|
||||||
isFinished: state.isFinished,
|
|
||||||
onPause: controller.pauseTimer,
|
|
||||||
onPlay: controller.startTimer,
|
|
||||||
onNext: controller.next,
|
|
||||||
onPrevious: controller.previous,
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: UIConstants.spacing24),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if (shouldExit == true && context.mounted) {
|
||||||
|
widget.controller.pauseTimer();
|
||||||
|
context.router.maybePop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatDuration(int seconds) {
|
String _formatDuration(int seconds) {
|
||||||
@@ -242,28 +312,116 @@ class _ActiveSessionView extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
class _RepsDisplay extends StatelessWidget {
|
||||||
// Circular timer with arc progress ring
|
final int reps;
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
const _RepsDisplay({required this.reps});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
const double size = 260;
|
||||||
|
return SizedBox(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: size * 0.75,
|
||||||
|
height: size * 0.75,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: AppColors.accent.withValues(alpha: 0.15),
|
||||||
|
blurRadius: 60,
|
||||||
|
spreadRadius: 10,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
CustomPaint(
|
||||||
|
size: const Size(size, size),
|
||||||
|
painter: TimerRingPainter(
|
||||||
|
progress: 1.0,
|
||||||
|
ringColor: AppColors.accent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'$reps',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
fontSize: 72,
|
||||||
|
fontWeight: FontWeight.w200,
|
||||||
|
letterSpacing: -2,
|
||||||
|
shadows: [
|
||||||
|
Shadow(
|
||||||
|
color: AppColors.accent.withValues(alpha: 0.3),
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Text(
|
||||||
|
'REPS',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors.accent,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.zinc800.withValues(alpha: 0.4),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.accent.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'TAP NEXT WHEN DONE',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
letterSpacing: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _CircularTimerDisplay extends StatelessWidget {
|
class _CircularTimerDisplay extends StatelessWidget {
|
||||||
final int timeRemaining;
|
final int timeRemaining;
|
||||||
final double progress;
|
final double progress;
|
||||||
final Color ringColor;
|
final Color ringColor;
|
||||||
final bool isRunning;
|
final bool isRunning;
|
||||||
final bool isTimeBased;
|
|
||||||
|
|
||||||
const _CircularTimerDisplay({
|
const _CircularTimerDisplay({
|
||||||
required this.timeRemaining,
|
required this.timeRemaining,
|
||||||
required this.progress,
|
required this.progress,
|
||||||
required this.ringColor,
|
required this.ringColor,
|
||||||
required this.isRunning,
|
required this.isRunning,
|
||||||
required this.isTimeBased,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
const double size = 220;
|
const double size = 260;
|
||||||
const double strokeWidth = 6.0;
|
|
||||||
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: size,
|
width: size,
|
||||||
@@ -271,49 +429,34 @@ class _CircularTimerDisplay extends StatelessWidget {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
// Background track
|
|
||||||
SizedBox(
|
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
value: 1.0,
|
|
||||||
strokeWidth: strokeWidth,
|
|
||||||
color: AppColors.zinc800.withValues(alpha: 0.5),
|
|
||||||
strokeCap: StrokeCap.round,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Progress arc
|
|
||||||
if (isTimeBased)
|
|
||||||
SizedBox(
|
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
value: progress.clamp(0.0, 1.0),
|
|
||||||
strokeWidth: strokeWidth,
|
|
||||||
color: ringColor,
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
strokeCap: StrokeCap.round,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Glow behind the timer text
|
|
||||||
Container(
|
Container(
|
||||||
width: size * 0.7,
|
width: size * 0.75,
|
||||||
height: size * 0.7,
|
height: size * 0.75,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: ringColor.withValues(alpha: 0.08),
|
color: ringColor.withValues(alpha: isRunning ? 0.15 : 0.05),
|
||||||
blurRadius: 40,
|
blurRadius: 60,
|
||||||
spreadRadius: 10,
|
spreadRadius: 10,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
TweenAnimationBuilder<double>(
|
||||||
// Timer text
|
tween: Tween<double>(begin: progress, end: progress),
|
||||||
|
duration: const Duration(milliseconds: 400),
|
||||||
|
curve: Curves.easeOutQuart,
|
||||||
|
builder: (context, value, child) {
|
||||||
|
return CustomPaint(
|
||||||
|
size: const Size(size, size),
|
||||||
|
painter: TimerRingPainter(
|
||||||
|
progress: value,
|
||||||
|
ringColor: ringColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
Column(
|
Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
@@ -321,39 +464,49 @@ class _CircularTimerDisplay extends StatelessWidget {
|
|||||||
_formatTime(timeRemaining),
|
_formatTime(timeRemaining),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
fontSize: 52,
|
fontSize: 64,
|
||||||
fontWeight: FontWeight.w300,
|
fontWeight: FontWeight.w200,
|
||||||
letterSpacing: 2,
|
letterSpacing: -1,
|
||||||
fontFeatures: const [FontFeature.tabularFigures()],
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
|
shadows: [
|
||||||
|
Shadow(
|
||||||
|
color: ringColor.withValues(alpha: 0.3),
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!isTimeBased)
|
AnimatedOpacity(
|
||||||
Padding(
|
opacity: isRunning ? 0.0 : 1.0,
|
||||||
padding: const EdgeInsets.only(top: 4),
|
duration: const Duration(milliseconds: 200),
|
||||||
child: Text(
|
child: Padding(
|
||||||
'UNTIMED',
|
padding: const EdgeInsets.only(top: 8),
|
||||||
style: TextStyle(
|
child: Container(
|
||||||
color: AppColors.textMuted,
|
padding: const EdgeInsets.symmetric(
|
||||||
fontSize: 11,
|
horizontal: 12,
|
||||||
fontWeight: FontWeight.w600,
|
vertical: 4,
|
||||||
letterSpacing: 2,
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.zinc800.withValues(alpha: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.zinc700.withValues(alpha: 0.5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
if (isTimeBased && !isRunning)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 4),
|
|
||||||
child: Text(
|
child: Text(
|
||||||
'PAUSED',
|
'PAUSED',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: AppColors.textMuted,
|
color: AppColors.textMuted,
|
||||||
fontSize: 11,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
letterSpacing: 2,
|
letterSpacing: 2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -368,9 +521,82 @@ class _CircularTimerDisplay extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
class TimerRingPainter extends CustomPainter {
|
||||||
// "Up next" pill
|
final double progress;
|
||||||
// ---------------------------------------------------------------------------
|
final Color ringColor;
|
||||||
|
|
||||||
|
TimerRingPainter({
|
||||||
|
required this.progress,
|
||||||
|
required this.ringColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final center = Offset(size.width / 2, size.height / 2);
|
||||||
|
final radius = math.min(size.width, size.height) / 2;
|
||||||
|
const strokeWidth = 10.0;
|
||||||
|
|
||||||
|
// Draw background track
|
||||||
|
final trackPaint = Paint()
|
||||||
|
..color = AppColors.zinc800.withValues(alpha: 0.4)
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = strokeWidth;
|
||||||
|
canvas.drawCircle(center, radius, trackPaint);
|
||||||
|
|
||||||
|
if (progress <= 0.0) return;
|
||||||
|
|
||||||
|
// Draw glowing shadow for the progress
|
||||||
|
final shadowPaint = Paint()
|
||||||
|
..color = ringColor.withValues(alpha: 0.5)
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = strokeWidth * 2
|
||||||
|
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 16);
|
||||||
|
|
||||||
|
final sweepAngle = 2 * math.pi * progress;
|
||||||
|
final startAngle = -math.pi / 2;
|
||||||
|
|
||||||
|
canvas.drawArc(
|
||||||
|
Rect.fromCircle(center: center, radius: radius),
|
||||||
|
startAngle,
|
||||||
|
sweepAngle,
|
||||||
|
false,
|
||||||
|
shadowPaint,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Draw progress ring with gradient
|
||||||
|
final gradient = SweepGradient(
|
||||||
|
startAngle: 0.0,
|
||||||
|
endAngle: 2 * math.pi,
|
||||||
|
colors: [
|
||||||
|
ringColor.withValues(alpha: 0.5),
|
||||||
|
ringColor,
|
||||||
|
ringColor.withValues(alpha: 0.8),
|
||||||
|
],
|
||||||
|
transform: const GradientRotation(-math.pi / 2),
|
||||||
|
);
|
||||||
|
|
||||||
|
final progressPaint = Paint()
|
||||||
|
..shader = gradient.createShader(Rect.fromCircle(center: center, radius: radius))
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeCap = StrokeCap.round
|
||||||
|
..strokeWidth = strokeWidth;
|
||||||
|
|
||||||
|
canvas.drawArc(
|
||||||
|
Rect.fromCircle(center: center, radius: radius),
|
||||||
|
startAngle,
|
||||||
|
sweepAngle,
|
||||||
|
false,
|
||||||
|
progressPaint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(TimerRingPainter oldDelegate) {
|
||||||
|
return oldDelegate.progress != progress ||
|
||||||
|
oldDelegate.ringColor != ringColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _UpNextPill extends StatelessWidget {
|
class _UpNextPill extends StatelessWidget {
|
||||||
final String nextActivityName;
|
final String nextActivityName;
|
||||||
final bool isNextRest;
|
final bool isNextRest;
|
||||||
@@ -439,9 +665,180 @@ class _UpNextPill extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
class _ActivitiesListPanel extends StatelessWidget {
|
||||||
// Completion screen
|
final List<WorkoutActivityEntity> activities;
|
||||||
// ---------------------------------------------------------------------------
|
final int currentIndex;
|
||||||
|
final Function(int) onJumpTo;
|
||||||
|
final VoidCallback onClose;
|
||||||
|
|
||||||
|
const _ActivitiesListPanel({
|
||||||
|
required this.activities,
|
||||||
|
required this.currentIndex,
|
||||||
|
required this.onJumpTo,
|
||||||
|
required this.onClose,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Positioned.fill(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: onClose,
|
||||||
|
child: ColoredBox(
|
||||||
|
color: Colors.black.withValues(alpha: 0.5),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () {},
|
||||||
|
child: Container(
|
||||||
|
width: 320,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AppColors.surfaceContainer,
|
||||||
|
border: Border(
|
||||||
|
left: BorderSide(color: AppColors.border),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(UIConstants.spacing16),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(color: AppColors.border),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'All Exercises',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
IconButton(
|
||||||
|
onPressed: onClose,
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.close,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: activities.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final activity = activities[index];
|
||||||
|
final isCurrent = index == currentIndex;
|
||||||
|
final isRest = activity.isRest;
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: () => onJumpTo(index),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UIConstants.spacing16,
|
||||||
|
vertical: UIConstants.spacing12,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isCurrent
|
||||||
|
? AppColors.accent.withValues(alpha: 0.12)
|
||||||
|
: null,
|
||||||
|
border: const Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: AppColors.border,
|
||||||
|
width: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: isCurrent
|
||||||
|
? AppColors.accent
|
||||||
|
: isRest
|
||||||
|
? AppColors.info
|
||||||
|
: AppColors.zinc600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: UIConstants.spacing12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
activity.name,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isCurrent
|
||||||
|
? AppColors.textPrimary
|
||||||
|
: AppColors.textSecondary,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: isCurrent
|
||||||
|
? FontWeight.w600
|
||||||
|
: FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!isRest && activity.setIndex != null)
|
||||||
|
Text(
|
||||||
|
'Set ${activity.setIndex}/${activity.totalSets} · ${activity.sectionName ?? ''}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (activity.isTimeBased)
|
||||||
|
Text(
|
||||||
|
_formatDuration(activity.duration),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
fontSize: 12,
|
||||||
|
fontFeatures: [
|
||||||
|
FontFeature.tabularFigures(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (!isRest)
|
||||||
|
Text(
|
||||||
|
'${activity.originalExercise?.value ?? 0} reps',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDuration(int seconds) {
|
||||||
|
final m = seconds ~/ 60;
|
||||||
|
final s = seconds % 60;
|
||||||
|
return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _CompletionScreen extends StatelessWidget {
|
class _CompletionScreen extends StatelessWidget {
|
||||||
final int totalTimeElapsed;
|
final int totalTimeElapsed;
|
||||||
|
|
||||||
@@ -466,7 +863,6 @@ class _CompletionScreen extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Checkmark circle
|
|
||||||
Container(
|
Container(
|
||||||
width: 96,
|
width: 96,
|
||||||
height: 96,
|
height: 96,
|
||||||
@@ -491,9 +887,7 @@ class _CompletionScreen extends StatelessWidget {
|
|||||||
size: 48,
|
size: 48,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: UIConstants.spacing24),
|
const SizedBox(height: UIConstants.spacing24),
|
||||||
|
|
||||||
const Text(
|
const Text(
|
||||||
'Workout Complete',
|
'Workout Complete',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -503,9 +897,7 @@ class _CompletionScreen extends StatelessWidget {
|
|||||||
letterSpacing: -0.5,
|
letterSpacing: -0.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: UIConstants.spacing8),
|
const SizedBox(height: UIConstants.spacing8),
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
'Great job! You crushed it.',
|
'Great job! You crushed it.',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -513,10 +905,7 @@ class _CompletionScreen extends StatelessWidget {
|
|||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: UIConstants.spacing32),
|
const SizedBox(height: UIConstants.spacing32),
|
||||||
|
|
||||||
// Total time card
|
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: UIConstants.spacing24,
|
horizontal: UIConstants.spacing24,
|
||||||
@@ -554,10 +943,7 @@ class _CompletionScreen extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: UIConstants.spacing32),
|
const SizedBox(height: UIConstants.spacing32),
|
||||||
|
|
||||||
// Finish button
|
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 200,
|
width: 200,
|
||||||
height: 48,
|
height: 48,
|
||||||
|
|||||||
@@ -6,17 +6,29 @@
|
|||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
|
||||||
|
#include <media_kit_video/media_kit_video_plugin.h>
|
||||||
#include <screen_retriever/screen_retriever_plugin.h>
|
#include <screen_retriever/screen_retriever_plugin.h>
|
||||||
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
|
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
|
||||||
|
#include <volume_controller/volume_controller_plugin.h>
|
||||||
#include <window_manager/window_manager_plugin.h>
|
#include <window_manager/window_manager_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
|
g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar =
|
||||||
|
fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin");
|
||||||
|
media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar);
|
||||||
|
g_autoptr(FlPluginRegistrar) media_kit_video_registrar =
|
||||||
|
fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitVideoPlugin");
|
||||||
|
media_kit_video_plugin_register_with_registrar(media_kit_video_registrar);
|
||||||
g_autoptr(FlPluginRegistrar) screen_retriever_registrar =
|
g_autoptr(FlPluginRegistrar) screen_retriever_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverPlugin");
|
||||||
screen_retriever_plugin_register_with_registrar(screen_retriever_registrar);
|
screen_retriever_plugin_register_with_registrar(screen_retriever_registrar);
|
||||||
g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar =
|
g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin");
|
||||||
sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar);
|
sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar);
|
||||||
|
g_autoptr(FlPluginRegistrar) volume_controller_registrar =
|
||||||
|
fl_plugin_registry_get_registrar_for_plugin(registry, "VolumeControllerPlugin");
|
||||||
|
volume_controller_plugin_register_with_registrar(volume_controller_registrar);
|
||||||
g_autoptr(FlPluginRegistrar) window_manager_registrar =
|
g_autoptr(FlPluginRegistrar) window_manager_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin");
|
||||||
window_manager_plugin_register_with_registrar(window_manager_registrar);
|
window_manager_plugin_register_with_registrar(window_manager_registrar);
|
||||||
|
|||||||
@@ -3,8 +3,11 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
media_kit_libs_linux
|
||||||
|
media_kit_video
|
||||||
screen_retriever
|
screen_retriever
|
||||||
sqlite3_flutter_libs
|
sqlite3_flutter_libs
|
||||||
|
volume_controller
|
||||||
window_manager
|
window_manager
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,22 @@
|
|||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
import media_kit_libs_macos_video
|
||||||
|
import media_kit_video
|
||||||
|
import package_info_plus
|
||||||
import screen_retriever
|
import screen_retriever
|
||||||
import sqlite3_flutter_libs
|
import sqlite3_flutter_libs
|
||||||
import video_player_avfoundation
|
import volume_controller
|
||||||
|
import wakelock_plus
|
||||||
import window_manager
|
import window_manager
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
|
MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin"))
|
||||||
|
MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin"))
|
||||||
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
ScreenRetrieverPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverPlugin"))
|
ScreenRetrieverPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverPlugin"))
|
||||||
Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin"))
|
Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin"))
|
||||||
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
|
VolumeControllerPlugin.register(with: registry.registrar(forPlugin: "VolumeControllerPlugin"))
|
||||||
|
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
|
||||||
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
|
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
256
pubspec.lock
256
pubspec.lock
@@ -33,6 +33,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.3.9"
|
version: "3.3.9"
|
||||||
|
archive:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: archive
|
||||||
|
sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.6.1"
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -217,14 +225,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.7"
|
version: "3.0.7"
|
||||||
csslib:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: csslib
|
|
||||||
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.0.2"
|
|
||||||
custom_lint_core:
|
custom_lint_core:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -249,6 +249,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.1"
|
version: "3.1.1"
|
||||||
|
dbus:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dbus
|
||||||
|
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.12"
|
||||||
|
dio:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: dio
|
||||||
|
sha256: b9d46faecab38fc8cc286f80bc4d61a3bb5d4ac49e51ed877b4d6706efe57b25
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.9.1"
|
||||||
|
dio_web_adapter:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dio_web_adapter
|
||||||
|
sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.1"
|
||||||
drift:
|
drift:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -461,14 +485,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.1"
|
version: "1.0.1"
|
||||||
html:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: html
|
|
||||||
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.15.6"
|
|
||||||
http:
|
http:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -493,6 +509,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
|
image:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image
|
||||||
|
sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.3.0"
|
||||||
intl:
|
intl:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -605,6 +629,70 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.11.1"
|
version: "0.11.1"
|
||||||
|
media_kit:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: media_kit
|
||||||
|
sha256: ae9e79597500c7ad6083a3c7b7b7544ddabfceacce7ae5c9709b0ec16a5d6643
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.6"
|
||||||
|
media_kit_libs_android_video:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: media_kit_libs_android_video
|
||||||
|
sha256: "3f6274e5ab2de512c286a25c327288601ee445ed8ac319e0ef0b66148bd8f76c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.8"
|
||||||
|
media_kit_libs_ios_video:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: media_kit_libs_ios_video
|
||||||
|
sha256: b5382994eb37a4564c368386c154ad70ba0cc78dacdd3fb0cd9f30db6d837991
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.4"
|
||||||
|
media_kit_libs_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: media_kit_libs_linux
|
||||||
|
sha256: "2b473399a49ec94452c4d4ae51cfc0f6585074398d74216092bf3d54aac37ecf"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.1"
|
||||||
|
media_kit_libs_macos_video:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: media_kit_libs_macos_video
|
||||||
|
sha256: f26aa1452b665df288e360393758f84b911f70ffb3878032e1aabba23aa1032d
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.4"
|
||||||
|
media_kit_libs_video:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: media_kit_libs_video
|
||||||
|
sha256: "2b235b5dac79c6020e01eef5022c6cc85fedc0df1738aadc6ea489daa12a92a9"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.7"
|
||||||
|
media_kit_libs_windows_video:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: media_kit_libs_windows_video
|
||||||
|
sha256: dff76da2778729ab650229e6b4ec6ec111eb5151431002cbd7ea304ff1f112ab
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.11"
|
||||||
|
media_kit_video:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: media_kit_video
|
||||||
|
sha256: "813858c3fe84eb46679eb698695f60665e2bfbef757766fac4d2e683f926e15a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.1"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -645,6 +733,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
|
package_info_plus:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_info_plus
|
||||||
|
sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "9.0.0"
|
||||||
|
package_info_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_info_plus_platform_interface
|
||||||
|
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.1"
|
||||||
path:
|
path:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -797,6 +901,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.6.4"
|
version: "2.6.4"
|
||||||
|
safe_local_storage:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: safe_local_storage
|
||||||
|
sha256: "287ea1f667c0b93cdc127dccc707158e2d81ee59fba0459c31a0c7da4d09c755"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.3"
|
||||||
|
screen_brightness_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: screen_brightness_android
|
||||||
|
sha256: d34f5321abd03bc3474f4c381f53d189117eba0b039eac1916aa92cca5fd0a96
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.3"
|
||||||
|
screen_brightness_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: screen_brightness_platform_interface
|
||||||
|
sha256: "737bd47b57746bc4291cab1b8a5843ee881af499514881b0247ec77447ee769c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.0"
|
||||||
screen_retriever:
|
screen_retriever:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -922,6 +1050,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.1"
|
||||||
|
synchronized:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: synchronized
|
||||||
|
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.4.0"
|
||||||
term_glyph:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -962,6 +1098,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.0"
|
||||||
|
universal_platform:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: universal_platform
|
||||||
|
sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
|
uri_parser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: uri_parser
|
||||||
|
sha256: "051c62e5f693de98ca9f130ee707f8916e2266945565926be3ff20659f7853ce"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.2"
|
||||||
uuid:
|
uuid:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1002,54 +1154,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
video_player:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: video_player
|
|
||||||
sha256: "08bfba72e311d48219acad4e191b1f9c27ff8cf928f2c7234874592d9c9d7341"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.11.0"
|
|
||||||
video_player_android:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: video_player_android
|
|
||||||
sha256: e726b33894526cf96a3eefe61af054b0c3e7d254443b3695b3c142dc277291be
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.9.3"
|
|
||||||
video_player_avfoundation:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: video_player_avfoundation
|
|
||||||
sha256: f93b93a3baa12ca0ff7d00ca8bc60c1ecd96865568a01ff0c18a99853ee201a5
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.9.3"
|
|
||||||
video_player_platform_interface:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: video_player_platform_interface
|
|
||||||
sha256: "57c5d73173f76d801129d0531c2774052c5a7c11ccb962f1830630decd9f24ec"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "6.6.0"
|
|
||||||
video_player_web:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: video_player_web
|
|
||||||
sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.4.0"
|
|
||||||
video_player_win:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: video_player_win
|
|
||||||
sha256: a4caca55ead1eb469d10060592e7ecdcbcd4493c6e2b63e4e666ff5446c790f1
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.2.2"
|
|
||||||
vm_service:
|
vm_service:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1058,6 +1162,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.0.2"
|
version: "15.0.2"
|
||||||
|
volume_controller:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: volume_controller
|
||||||
|
sha256: "5c1a13d2ea99d2f6753e7c660d0d3fab541f36da3999cafeb17b66fe49759ad7"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.4.1"
|
||||||
|
wakelock_plus:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: wakelock_plus
|
||||||
|
sha256: "9296d40c9adbedaba95d1e704f4e0b434be446e2792948d0e4aa977048104228"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.0"
|
||||||
|
wakelock_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: wakelock_plus_platform_interface
|
||||||
|
sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.0"
|
||||||
watcher:
|
watcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
11
pubspec.yaml
11
pubspec.yaml
@@ -36,9 +36,10 @@ dependencies:
|
|||||||
fl_chart: ^0.66.0
|
fl_chart: ^0.66.0
|
||||||
animate_do: ^3.3.4
|
animate_do: ^3.3.4
|
||||||
|
|
||||||
# Video
|
# Video (cross-platform: Linux, Windows, macOS, Android, iOS)
|
||||||
video_player: ^2.8.2
|
media_kit: ^1.1.10+1
|
||||||
video_player_win: ^3.2.2
|
media_kit_video: ^1.2.4
|
||||||
|
media_kit_libs_video: ^1.0.5
|
||||||
|
|
||||||
# Markdown
|
# Markdown
|
||||||
flutter_markdown: ^0.6.18+3
|
flutter_markdown: ^0.6.18+3
|
||||||
@@ -49,6 +50,10 @@ dependencies:
|
|||||||
window_manager: ^0.3.9
|
window_manager: ^0.3.9
|
||||||
uuid: ^4.5.1
|
uuid: ^4.5.1
|
||||||
|
|
||||||
|
# AI Model Download
|
||||||
|
dio: ^5.7.0
|
||||||
|
archive: ^3.6.0
|
||||||
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -6,18 +6,24 @@
|
|||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h>
|
||||||
|
#include <media_kit_video/media_kit_video_plugin_c_api.h>
|
||||||
#include <screen_retriever/screen_retriever_plugin.h>
|
#include <screen_retriever/screen_retriever_plugin.h>
|
||||||
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
|
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
|
||||||
#include <video_player_win/video_player_win_plugin_c_api.h>
|
#include <volume_controller/volume_controller_plugin_c_api.h>
|
||||||
#include <window_manager/window_manager_plugin.h>
|
#include <window_manager/window_manager_plugin.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
|
MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi"));
|
||||||
|
MediaKitVideoPluginCApiRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi"));
|
||||||
ScreenRetrieverPluginRegisterWithRegistrar(
|
ScreenRetrieverPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("ScreenRetrieverPlugin"));
|
registry->GetRegistrarForPlugin("ScreenRetrieverPlugin"));
|
||||||
Sqlite3FlutterLibsPluginRegisterWithRegistrar(
|
Sqlite3FlutterLibsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin"));
|
registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin"));
|
||||||
VideoPlayerWinPluginCApiRegisterWithRegistrar(
|
VolumeControllerPluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("VideoPlayerWinPluginCApi"));
|
registry->GetRegistrarForPlugin("VolumeControllerPluginCApi"));
|
||||||
WindowManagerPluginRegisterWithRegistrar(
|
WindowManagerPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("WindowManagerPlugin"));
|
registry->GetRegistrarForPlugin("WindowManagerPlugin"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
media_kit_libs_windows_video
|
||||||
|
media_kit_video
|
||||||
screen_retriever
|
screen_retriever
|
||||||
sqlite3_flutter_libs
|
sqlite3_flutter_libs
|
||||||
video_player_win
|
volume_controller
|
||||||
window_manager
|
window_manager
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user