Compare commits

2 Commits

Author SHA1 Message Date
Kazimierz Ciołek
9dcc4b87de Next refactors
Some checks failed
Build Linux App / build (push) Failing after 1m18s
2026-02-24 02:19:28 +01:00
0c9eb8878d Refactoring
Some checks failed
Build Linux App / build (push) Failing after 1m33s
2026-02-23 10:02:23 -05:00
81 changed files with 10121 additions and 2116 deletions

View File

@@ -8,7 +8,19 @@
"Bash(dir /s /b \"C:\\\\Users\\\\kaziu\\\\Desktop\\\\Trainhubv2\\\\trainhub_flutter\\\\lib\\\\presentation\\\\*.dart\")",
"Bash(flutter build:*)",
"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
View File

@@ -1,16 +1,141 @@
# trainhub_flutter
# TrainHub
A new Flutter project.
AI-powered training management desktop application for personal trainers. Features on-device AI chat with RAG, video analysis, program scheduling, and exercise library management.
## Architecture
```
lib/
├── core/ # Shared constants, extensions, router, theme, utils
├── domain/ # Business entities and repository interfaces
│ ├── entities/ # Freezed immutable models
│ └── repositories/ # Abstract repository contracts
├── data/ # Infrastructure and data access
│ ├── database/ # Drift ORM schema, DAOs
│ ├── mappers/ # Entity <-> DTO conversion
│ ├── repositories/ # Repository implementations
│ └── services/ # External service integrations (AI, embeddings)
└── presentation/ # UI layer
├── analysis/ # Video analysis feature
├── calendar/ # Program calendar
├── chat/ # AI chat with RAG
├── common/ # Shared widgets and dialogs
├── home/ # Dashboard
├── plan_editor/ # Training plan builder
├── settings/ # App settings and model management
├── shell/ # Main app layout (sidebar + tabs)
├── trainings/ # Training list management
├── welcome/ # First-launch onboarding
└── workout_session/ # Active workout tracking
```
**Layers:**
- **Domain** -- Pure Dart. Entities (Freezed), abstract repository interfaces. No framework imports.
- **Data** -- Drift database, DAOs, mappers, repository implementations, AI services (llama.cpp, Nomic).
- **Presentation** -- Flutter widgets, Riverpod controllers, Freezed UI states.
## Tech Stack
| Layer | Technology |
|---|---|
| UI | Flutter 3.x, Material Design 3, shadcn_ui |
| State | Riverpod 2.6 with code generation |
| Routing | AutoRoute 9.2 |
| Database | Drift 2.14 (SQLite) |
| DI | get_it 8.0 |
| Immutability | Freezed 2.5 |
| Local AI | llama.cpp (Qwen 2.5 7B + Nomic Embed v1.5) |
| Video | media_kit |
## Getting Started
This project is a starting point for a Flutter application.
### Prerequisites
A few resources to get you started if this is your first Flutter project:
- Flutter SDK (stable channel)
- Dart SDK >= 3.10.7
- Desktop platform toolchain (Visual Studio for Windows, Xcode for macOS)
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
### Setup
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
```bash
# Install dependencies
flutter pub get
# Run code generation
dart run build_runner build --delete-conflicting-outputs
# Launch app
flutter run -d windows # or macos, linux
```
### AI Models
The app uses local AI inference via llama.cpp. Models are downloaded automatically on first launch or from **Settings > AI Models**:
- **llama-server** binary (llama.cpp build b8130)
- **Qwen 2.5 7B Instruct Q4_K_M** (~4.7 GB) -- chat/reasoning
- **Nomic Embed Text v1.5 Q4_K_M** (~300 MB) -- text embeddings for RAG
Models are stored in the system documents directory. Total download: ~5 GB.
AI configuration constants are centralized in `lib/core/constants/ai_constants.dart`.
## Code Generation
This project relies on code generation for several packages. After modifying any annotated files, run:
```bash
dart run build_runner build --delete-conflicting-outputs
```
Generated files (do not edit manually):
- `*.freezed.dart` -- Freezed immutable models
- `*.g.dart` -- Drift DAOs, Riverpod providers, JSON serialization
- `app_router.gr.dart` -- AutoRoute generated routes
## Key Conventions
### State Management
Each feature follows the controller + state pattern:
```
feature/
├── feature_page.dart # @RoutePage widget
├── feature_controller.dart # @riverpod controller
├── feature_state.dart # @freezed state model
└── widgets/ # Extracted sub-widgets
```
Controllers are `@riverpod` classes that manage async state. States are `@freezed` classes.
### Dependency Injection
Services and repositories are registered in `lib/injection.dart` using get_it:
- **Singletons** -- database, DAOs, AI services
- **Lazy singletons** -- repositories
Controllers access dependencies via `getIt<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 |

View 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.';
}

View File

@@ -2,6 +2,7 @@ class AppConstants {
AppConstants._();
static const String appName = 'TrainHub';
static const String appVersion = '2.0.0';
static const double windowWidth = 1280;
static const double windowHeight = 800;
static const double minWindowWidth = 800;

View File

@@ -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/home/home_page.dart';
import 'package:trainhub_flutter/presentation/plan_editor/plan_editor_page.dart';
import 'package:trainhub_flutter/presentation/settings/knowledge_base_page.dart';
import 'package:trainhub_flutter/presentation/settings/settings_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/welcome/welcome_screen.dart';
import 'package:trainhub_flutter/presentation/workout_session/workout_session_page.dart';
part 'app_router.gr.dart';
@@ -15,9 +18,12 @@ part 'app_router.gr.dart';
class AppRouter extends RootStackRouter {
@override
List<AutoRoute> get routes => [
// First-launch welcome / model download screen
AutoRoute(page: WelcomeRoute.page, initial: true),
// Main app shell (side-nav + tab router)
AutoRoute(
page: ShellRoute.page,
initial: true,
children: [
AutoRoute(page: HomeRoute.page, initial: true),
AutoRoute(page: TrainingsRoute.page),
@@ -26,7 +32,11 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: ChatRoute.page),
],
),
// Full-screen standalone pages
AutoRoute(page: PlanEditorRoute.page),
AutoRoute(page: WorkoutSessionRoute.page),
AutoRoute(page: SettingsRoute.page),
AutoRoute(page: KnowledgeBaseRoute.page),
];
}

View File

@@ -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
/// [PlanEditorPage]
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
/// [ShellPage]
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
/// [WorkoutSessionPage]
class WorkoutSessionRoute extends PageRouteInfo<WorkoutSessionRouteArgs> {

View File

@@ -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 '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';
// ---------------------------------------------------------------------------
// Existing tables (unchanged)
// ---------------------------------------------------------------------------
class Exercises extends Table {
TextColumn get id => text()();
TextColumn get name => text()();
@@ -116,6 +122,35 @@ class ChatMessages extends Table {
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(
tables: [
Exercises,
@@ -127,6 +162,7 @@ class ChatMessages extends Table {
Annotations,
ChatSessions,
ChatMessages,
KnowledgeChunks, // added in schema v2
],
daos: [
ExerciseDao,
@@ -134,13 +170,24 @@ class ChatMessages extends Table {
ProgramDao,
AnalysisDao,
ChatDao,
KnowledgeChunkDao, // added in schema v2
],
)
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
@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() {

View File

@@ -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 {
_$AppDatabase(QueryExecutor e) : super(e);
$AppDatabaseManager get managers => $AppDatabaseManager(this);
@@ -3266,6 +3635,9 @@ abstract class _$AppDatabase extends GeneratedDatabase {
late final $AnnotationsTable annotations = $AnnotationsTable(this);
late final $ChatSessionsTable chatSessions = $ChatSessionsTable(this);
late final $ChatMessagesTable chatMessages = $ChatMessagesTable(this);
late final $KnowledgeChunksTable knowledgeChunks = $KnowledgeChunksTable(
this,
);
late final ExerciseDao exerciseDao = ExerciseDao(this as AppDatabase);
late final TrainingPlanDao trainingPlanDao = TrainingPlanDao(
this as AppDatabase,
@@ -3273,6 +3645,9 @@ abstract class _$AppDatabase extends GeneratedDatabase {
late final ProgramDao programDao = ProgramDao(this as AppDatabase);
late final AnalysisDao analysisDao = AnalysisDao(this as AppDatabase);
late final ChatDao chatDao = ChatDao(this as AppDatabase);
late final KnowledgeChunkDao knowledgeChunkDao = KnowledgeChunkDao(
this as AppDatabase,
);
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@@ -3287,6 +3662,7 @@ abstract class _$AppDatabase extends GeneratedDatabase {
annotations,
chatSessions,
chatMessages,
knowledgeChunks,
];
@override
StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([
@@ -6224,6 +6600,212 @@ typedef $$ChatMessagesTableProcessedTableManager =
ChatMessage,
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 {
final _$AppDatabase _db;
@@ -6246,4 +6828,6 @@ class $AppDatabaseManager {
$$ChatSessionsTableTableManager(_db, _db.chatSessions);
$$ChatMessagesTableTableManager get chatMessages =>
$$ChatMessagesTableTableManager(_db, _db.chatMessages);
$$KnowledgeChunksTableTableManager get knowledgeChunks =>
$$KnowledgeChunksTableTableManager(_db, _db.knowledgeChunks);
}

View File

@@ -27,6 +27,20 @@ class AnalysisDao extends DatabaseAccessor<AppDatabase>
Future<void> insertAnnotation(AnnotationsCompanion 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) =>
(delete(annotations)..where((t) => t.id.equals(id))).go();
}

View 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();
}

View 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;
}

View File

@@ -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
Future<void> deleteAnnotation(String id) async {
await _dao.deleteAnnotation(id);

View 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;
}

View 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();
}
}
}

View 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();
}
}

View 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;
}

View 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;
}

View File

@@ -15,5 +15,11 @@ abstract class AnalysisRepository {
required double endTime,
required String color,
});
Future<void> updateAnnotation({
required String id,
required String name,
required String description,
required String color,
});
Future<void> deleteAnnotation(String id);
}

View 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();
}

View File

@@ -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/analysis_repository_impl.dart';
import 'package:trainhub_flutter/data/repositories/chat_repository_impl.dart';
import 'package:trainhub_flutter/data/repositories/note_repository_impl.dart';
import 'package:trainhub_flutter/domain/repositories/note_repository.dart';
import 'package:trainhub_flutter/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;
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
getIt.registerSingleton<AppDatabase>(AppDatabase());
@@ -30,6 +39,12 @@ void init() {
getIt.registerSingleton<ProgramDao>(ProgramDao(getIt<AppDatabase>()));
getIt.registerSingleton<AnalysisDao>(AnalysisDao(getIt<AppDatabase>()));
getIt.registerSingleton<ChatDao>(ChatDao(getIt<AppDatabase>()));
getIt.registerSingleton<KnowledgeChunkDao>(
KnowledgeChunkDao(getIt<AppDatabase>()),
);
// Services
getIt.registerSingleton<EmbeddingService>(EmbeddingService());
// Repositories
getIt.registerLazySingleton<ExerciseRepository>(
@@ -47,4 +62,10 @@ void init() {
getIt.registerLazySingleton<ChatRepository>(
() => ChatRepositoryImpl(getIt<ChatDao>()),
);
getIt.registerLazySingleton<NoteRepository>(
() => NoteRepositoryImpl(
getIt<KnowledgeChunkDao>(),
getIt<EmbeddingService>(),
),
);
}

View File

@@ -1,18 +1,20 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:media_kit/media_kit.dart';
import 'package:window_manager/window_manager.dart';
import 'package:trainhub_flutter/core/router/app_router.dart';
import 'package:trainhub_flutter/core/theme/app_theme.dart';
import 'package:trainhub_flutter/data/services/ai_process_manager.dart';
import 'package:trainhub_flutter/injection.dart' as di;
void main() async {
WidgetsFlutterBinding.ensureInitialized();
MediaKit.ensureInitialized();
await windowManager.ensureInitialized();
// Initialize dependency injection
di.init();
WindowOptions windowOptions = const WindowOptions(
const windowOptions = WindowOptions(
size: Size(1280, 800),
minimumSize: Size(800, 600),
center: true,
@@ -30,17 +32,59 @@ void main() async {
runApp(const ProviderScope(child: TrainHubApp()));
}
class TrainHubApp extends StatelessWidget {
// =============================================================================
// Root application widget
// =============================================================================
class TrainHubApp extends ConsumerStatefulWidget {
const TrainHubApp({super.key});
@override
Widget build(BuildContext context) {
final appRouter = AppRouter();
ConsumerState<TrainHubApp> createState() => _TrainHubAppState();
}
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(
title: 'TrainHub',
theme: AppTheme.dark,
routerConfig: appRouter.config(),
routerConfig: _appRouter.config(),
debugShowCheckedModeBanner: false,
);
}

View File

@@ -1,6 +1,8 @@
import 'package:riverpod_annotation/riverpod_annotation.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/exercise_repository.dart';
import 'package:trainhub_flutter/presentation/analysis/analysis_state.dart';
part 'analysis_controller.g.dart';
@@ -8,10 +10,12 @@ part 'analysis_controller.g.dart';
@riverpod
class AnalysisController extends _$AnalysisController {
late AnalysisRepository _repo;
late ExerciseRepository _exerciseRepo;
@override
Future<AnalysisState> build() async {
_repo = getIt<AnalysisRepository>();
_exerciseRepo = getIt<ExerciseRepository>();
final sessions = await _repo.getAllSessions();
return AnalysisState(sessions: sessions);
}
@@ -35,10 +39,7 @@ class AnalysisController extends _$AnalysisController {
final annotations = await _repo.getAnnotations(id);
final current = state.valueOrNull ?? const AnalysisState();
state = AsyncValue.data(
current.copyWith(
activeSession: session,
annotations: annotations,
),
current.copyWith(activeSession: session, annotations: annotations),
);
}
@@ -77,6 +78,34 @@ class AnalysisController extends _$AnalysisController {
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 {
await _repo.deleteAnnotation(id);
final current = state.valueOrNull;

View File

@@ -7,7 +7,7 @@ part of 'analysis_controller.dart';
// **************************************************************************
String _$analysisControllerHash() =>
r'855d4ab55b8dc398e10c19d0ed245a60f104feed';
r'f6ad8fc731654c59a90d8ce64438b8490bbfa231';
/// See also [AnalysisController].
@ProviderFor(AnalysisController)

View File

@@ -1,11 +1,13 @@
import 'dart:io';
import 'dart:async';
import 'package:flutter/material.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/annotation.dart';
import 'package:trainhub_flutter/presentation/analysis/analysis_controller.dart';
import 'package:video_player/video_player.dart';
class AnalysisViewer extends ConsumerStatefulWidget {
final AnalysisSessionEntity session;
@@ -24,19 +26,34 @@ class AnalysisViewer extends ConsumerStatefulWidget {
}
class _AnalysisViewerState extends ConsumerState<AnalysisViewer> {
VideoPlayerController? _videoController;
late final Player _player;
late final VideoController _videoController;
bool _isPlaying = false;
double _currentPosition = 0.0;
double _totalDuration = 1.0;
bool _isInitialized = false;
bool _hasError = false;
// IN/OUT points
double? _inPoint;
double? _outPoint;
bool _isLooping = false;
late StreamSubscription<Duration> _positionSubscription;
late StreamSubscription<Duration> _durationSubscription;
late StreamSubscription<bool> _playingSubscription;
@override
void initState() {
super.initState();
_player = Player();
_videoController = VideoController(
_player,
configuration: const VideoControllerConfiguration(
enableHardwareAcceleration: false,
),
);
_setupStreams();
_initializeVideo();
}
@@ -48,66 +65,67 @@ class _AnalysisViewerState extends ConsumerState<AnalysisViewer> {
}
}
Future<void> _initializeVideo() async {
final path = widget.session.videoPath;
if (path == null) return;
_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!) {
void _setupStreams() {
_positionSubscription = _player.stream.position.listen((position) {
final posSeconds = position.inMilliseconds / 1000.0;
if (_isLooping && _outPoint != null && posSeconds >= _outPoint!) {
_seekTo(_inPoint ?? 0.0);
return;
}
setState(() => _currentPosition = posSeconds);
});
if (isPlaying != _isPlaying || (position - _currentPosition).abs() > 0.1) {
setState(() {
_isPlaying = isPlaying;
_currentPosition = position;
_durationSubscription = _player.stream.duration.listen((duration) {
final total = duration.inMilliseconds / 1000.0;
setState(() => _totalDuration = total > 0 ? total : 1.0);
});
_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
void dispose() {
_videoController?.removeListener(_videoListener);
_videoController?.dispose();
_positionSubscription.cancel();
_durationSubscription.cancel();
_playingSubscription.cancel();
_player.dispose();
super.dispose();
}
void _togglePlay() {
if (_videoController == null) return;
if (_videoController!.value.isPlaying) {
_videoController!.pause();
if (_isPlaying) {
_player.pause();
} else {
if (_isLooping && _outPoint != null && _currentPosition >= _outPoint!) {
_seekTo(_inPoint ?? 0.0);
}
_videoController!.play();
_player.play();
}
}
void _seekTo(double value) {
if (_videoController == null) return;
_videoController!.seekTo(Duration(milliseconds: (value * 1000).toInt()));
_player.seek(Duration(milliseconds: (value * 1000).toInt()));
}
void _setInPoint() {
@@ -137,9 +155,7 @@ class _AnalysisViewerState extends ConsumerState<AnalysisViewer> {
}
void _toggleLoop() {
setState(() {
_isLooping = !_isLooping;
});
setState(() => _isLooping = !_isLooping);
}
void _playRange(double start, double end) {
@@ -149,7 +165,7 @@ class _AnalysisViewerState extends ConsumerState<AnalysisViewer> {
_isLooping = true;
});
_seekTo(start);
_videoController?.play();
_player.play();
}
@override
@@ -158,23 +174,33 @@ class _AnalysisViewerState extends ConsumerState<AnalysisViewer> {
return Column(
children: [
// Video Area
Expanded(
flex: 3,
child: Container(
color: Colors.black,
alignment: Alignment.center,
child:
_videoController != null &&
_videoController!.value.isInitialized
child: _hasError
? const Center(
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(
alignment: Alignment.bottomCenter,
children: [
Center(
child: AspectRatio(
aspectRatio: _videoController!.value.aspectRatio,
child: VideoPlayer(_videoController!),
),
Video(
controller: _videoController,
controls: NoVideoControls,
fit: BoxFit.contain,
),
_buildTimelineControls(),
],
@@ -182,8 +208,6 @@ class _AnalysisViewerState extends ConsumerState<AnalysisViewer> {
: const Center(child: CircularProgressIndicator()),
),
),
// Annotations List
Expanded(
flex: 2,
child: Column(
@@ -197,33 +221,28 @@ class _AnalysisViewerState extends ConsumerState<AnalysisViewer> {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Annotations",
'Annotations',
style: Theme.of(context).textTheme.titleMedium,
),
ElevatedButton.icon(
onPressed: () {
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",
);
},
onPressed: () => _showAddAnnotationDialog(controller),
icon: const Icon(Icons.add),
label: const Text("Add from Selection"),
label: const Text('Add from Selection'),
),
],
),
),
const Divider(height: 1),
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) =>
const Divider(height: 1),
itemCount: widget.annotations.length,
@@ -233,39 +252,14 @@ class _AnalysisViewerState extends ConsumerState<AnalysisViewer> {
_currentPosition >= note.startTime &&
_currentPosition <= note.endTime;
return Container(
color: isActive
? Theme.of(
context,
).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: () =>
return _AnnotationListItem(
annotation: note,
isActive: isActive,
onPlay: () =>
_playRange(note.startTime, note.endTime),
tooltip: "Play Range",
),
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () =>
controller.deleteAnnotation(note.id),
),
],
),
),
onEdit: () =>
_showEditAnnotationDialog(controller, note),
onDelete: () => controller.deleteAnnotation(note.id),
);
},
),
@@ -289,56 +283,57 @@ class _AnalysisViewerState extends ConsumerState<AnalysisViewer> {
Widget _buildTimelineControls() {
return Container(
color: Colors.black.withOpacity(0.8),
color: Colors.black.withValues(alpha: 0.8),
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Timeline Visualization
SizedBox(
_buildAnnotationTimeline(),
_buildSeekSlider(),
_buildControlsRow(),
],
),
);
}
Widget _buildAnnotationTimeline() {
return SizedBox(
height: 20,
child: LayoutBuilder(
builder: (context, constraints) {
return Stack(
children: [
// Annotations
...widget.annotations.map((note) {
final left =
(note.startTime / _totalDuration) *
constraints.maxWidth;
(note.startTime / _totalDuration) * constraints.maxWidth;
final width =
((note.endTime - note.startTime) / _totalDuration) *
constraints.maxWidth;
return Positioned(
left: left,
width: width,
width: width.clamp(2.0, constraints.maxWidth),
top: 4,
bottom: 4,
child: Container(
decoration: BoxDecoration(
color: _parseColor(
note.color ?? 'grey',
).withOpacity(0.6),
).withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(2),
),
),
);
}),
// IN Point
if (_inPoint != null)
Positioned(
left:
(_inPoint! / _totalDuration) * constraints.maxWidth,
left: (_inPoint! / _totalDuration) * constraints.maxWidth,
top: 0,
bottom: 0,
child: Container(width: 2, color: Colors.green),
),
// OUT Point
if (_outPoint != null)
Positioned(
left:
(_outPoint! / _totalDuration) *
constraints.maxWidth,
left: (_outPoint! / _totalDuration) * constraints.maxWidth,
top: 0,
bottom: 0,
child: Container(width: 2, color: Colors.red),
@@ -347,10 +342,11 @@ class _AnalysisViewerState extends ConsumerState<AnalysisViewer> {
);
},
),
),
);
}
// Slider
SliderTheme(
Widget _buildSeekSlider() {
return SliderTheme(
data: SliderTheme.of(context).copyWith(
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 10),
@@ -360,14 +356,15 @@ class _AnalysisViewerState extends ConsumerState<AnalysisViewer> {
value: _currentPosition.clamp(0.0, _totalDuration),
min: 0.0,
max: _totalDuration,
onChanged: (value) => _seekTo(value),
onChanged: _seekTo,
activeColor: Theme.of(context).colorScheme.primary,
inactiveColor: Colors.grey,
),
),
);
}
// Controls Row
Padding(
Widget _buildControlsRow() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
@@ -377,12 +374,8 @@ class _AnalysisViewerState extends ConsumerState<AnalysisViewer> {
style: const TextStyle(color: Colors.white, fontSize: 12),
),
const SizedBox(width: 16),
IconButton(
icon: const Icon(
Icons.keyboard_arrow_left,
color: Colors.white,
),
icon: const Icon(Icons.keyboard_arrow_left, color: Colors.white),
onPressed: () => _seekTo(_currentPosition - 1),
),
IconButton(
@@ -393,24 +386,18 @@ class _AnalysisViewerState extends ConsumerState<AnalysisViewer> {
onPressed: _togglePlay,
),
IconButton(
icon: const Icon(
Icons.keyboard_arrow_right,
color: Colors.white,
),
icon: const Icon(Icons.keyboard_arrow_right, color: Colors.white),
onPressed: () => _seekTo(_currentPosition + 1),
),
const SizedBox(width: 16),
// IN/OUT Controls
IconButton(
icon: const Icon(Icons.login, color: Colors.green),
tooltip: "Set IN Point",
tooltip: 'Set IN Point',
onPressed: _setInPoint,
),
IconButton(
icon: const Icon(Icons.logout, color: Colors.red),
tooltip: "Set OUT Point",
tooltip: 'Set OUT Point',
onPressed: _setOutPoint,
),
IconButton(
@@ -420,19 +407,15 @@ class _AnalysisViewerState extends ConsumerState<AnalysisViewer> {
? Theme.of(context).colorScheme.primary
: Colors.white,
),
tooltip: "Toggle Loop",
tooltip: 'Toggle Loop',
onPressed: _toggleLoop,
),
if (_inPoint != null || _outPoint != null)
IconButton(
icon: const Icon(
Icons.cancel_outlined,
color: Colors.white,
),
tooltip: "Clear Points",
icon: const Icon(Icons.cancel_outlined, color: Colors.white),
tooltip: 'Clear Points',
onPressed: _clearPoints,
),
const Spacer(),
Text(
_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;
case '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:
return Colors.grey;
}

View File

@@ -7,7 +7,7 @@ part of 'calendar_controller.dart';
// **************************************************************************
String _$calendarControllerHash() =>
r'747a59ba47bf4d1b6a66e3bcc82276e4ad81eb1a';
r'd26afbe4d0a107aa6d0067e9b6f44e5ba079d37c';
/// See also [CalendarController].
@ProviderFor(CalendarController)

View File

@@ -1,4 +1,6 @@
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/domain/entities/program_week.dart';
import 'package:trainhub_flutter/domain/entities/program_workout.dart';
@@ -31,12 +33,12 @@ class ProgramWeekView extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Week ${week.position}",
'Week ${week.position}',
style: Theme.of(context).textTheme.headlineSmall,
),
const Divider(),
SizedBox(
height: 500, // Fixed height for the week grid, or make it dynamic
height: 500,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: List.generate(7, (dayIndex) {
@@ -46,14 +48,52 @@ class ProgramWeekView extends StatelessWidget {
.toList();
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(
border: dayIndex < 6
? const Border(
right: BorderSide(
color: Colors.grey,
width: 0.5,
),
right: BorderSide(color: Colors.grey, width: 0.5),
)
: null,
color: dayIndex % 2 == 0
@@ -66,14 +106,9 @@ class ProgramWeekView extends StatelessWidget {
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHigh,
color: Theme.of(context).colorScheme.surfaceContainerHigh,
border: const Border(
bottom: BorderSide(
color: Colors.grey,
width: 0.5,
),
bottom: BorderSide(color: Colors.grey, width: 0.5),
),
),
child: Row(
@@ -81,18 +116,12 @@ class ProgramWeekView extends StatelessWidget {
children: [
Text(
_getDayName(dayNum),
style: const TextStyle(
fontWeight: FontWeight.bold,
),
style: const TextStyle(fontWeight: FontWeight.bold),
),
InkWell(
onTap: () =>
_showAddWorkoutSheet(context, dayNum),
onTap: () => _showAddWorkoutSheet(context),
borderRadius: BorderRadius.circular(16),
child: const Icon(
Icons.add_circle_outline,
size: 20,
),
child: const Icon(Icons.add_circle_outline, size: 20),
),
],
),
@@ -107,7 +136,7 @@ class ProgramWeekView extends StatelessWidget {
padding: const EdgeInsets.only(top: 16.0),
child: Center(
child: Text(
"Rest",
'Rest',
style: TextStyle(
color: Theme.of(
context,
@@ -119,39 +148,9 @@ class ProgramWeekView extends StatelessWidget {
)
else
...dayWorkouts.map(
(w) => Card(
margin: const EdgeInsets.only(
bottom: 4,
),
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
},
),
(workout) => _WorkoutCard(
workout: workout,
onDelete: () => onDeleteWorkout(workout.id),
),
),
],
@@ -160,64 +159,211 @@ class ProgramWeekView extends StatelessWidget {
),
],
),
),
);
}),
),
),
],
),
),
);
}
String _getDayName(int day) {
const days = [
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
'Sunday',
'Mon',
'Tue',
'Wed',
'Thu',
'Fri',
'Sat',
'Sun',
];
if (day >= 1 && day <= 7) return days[day - 1];
return 'Day $day';
}
void _showAddWorkoutSheet(BuildContext context, int dayNum) {
void _showAddWorkoutSheet(BuildContext context) {
showModalBottomSheet(
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),
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.6,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Text(
"Select Training Plan",
'Add to Schedule',
style: Theme.of(context).textTheme.titleMedium,
),
),
const Divider(),
if (availablePlans.isEmpty)
const Padding(
TabBar(
controller: _tabController,
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),
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),
title: Text(plan.name),
subtitle: Text("${plan.totalExercises} exercises"),
subtitle: Text('${plan.totalExercises} exercises'),
onTap: () {
final newWorkout = ProgramWorkoutEntity(
id: IdGenerator.generate(),
@@ -227,18 +373,92 @@ class ProgramWeekView extends StatelessWidget {
type: 'workout',
name: plan.name,
refId: plan.id,
description: "${plan.sections.length} sections",
description: '${plan.sections.length} sections',
completed: false,
);
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(),
],
),
),
);
}
}

View File

@@ -1,17 +1,43 @@
import 'dart:convert';
import 'package:dio/dio.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/note_repository.dart';
import 'package:trainhub_flutter/data/services/ai_process_manager.dart';
import 'package:trainhub_flutter/injection.dart';
import 'package:trainhub_flutter/presentation/chat/chat_state.dart';
import 'package:uuid/uuid.dart';
part 'chat_controller.g.dart';
@riverpod
AiProcessManager aiProcessManager(AiProcessManagerRef ref) {
final manager = getIt<AiProcessManager>();
manager.addListener(() => ref.notifyListeners());
return manager;
}
@riverpod
class ChatController extends _$ChatController {
late ChatRepository _repo;
late NoteRepository _noteRepo;
final _dio = Dio(
BaseOptions(
connectTimeout: AiConstants.serverConnectTimeout,
receiveTimeout: AiConstants.serverReceiveTimeout,
),
);
@override
Future<ChatState> build() async {
_repo = getIt<ChatRepository>();
_noteRepo = getIt<NoteRepository>();
final aiManager = ref.read(aiProcessManagerProvider);
if (aiManager.status == AiServerStatus.offline) {
aiManager.startServers();
}
final sessions = await _repo.getAllSessions();
return ChatState(sessions: sessions);
}
@@ -51,17 +77,26 @@ class ChatController extends _$ChatController {
Future<void> sendMessage(String content) async {
final current = state.valueOrNull;
if (current == null) return;
String sessionId;
if (current.activeSession == null) {
final sessionId = await _resolveSession(current, content);
await _persistUserMessage(sessionId, content);
final contextChunks = await _searchKnowledgeBase(content);
final systemPrompt = _buildSystemPrompt(contextChunks);
final history = _buildHistory();
final fullAiResponse = await _streamResponse(systemPrompt, history);
await _persistAssistantResponse(sessionId, content, fullAiResponse);
}
Future<String> _resolveSession(ChatState current, String content) async {
if (current.activeSession != null) return current.activeSession!.id;
final session = await _repo.createSession();
sessionId = session.id;
final sessions = await _repo.getAllSessions();
state = AsyncValue.data(
current.copyWith(sessions: sessions, activeSession: session),
);
} else {
sessionId = current.activeSession!.id;
return session.id;
}
Future<void> _persistUserMessage(String sessionId, String content) async {
await _repo.addMessage(
sessionId: sessionId,
role: 'user',
@@ -69,20 +104,143 @@ class ChatController extends _$ChatController {
);
final messagesAfterUser = await _repo.getMessages(sessionId);
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(
sessionId: sessionId,
role: 'assistant',
content: response,
content: aiResponse,
);
final messagesAfterAi = await _repo.getMessages(sessionId);
if (messagesAfterAi.length <= 2) {
final title = content.length > 30
? '${content.substring(0, 30)}...'
: content;
final title = userContent.length > 30
? '${userContent.substring(0, 30)}'
: userContent;
await _repo.updateSessionTitle(sessionId, title);
}
final sessions = await _repo.getAllSessions();
@@ -90,20 +248,59 @@ class ChatController extends _$ChatController {
state.valueOrNull!.copyWith(
messages: messagesAfterAi,
isTyping: false,
streamingContent: null,
thinkingSteps: [],
sessions: sessions,
),
);
}
String _getMockResponse(String input) {
final String lower = input.toLowerCase();
if (lower.contains('plan') || lower.contains('program')) {
return "I can help you design a training plan! What are your goals? Strength, hypertrophy, or endurance?";
} else if (lower.contains('squat') || lower.contains('bench')) {
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')) {
return "Nutrition is key. Aim for 1.6-2.2g of protein per kg of bodyweight if you're training hard.";
ThinkingStep _createStep(String title) {
final step = ThinkingStep(
id: const Uuid().v4(),
title: title,
status: ThinkingStepStatus.pending,
);
final current = state.valueOrNull;
if (current != null) {
state = AsyncValue.data(
current.copyWith(thinkingSteps: [...current.thinkingSteps, step]),
);
}
return "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.';
}
}

View File

@@ -6,7 +6,24 @@ part of 'chat_controller.dart';
// 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].
@ProviderFor(ChatController)

View File

@@ -1,14 +1,19 @@
import 'dart:math' as math;
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
import 'package:trainhub_flutter/domain/entities/chat_message.dart';
import 'package:trainhub_flutter/data/services/ai_process_manager.dart';
import 'package:trainhub_flutter/domain/entities/chat_session.dart';
import 'package:trainhub_flutter/presentation/chat/chat_controller.dart';
import 'package:trainhub_flutter/presentation/chat/chat_state.dart';
import 'package:trainhub_flutter/presentation/chat/widgets/message_bubble.dart';
import 'package:trainhub_flutter/presentation/chat/widgets/missing_models_state.dart';
import 'package:trainhub_flutter/presentation/chat/widgets/new_chat_button.dart';
import 'package:trainhub_flutter/presentation/chat/widgets/typing_bubble.dart';
import 'package:trainhub_flutter/presentation/chat/widgets/typing_indicator.dart';
import 'package:trainhub_flutter/presentation/settings/ai_model_settings_controller.dart';
@RoutePage()
class ChatPage extends ConsumerStatefulWidget {
@@ -46,12 +51,11 @@ class _ChatPageState extends ConsumerState<ChatPage> {
void _sendMessage(ChatController controller) {
final text = _inputController.text.trim();
if (text.isNotEmpty) {
if (text.isEmpty) return;
controller.sendMessage(text);
_inputController.clear();
_inputFocusNode.requestFocus();
}
}
String _formatTimestamp(String timestamp) {
try {
@@ -72,39 +76,39 @@ class _ChatPageState extends ConsumerState<ChatPage> {
@override
Widget build(BuildContext context) {
final modelsValidated =
ref.watch(aiModelSettingsControllerProvider).areModelsValidated;
final state = ref.watch(chatControllerProvider);
final controller = ref.read(chatControllerProvider.notifier);
ref.listen(chatControllerProvider, (prev, next) {
if (next.hasValue &&
(prev?.value?.messages.length ?? 0) <
next.value!.messages.length) {
_scrollToBottom();
}
if (next.hasValue && next.value!.isTyping && !(prev?.value?.isTyping ?? false)) {
if (next.hasValue &&
next.value!.isTyping &&
!(prev?.value?.isTyping ?? false)) {
_scrollToBottom();
}
});
if (!modelsValidated) {
return const Scaffold(
backgroundColor: AppColors.surface,
body: MissingModelsState(),
);
}
return Scaffold(
backgroundColor: AppColors.surface,
body: Row(
children: [
// --- Side Panel ---
_buildSidePanel(state, controller),
// --- Main Chat Area ---
Expanded(
child: _buildChatArea(state, controller),
),
Expanded(child: _buildChatArea(state, controller)),
],
),
);
}
// ---------------------------------------------------------------------------
// Side Panel
// ---------------------------------------------------------------------------
Widget _buildSidePanel(
AsyncValue<ChatState> asyncState,
ChatController controller,
@@ -113,31 +117,25 @@ class _ChatPageState extends ConsumerState<ChatPage> {
width: 250,
decoration: const BoxDecoration(
color: AppColors.surfaceContainer,
border: Border(
right: BorderSide(color: AppColors.border, width: 1),
),
border: Border(right: BorderSide(color: AppColors.border, width: 1)),
),
child: Column(
children: [
// New Chat button
Padding(
padding: const EdgeInsets.all(UIConstants.spacing12),
child: SizedBox(
width: double.infinity,
child: _NewChatButton(onPressed: controller.createSession),
child: NewChatButton(onPressed: controller.createSession),
),
),
const Divider(height: 1, color: AppColors.border),
// Session list
Expanded(
child: asyncState.when(
data: (data) {
if (data.sessions.isEmpty) {
return Center(
return const Center(
child: Padding(
padding: const EdgeInsets.all(UIConstants.spacing24),
padding: EdgeInsets.all(UIConstants.spacing24),
child: Text(
'No conversations yet',
style: TextStyle(
@@ -155,17 +153,15 @@ class _ChatPageState extends ConsumerState<ChatPage> {
itemCount: data.sessions.length,
itemBuilder: (context, index) {
final session = data.sessions[index];
final isActive =
session.id == data.activeSession?.id;
return _buildSessionTile(
session: session,
isActive: isActive,
isActive: session.id == data.activeSession?.id,
controller: controller,
);
},
);
},
error: (_, __) => Center(
error: (_, __) => const Center(
child: Text(
'Error loading sessions',
style: TextStyle(color: AppColors.textMuted, fontSize: 13),
@@ -194,13 +190,10 @@ class _ChatPageState extends ConsumerState<ChatPage> {
required ChatController controller,
}) {
final isHovered = _hoveredSessionId == session.id;
return MouseRegion(
onEnter: (_) => setState(() => _hoveredSessionId = session.id),
onExit: (_) => setState(() {
if (_hoveredSessionId == session.id) {
_hoveredSessionId = null;
}
if (_hoveredSessionId == session.id) _hoveredSessionId = null;
}),
child: GestureDetector(
onTap: () => controller.loadSession(session.id),
@@ -220,11 +213,11 @@ class _ChatPageState extends ConsumerState<ChatPage> {
: isHovered
? AppColors.zinc800.withValues(alpha: 0.6)
: Colors.transparent,
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
borderRadius:
BorderRadius.circular(UIConstants.smallBorderRadius),
border: isActive
? Border.all(
color: AppColors.accent.withValues(alpha: 0.3),
width: 1,
)
: null,
),
@@ -251,7 +244,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
),
),
),
// Delete button appears on hover
AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: isHovered ? 1.0 : 0.0,
@@ -268,7 +260,8 @@ class _ChatPageState extends ConsumerState<ChatPage> {
Icons.delete_outline_rounded,
color: AppColors.textMuted,
),
onPressed: () => controller.deleteSession(session.id),
onPressed: () =>
controller.deleteSession(session.id),
tooltip: 'Delete',
),
),
@@ -281,22 +274,16 @@ class _ChatPageState extends ConsumerState<ChatPage> {
);
}
// ---------------------------------------------------------------------------
// Chat Area
// ---------------------------------------------------------------------------
Widget _buildChatArea(
AsyncValue<ChatState> asyncState,
ChatController controller,
) {
return Column(
children: [
// Messages
Expanded(
child: asyncState.when(
data: (data) {
if (data.messages.isEmpty) {
return _buildEmptyState();
}
if (data.messages.isEmpty) return _buildEmptyState();
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(
@@ -307,26 +294,34 @@ class _ChatPageState extends ConsumerState<ChatPage> {
data.messages.length + (data.isTyping ? 1 : 0),
itemBuilder: (context, index) {
if (index == data.messages.length) {
return const _TypingIndicator();
if (data.thinkingSteps.isNotEmpty ||
(data.streamingContent != null &&
data.streamingContent!.isNotEmpty)) {
return TypingBubble(
thinkingSteps: data.thinkingSteps,
streamingContent: data.streamingContent,
);
}
return const TypingIndicator();
}
final msg = data.messages[index];
return _MessageBubble(
return MessageBubble(
message: msg,
formattedTime: _formatTimestamp(msg.createdAt),
);
},
);
},
error: (e, _) => Center(
error: (e, _) => const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icon(
Icons.error_outline_rounded,
color: AppColors.destructive,
size: 40,
),
const SizedBox(height: UIConstants.spacing12),
SizedBox(height: UIConstants.spacing12),
Text(
'Something went wrong',
style: TextStyle(
@@ -349,8 +344,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
),
),
),
// Input area
_buildInputBar(asyncState, controller),
],
);
@@ -386,37 +379,100 @@ class _ChatPageState extends ConsumerState<ChatPage> {
const SizedBox(height: UIConstants.spacing8),
const Text(
'Start a conversation to get personalized advice.',
style: TextStyle(
color: AppColors.textMuted,
fontSize: 13,
),
style: TextStyle(color: AppColors.textMuted, fontSize: 13),
),
],
),
);
}
// ---------------------------------------------------------------------------
// Input Bar
// ---------------------------------------------------------------------------
Widget _buildInputBar(
AsyncValue<ChatState> asyncState,
ChatController controller,
) {
final aiManager = ref.watch(aiProcessManagerProvider);
final isTyping = asyncState.valueOrNull?.isTyping ?? false;
final isStarting = aiManager.status == AiServerStatus.starting;
final isError = aiManager.status == AiServerStatus.error;
final isReady = aiManager.status == AiServerStatus.ready;
String? statusMessage;
Color statusColor = AppColors.textMuted;
if (isStarting) {
statusMessage =
'Starting AI inference server (this may take a moment)...';
statusColor = AppColors.info;
} else if (isError) {
statusMessage =
'AI Server Error: ${aiManager.errorMessage ?? "Unknown error"}';
statusColor = AppColors.destructive;
} else if (!isReady) {
statusMessage = 'AI Server offline. Reconnecting...';
}
return Container(
decoration: const BoxDecoration(
color: AppColors.surface,
border: Border(
top: BorderSide(color: AppColors.border, width: 1),
),
border: Border(top: BorderSide(color: AppColors.border, width: 1)),
),
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing16,
vertical: UIConstants.spacing12,
),
child: Column(
children: [
if (statusMessage != null)
Padding(
padding: const EdgeInsets.only(bottom: UIConstants.spacing8),
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,
children: [
Expanded(
@@ -425,20 +481,26 @@ class _ChatPageState extends ConsumerState<ChatPage> {
color: AppColors.surfaceContainer,
borderRadius:
BorderRadius.circular(UIConstants.borderRadius),
border: Border.all(color: AppColors.border, width: 1),
border:
Border.all(color: AppColors.border, width: 1),
),
child: TextField(
controller: _inputController,
focusNode: _inputFocusNode,
style: const TextStyle(
color: AppColors.textPrimary,
enabled: isReady,
style: TextStyle(
color: isReady
? AppColors.textPrimary
: AppColors.textMuted,
fontSize: 14,
),
maxLines: 4,
minLines: 1,
decoration: InputDecoration(
hintText: 'Type a message...',
hintStyle: TextStyle(
hintText: isReady
? 'Type a message...'
: 'Waiting for AI...',
hintStyle: const TextStyle(
color: AppColors.textMuted,
fontSize: 14,
),
@@ -449,8 +511,11 @@ class _ChatPageState extends ConsumerState<ChatPage> {
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
disabledBorder: InputBorder.none,
),
onSubmitted: (_) => _sendMessage(controller),
onSubmitted: isReady
? (_) => _sendMessage(controller)
: null,
textInputAction: TextInputAction.send,
),
),
@@ -460,16 +525,22 @@ class _ChatPageState extends ConsumerState<ChatPage> {
width: 40,
height: 40,
child: Material(
color: isTyping ? AppColors.zinc700 : AppColors.accent,
color: (isTyping || !isReady)
? AppColors.zinc700
: AppColors.accent,
borderRadius:
BorderRadius.circular(UIConstants.borderRadius),
child: InkWell(
borderRadius:
BorderRadius.circular(UIConstants.borderRadius),
onTap: isTyping ? null : () => _sendMessage(controller),
onTap: (isTyping || !isReady)
? null
: () => _sendMessage(controller),
child: Icon(
Icons.arrow_upward_rounded,
color: isTyping ? AppColors.textMuted : AppColors.zinc950,
color: (isTyping || !isReady)
? AppColors.textMuted
: AppColors.zinc950,
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,
),
),
),
);
}),
);
},
),
),
],
),
);

View File

@@ -4,6 +4,18 @@ import 'package:trainhub_flutter/domain/entities/chat_message.dart';
part 'chat_state.freezed.dart';
enum ThinkingStepStatus { pending, running, completed, error }
@freezed
class ThinkingStep with _$ThinkingStep {
const factory ThinkingStep({
required String id,
required String title,
@Default(ThinkingStepStatus.running) ThinkingStepStatus status,
String? details,
}) = _ThinkingStep;
}
@freezed
class ChatState with _$ChatState {
const factory ChatState({
@@ -11,5 +23,7 @@ class ChatState with _$ChatState {
ChatSessionEntity? activeSession,
@Default([]) List<ChatMessageEntity> messages,
@Default(false) bool isTyping,
@Default([]) List<ThinkingStep> thinkingSteps,
String? streamingContent,
}) = _ChatState;
}

View File

@@ -15,12 +15,219 @@ final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
);
/// @nodoc
mixin _$ThinkingStep {
String get id => throw _privateConstructorUsedError;
String get title => throw _privateConstructorUsedError;
ThinkingStepStatus get status => throw _privateConstructorUsedError;
String? get details => throw _privateConstructorUsedError;
/// Create a copy of ThinkingStep
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$ThinkingStepCopyWith<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
mixin _$ChatState {
List<ChatSessionEntity> get sessions => throw _privateConstructorUsedError;
ChatSessionEntity? get activeSession => throw _privateConstructorUsedError;
List<ChatMessageEntity> get messages => throw _privateConstructorUsedError;
bool get isTyping => throw _privateConstructorUsedError;
List<ThinkingStep> get thinkingSteps => throw _privateConstructorUsedError;
String? get streamingContent => throw _privateConstructorUsedError;
/// Create a copy of ChatState
/// with the given fields replaced by the non-null parameter values.
@@ -39,6 +246,8 @@ abstract class $ChatStateCopyWith<$Res> {
ChatSessionEntity? activeSession,
List<ChatMessageEntity> messages,
bool isTyping,
List<ThinkingStep> thinkingSteps,
String? streamingContent,
});
$ChatSessionEntityCopyWith<$Res>? get activeSession;
@@ -63,6 +272,8 @@ class _$ChatStateCopyWithImpl<$Res, $Val extends ChatState>
Object? activeSession = freezed,
Object? messages = null,
Object? isTyping = null,
Object? thinkingSteps = null,
Object? streamingContent = freezed,
}) {
return _then(
_value.copyWith(
@@ -82,6 +293,14 @@ class _$ChatStateCopyWithImpl<$Res, $Val extends ChatState>
? _value.isTyping
: isTyping // ignore: cast_nullable_to_non_nullable
as bool,
thinkingSteps: null == thinkingSteps
? _value.thinkingSteps
: thinkingSteps // ignore: cast_nullable_to_non_nullable
as List<ThinkingStep>,
streamingContent: freezed == streamingContent
? _value.streamingContent
: streamingContent // ignore: cast_nullable_to_non_nullable
as String?,
)
as $Val,
);
@@ -116,6 +335,8 @@ abstract class _$$ChatStateImplCopyWith<$Res>
ChatSessionEntity? activeSession,
List<ChatMessageEntity> messages,
bool isTyping,
List<ThinkingStep> thinkingSteps,
String? streamingContent,
});
@override
@@ -140,6 +361,8 @@ class __$$ChatStateImplCopyWithImpl<$Res>
Object? activeSession = freezed,
Object? messages = null,
Object? isTyping = null,
Object? thinkingSteps = null,
Object? streamingContent = freezed,
}) {
return _then(
_$ChatStateImpl(
@@ -159,6 +382,14 @@ class __$$ChatStateImplCopyWithImpl<$Res>
? _value.isTyping
: isTyping // ignore: cast_nullable_to_non_nullable
as bool,
thinkingSteps: null == thinkingSteps
? _value._thinkingSteps
: thinkingSteps // ignore: cast_nullable_to_non_nullable
as List<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,
final List<ChatMessageEntity> messages = const [],
this.isTyping = false,
final List<ThinkingStep> thinkingSteps = const [],
this.streamingContent,
}) : _sessions = sessions,
_messages = messages;
_messages = messages,
_thinkingSteps = thinkingSteps;
final List<ChatSessionEntity> _sessions;
@override
@@ -198,10 +432,21 @@ class _$ChatStateImpl implements _ChatState {
@override
@JsonKey()
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
String toString() {
return 'ChatState(sessions: $sessions, activeSession: $activeSession, messages: $messages, isTyping: $isTyping)';
return 'ChatState(sessions: $sessions, activeSession: $activeSession, messages: $messages, isTyping: $isTyping, thinkingSteps: $thinkingSteps, streamingContent: $streamingContent)';
}
@override
@@ -214,7 +459,13 @@ class _$ChatStateImpl implements _ChatState {
other.activeSession == activeSession) &&
const DeepCollectionEquality().equals(other._messages, _messages) &&
(identical(other.isTyping, isTyping) ||
other.isTyping == isTyping));
other.isTyping == isTyping) &&
const DeepCollectionEquality().equals(
other._thinkingSteps,
_thinkingSteps,
) &&
(identical(other.streamingContent, streamingContent) ||
other.streamingContent == streamingContent));
}
@override
@@ -224,6 +475,8 @@ class _$ChatStateImpl implements _ChatState {
activeSession,
const DeepCollectionEquality().hash(_messages),
isTyping,
const DeepCollectionEquality().hash(_thinkingSteps),
streamingContent,
);
/// Create a copy of ChatState
@@ -241,6 +494,8 @@ abstract class _ChatState implements ChatState {
final ChatSessionEntity? activeSession,
final List<ChatMessageEntity> messages,
final bool isTyping,
final List<ThinkingStep> thinkingSteps,
final String? streamingContent,
}) = _$ChatStateImpl;
@override
@@ -251,6 +506,10 @@ abstract class _ChatState implements ChatState {
List<ChatMessageEntity> get messages;
@override
bool get isTyping;
@override
List<ThinkingStep> get thinkingSteps;
@override
String? get streamingContent;
/// Create a copy of ChatState
/// with the given fields replaced by the non-null parameter values.

View 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),
);
}
}

View 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,
),
),
],
),
),
),
),
),
);
}
}

View 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,
),
),
],
),
),
),
),
),
);
}
}

View 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,
),
),
),
),
],
),
),
],
),
);
}
}

View 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(),
],
),
),
],
),
);
}
}

View 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,
),
);
}
}

View File

@@ -4,11 +4,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
import 'package:trainhub_flutter/domain/entities/program_workout.dart';
import 'package:trainhub_flutter/presentation/common/widgets/app_empty_state.dart';
import 'package:trainhub_flutter/presentation/common/widgets/app_stat_card.dart';
import 'package:trainhub_flutter/presentation/home/home_controller.dart';
import 'package:trainhub_flutter/presentation/home/home_state.dart';
import 'package:trainhub_flutter/presentation/home/widgets/next_workout_banner.dart';
import 'package:trainhub_flutter/presentation/home/widgets/quick_actions_row.dart';
import 'package:trainhub_flutter/presentation/home/widgets/recent_activity_section.dart';
import 'package:trainhub_flutter/presentation/home/widgets/stat_cards_row.dart';
import 'package:trainhub_flutter/presentation/home/widgets/welcome_header.dart';
@RoutePage()
class HomePage extends ConsumerWidget {
@@ -20,13 +23,42 @@ class HomePage extends ConsumerWidget {
return asyncState.when(
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(
padding: const EdgeInsets.all(UIConstants.pagePadding),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
const Icon(
Icons.error_outline,
size: 48,
color: AppColors.destructive,
@@ -42,7 +74,7 @@ class HomePage extends ConsumerWidget {
),
const SizedBox(height: UIConstants.spacing8),
Text(
'$e',
'$error',
style: GoogleFonts.inter(
fontSize: 13,
color: AppColors.textMuted,
@@ -51,39 +83,22 @@ class HomePage extends ConsumerWidget {
),
const SizedBox(height: UIConstants.spacing24),
FilledButton.icon(
onPressed: () =>
ref.read(homeControllerProvider.notifier).refresh(),
onPressed: onRetry,
icon: const Icon(Icons.refresh, size: 18),
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 {
final HomeState data;
const _HomeContent({required this.data});
final HomeState data;
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
@@ -91,609 +106,22 @@ class _HomeContent extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// -- Welcome header --
_WelcomeHeader(programName: data.activeProgramName!),
WelcomeHeader(programName: data.activeProgramName!),
const SizedBox(height: UIConstants.spacing24),
// -- Stat cards row --
_StatCardsRow(
StatCardsRow(
completed: data.completedWorkouts,
total: data.totalWorkouts,
),
const SizedBox(height: UIConstants.spacing24),
// -- Next workout banner --
if (data.nextWorkoutName != null) ...[
_NextWorkoutBanner(workoutName: data.nextWorkoutName!),
NextWorkoutBanner(workoutName: data.nextWorkoutName!),
const SizedBox(height: UIConstants.spacing24),
],
// -- Quick actions --
_QuickActionsRow(),
const QuickActionsRow(),
const SizedBox(height: UIConstants.spacing32),
// -- Recent activity --
_RecentActivitySection(activity: data.recentActivity),
RecentActivitySection(activity: data.recentActivity),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Welcome header
// ---------------------------------------------------------------------------
class _WelcomeHeader extends StatelessWidget {
final String programName;
const _WelcomeHeader({required this.programName});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Welcome back',
style: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.textMuted,
),
),
const SizedBox(height: UIConstants.spacing4),
Row(
children: [
Expanded(
child: Text(
programName,
style: GoogleFonts.inter(
fontSize: 28,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing12,
vertical: 6,
),
decoration: BoxDecoration(
color: AppColors.accentMuted,
borderRadius: UIConstants.smallCardBorderRadius,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.fitness_center,
size: 14,
color: AppColors.accent,
),
const SizedBox(width: 6),
Text(
'Active Program',
style: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.accent,
),
),
],
),
),
],
),
],
);
}
}
// ---------------------------------------------------------------------------
// Stat cards row
// ---------------------------------------------------------------------------
class _StatCardsRow extends StatelessWidget {
final int completed;
final int total;
const _StatCardsRow({required this.completed, required this.total});
@override
Widget build(BuildContext context) {
final progress = total == 0 ? 0 : (completed / total * 100).round();
return Row(
children: [
Expanded(
child: AppStatCard(
title: 'Completed',
value: '$completed',
icon: Icons.check_circle_outline,
accentColor: AppColors.success,
),
),
const SizedBox(width: UIConstants.spacing16),
Expanded(
child: AppStatCard(
title: 'Total Workouts',
value: '$total',
icon: Icons.list_alt,
accentColor: AppColors.info,
),
),
const SizedBox(width: UIConstants.spacing16),
Expanded(
child: AppStatCard(
title: 'Progress',
value: '$progress%',
icon: Icons.trending_up,
accentColor: AppColors.purple,
),
),
],
);
}
}
// ---------------------------------------------------------------------------
// Next workout banner
// ---------------------------------------------------------------------------
class _NextWorkoutBanner extends StatelessWidget {
final String workoutName;
const _NextWorkoutBanner({required this.workoutName});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(UIConstants.cardPadding),
decoration: BoxDecoration(
color: AppColors.surfaceContainer,
borderRadius: UIConstants.cardBorderRadius,
border: Border.all(color: AppColors.border),
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColors.accentMuted,
borderRadius: UIConstants.smallCardBorderRadius,
),
child: const Icon(
Icons.play_arrow_rounded,
color: AppColors.accent,
size: 22,
),
),
const SizedBox(width: UIConstants.spacing12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Up Next',
style: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.textMuted,
),
),
const SizedBox(height: 2),
Text(
workoutName,
style: GoogleFonts.inter(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
],
),
),
Icon(
Icons.chevron_right,
color: AppColors.textMuted,
size: 20,
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Quick actions row
// ---------------------------------------------------------------------------
class _QuickActionsRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Quick Actions',
style: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(height: UIConstants.spacing12),
Row(
children: [
_QuickActionButton(
icon: Icons.play_arrow_rounded,
label: 'New Workout',
color: AppColors.accent,
onTap: () {
AutoTabsRouter.of(context).setActiveIndex(1);
},
),
const SizedBox(width: UIConstants.spacing12),
_QuickActionButton(
icon: Icons.description_outlined,
label: 'View Plans',
color: AppColors.info,
onTap: () {
AutoTabsRouter.of(context).setActiveIndex(1);
},
),
const SizedBox(width: UIConstants.spacing12),
_QuickActionButton(
icon: Icons.chat_bubble_outline,
label: 'AI Chat',
color: AppColors.purple,
onTap: () {
AutoTabsRouter.of(context).setActiveIndex(4);
},
),
],
),
],
);
}
}
class _QuickActionButton extends StatefulWidget {
final IconData icon;
final String label;
final Color color;
final VoidCallback onTap;
const _QuickActionButton({
required this.icon,
required this.label,
required this.color,
required this.onTap,
});
@override
State<_QuickActionButton> createState() => _QuickActionButtonState();
}
class _QuickActionButtonState extends State<_QuickActionButton> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: AnimatedContainer(
duration: UIConstants.animationDuration,
decoration: BoxDecoration(
color: _isHovered
? widget.color.withValues(alpha: 0.08)
: Colors.transparent,
borderRadius: UIConstants.smallCardBorderRadius,
border: Border.all(
color: _isHovered ? widget.color.withValues(alpha: 0.4) : AppColors.border,
),
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: widget.onTap,
borderRadius: UIConstants.smallCardBorderRadius,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing24,
vertical: UIConstants.spacing12,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
widget.icon,
size: 18,
color: widget.color,
),
const SizedBox(width: UIConstants.spacing8),
Text(
widget.label,
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w500,
color: _isHovered
? widget.color
: AppColors.textSecondary,
),
),
],
),
),
),
),
),
);
}
}
// ---------------------------------------------------------------------------
// Recent activity section
// ---------------------------------------------------------------------------
class _RecentActivitySection extends StatelessWidget {
final List<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,
),
],
),
),
);
}
}

View 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,
),
],
),
);
}
}

View 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,
),
),
],
),
),
),
),
),
);
}
}

View 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,
);
}
}

View 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,
),
),
],
);
}
}

View 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,
),
),
],
),
),
],
),
],
);
}
}

View 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;
}

View File

@@ -3,7 +3,6 @@ import 'package:trainhub_flutter/core/utils/id_generator.dart';
import 'package:trainhub_flutter/injection.dart';
import 'package:trainhub_flutter/domain/entities/exercise.dart';
import 'package:trainhub_flutter/domain/entities/training_exercise.dart';
import 'package:trainhub_flutter/domain/entities/training_plan.dart';
import 'package:trainhub_flutter/domain/entities/training_section.dart';
import 'package:trainhub_flutter/domain/repositories/exercise_repository.dart';
import 'package:trainhub_flutter/domain/repositories/training_plan_repository.dart';
@@ -14,14 +13,15 @@ part 'plan_editor_controller.g.dart';
@riverpod
class PlanEditorController extends _$PlanEditorController {
late TrainingPlanRepository _planRepo;
late ExerciseRepository _exerciseRepo;
@override
Future<PlanEditorState> build(String planId) async {
_planRepo = getIt<TrainingPlanRepository>();
final ExerciseRepository exerciseRepo = getIt<ExerciseRepository>();
_exerciseRepo = getIt<ExerciseRepository>();
final plan = await _planRepo.getById(planId);
final exercises = await exerciseRepo.getAll();
final exercises = await _exerciseRepo.getAll();
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) {
final current = state.valueOrNull;
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(
int sectionIndex,
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 {
final current = state.valueOrNull;
if (current == null) return;

View File

@@ -7,7 +7,7 @@ part of 'plan_editor_controller.dart';
// **************************************************************************
String _$planEditorControllerHash() =>
r'4045493829126f28b3a58695b68ade53519c1412';
r'6c6c2f74725e250bd41401cab12c1a62306d10ea';
/// Copied from Dart SDK
class _SystemHash {

View File

@@ -1,6 +1,9 @@
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/plan_editor/plan_editor_controller.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);
return Scaffold(
appBar: AppBar(
title: state.when(
backgroundColor: AppColors.surface,
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(
controller: TextEditingController(text: data.plan.name)
..selection = TextSelection.fromPosition(
@@ -25,46 +79,105 @@ class PlanEditorPage extends ConsumerWidget {
),
decoration: const InputDecoration(
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,
),
error: (_, __) => const Text('Error'),
loading: () => const Text('Loading...'),
orElse: () => Text(
'Loading...',
style: GoogleFonts.inter(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.textMuted,
),
actions: [
),
),
),
// Unsaved changes badge + save button
state.maybeWhen(
data: (data) => IconButton(
icon: Icon(
Icons.save,
color: data.isDirty ? Theme.of(context).primaryColor : null,
data: (data) => data.isDirty
? Row(
children: [
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(),
),
],
),
body: state.when(
data: (data) => ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: data.plan.sections.length + 1,
itemBuilder: (context, index) {
if (index == data.plan.sections.length) {
return Center(
),
// --- Body ---
Expanded(
child: state.when(
data: (data) => ReorderableListView.builder(
padding: const EdgeInsets.all(UIConstants.spacing24),
onReorder: controller.reorderSection,
footer: Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: ElevatedButton.icon(
padding: const EdgeInsets.symmetric(
vertical: UIConstants.spacing16,
),
child: OutlinedButton.icon(
onPressed: controller.addSection,
icon: const Icon(Icons.add),
icon: const Icon(Icons.add, size: 16),
label: const Text('Add Section'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.textSecondary,
side: const BorderSide(color: AppColors.border),
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing24,
vertical: UIConstants.spacing12,
),
),
);
}
),
),
),
itemCount: data.plan.sections.length,
itemBuilder: (context, index) {
final section = data.plan.sections[index];
return PlanSectionCard(
key: ValueKey(section.id),
section: section,
sectionIndex: index,
plan: data.plan,
@@ -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()),
),
),
],
),
);
}
}

View File

@@ -1,10 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
import 'package:trainhub_flutter/domain/entities/training_exercise.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';
class PlanExerciseTile extends ConsumerWidget {
class PlanExerciseTile extends ConsumerStatefulWidget {
final TrainingExerciseEntity exercise;
final int sectionIndex;
final int exerciseIndex;
@@ -19,100 +23,192 @@ class PlanExerciseTile extends ConsumerWidget {
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = ref.read(planEditorControllerProvider(plan.id).notifier);
ConsumerState<PlanExerciseTile> createState() => _PlanExerciseTileState();
}
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
elevation: 0,
color: Theme.of(context).colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(
side: BorderSide(color: Theme.of(context).colorScheme.outlineVariant),
class _PlanExerciseTileState extends ConsumerState<PlanExerciseTile> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
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),
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(
padding: const EdgeInsets.all(8.0),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Column(
children: [
// --- Header: drag handle + name + delete ---
Row(
children: [
const Icon(
Icons.drag_handle,
size: 15,
color: AppColors.zinc600,
),
const SizedBox(width: 8),
Expanded(
child: Text(
exercise.name,
style: const TextStyle(fontWeight: FontWeight.bold),
widget.exercise.name,
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),
// --- Fields row ---
Row(
children: [
_buildNumberInput(
context,
_FieldBox(
label: 'Sets',
value: exercise.sets,
value: widget.exercise.sets,
onChanged: (val) => controller.updateExerciseParams(
sectionIndex,
exerciseIndex,
widget.sectionIndex,
widget.exerciseIndex,
sets: val,
),
),
const SizedBox(width: 8),
_buildNumberInput(
context,
label: exercise.isTime ? 'Secs' : 'Reps',
value: exercise.value,
_FieldBox(
label: widget.exercise.isTime ? 'Secs' : 'Reps',
value: widget.exercise.value,
onChanged: (val) => controller.updateExerciseParams(
sectionIndex,
exerciseIndex,
widget.sectionIndex,
widget.exerciseIndex,
value: val,
),
),
const SizedBox(width: 8),
_buildNumberInput(
context,
_FieldBox(
label: 'Rest(s)',
value: exercise.rest,
value: widget.exercise.rest,
onChanged: (val) => controller.updateExerciseParams(
sectionIndex,
exerciseIndex,
widget.sectionIndex,
widget.exerciseIndex,
rest: val,
),
),
const SizedBox(width: 8),
// Toggle Time/Reps
InkWell(
// Time toggle pill
GestureDetector(
onTap: () => controller.updateExerciseParams(
sectionIndex,
exerciseIndex,
isTime: !exercise.isTime,
widget.sectionIndex,
widget.exerciseIndex,
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(
color: widget.exercise.isTime
? AppColors.accentMuted
: AppColors.zinc800,
borderRadius: BorderRadius.circular(6),
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(
Icons.timer,
size: 16,
color: exercise.isTime
? Theme.of(context).colorScheme.onPrimaryContainer
: null,
Icons.timer_outlined,
size: 15,
color: widget.exercise.isTime
? AppColors.accent
: AppColors.textMuted,
),
),
),
@@ -123,38 +219,75 @@ class PlanExerciseTile extends ConsumerWidget {
),
);
}
}
Widget _buildNumberInput(
BuildContext context, {
required String label,
required int value,
required Function(int) onChanged,
}) {
// ---------------------------------------------------------------------------
// Compact field box (label + number input)
// ---------------------------------------------------------------------------
class _FieldBox extends StatelessWidget {
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(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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(
height: 40,
height: 32,
child: TextField(
controller: TextEditingController(text: value.toString())
..selection = TextSelection.fromPosition(
TextPosition(offset: value.toString().length),
),
keyboardType: TextInputType.number,
decoration: const InputDecoration(
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(
horizontal: 8,
textAlign: TextAlign.center,
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
decoration: InputDecoration(
filled: true,
fillColor: AppColors.zinc950,
contentPadding: const EdgeInsets.symmetric(
horizontal: 4,
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) {
final intVal = int.tryParse(val);
if (intVal != null) {
onChanged(intVal);
}
if (intVal != null) onChanged(intVal);
},
),
),

View File

@@ -1,12 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
import 'package:trainhub_flutter/domain/entities/exercise.dart';
import 'package:trainhub_flutter/domain/entities/training_plan.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/widgets/plan_exercise_tile.dart';
class PlanSectionCard extends ConsumerWidget {
class PlanSectionCard extends ConsumerStatefulWidget {
final TrainingSectionEntity section;
final int sectionIndex;
final TrainingPlanEntity plan;
@@ -21,74 +25,252 @@ class PlanSectionCard extends ConsumerWidget {
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = ref.read(planEditorControllerProvider(plan.id).notifier);
ConsumerState<PlanSectionCard> createState() => _PlanSectionCardState();
}
return Card(
margin: const EdgeInsets.only(bottom: 16),
child: Padding(
padding: const EdgeInsets.all(16),
class _PlanSectionCardState extends ConsumerState<PlanSectionCard> {
bool _isDragOver = false;
bool _isHovered = false;
@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(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
// --- Section header ---
Padding(
padding: const EdgeInsets.fromLTRB(
UIConstants.spacing16,
UIConstants.spacing12,
UIConstants.spacing8,
UIConstants.spacing12,
),
child: Row(
children: [
const Icon(
Icons.drag_handle,
color: AppColors.zinc600,
size: 18,
),
const SizedBox(width: UIConstants.spacing8),
Expanded(
child: TextField(
controller: TextEditingController(text: section.name)
..selection = TextSelection.fromPosition(
TextPosition(offset: section.name.length),
controller: TextEditingController(
text: widget.section.name,
)..selection = TextSelection.fromPosition(
TextPosition(
offset: widget.section.name.length,
),
),
decoration: const InputDecoration(
border: InputBorder.none,
hintText: 'Section Name',
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
filled: false,
hintText: 'Section name',
isDense: true,
contentPadding: EdgeInsets.zero,
),
style: Theme.of(context).textTheme.titleMedium,
onChanged: (val) =>
controller.updateSectionName(sectionIndex, val),
style: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
onChanged: (val) => controller.updateSectionName(
widget.sectionIndex,
val,
),
),
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () => controller.deleteSection(sectionIndex),
),
// Exercise count badge
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,
physics: const NeverScrollableScrollPhysics(),
itemCount: section.exercises.length,
itemCount: widget.section.exercises.length,
onReorder: (oldIndex, newIndex) =>
controller.reorderExercise(sectionIndex, oldIndex, newIndex),
controller.reorderExercise(
widget.sectionIndex,
oldIndex,
newIndex,
),
itemBuilder: (context, index) {
final exercise = section.exercises[index];
final exercise = widget.section.exercises[index];
return PlanExerciseTile(
key: ValueKey(exercise.instanceId),
exercise: exercise,
sectionIndex: sectionIndex,
sectionIndex: widget.sectionIndex,
exerciseIndex: index,
plan: plan,
plan: widget.plan,
);
},
),
const SizedBox(height: 8),
Center(
child: TextButton.icon(
onPressed: () {
),
// --- Bottom actions ---
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) {
controller.addExerciseToSection(sectionIndex, exercise);
});
},
icon: const Icon(Icons.add),
label: const Text('Add Exercise'),
controller.addExerciseToSection(
widget.sectionIndex,
exercise,
);
}),
),
const SizedBox(width: UIConstants.spacing8),
_ActionButton(
icon: Icons.create_outlined,
label: 'Create New',
onTap: () =>
_showCreateExerciseDialog(context, controller),
),
],
),
),
],
),
),
);
},
);
}
void _showExercisePicker(
@@ -107,22 +289,77 @@ class PlanSectionCard extends ConsumerWidget {
builder: (context, scrollController) {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
Container(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 12),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: AppColors.border)),
),
child: Row(
children: [
Text(
'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(
child: ListView.builder(
controller: scrollController,
itemCount: availableExercises.length,
padding: const EdgeInsets.symmetric(
vertical: UIConstants.spacing8,
),
itemCount: widget.availableExercises.length,
itemBuilder: (context, index) {
final exercise = availableExercises[index];
final exercise = widget.availableExercises[index];
return ListTile(
title: Text(exercise.name),
subtitle: Text(exercise.muscleGroup ?? ''),
contentPadding: const EdgeInsets.symmetric(
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: () {
onSelect(exercise);
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,
),
),
],
),
),
),
);
}
}

View 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);
}
}
}
}

View File

@@ -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

View 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;
}

View 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;
}

View 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';
}
}

View 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

View 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,
),
),
),
);
}
}

View 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;
}

View 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;
}

View 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()),
),
],
),
),
),
),
),
],
),
);
}
}

View 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,
),
),
),
],
);
}
}

View 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,
),
],
),
),
),
);
}
}

View File

@@ -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,
),
),
],
),
),
),
),
),
);
}
}

View 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,
),
),
),
),
);
}
}

View File

@@ -1,7 +1,10 @@
import 'package:auto_route/auto_route.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/theme/app_colors.dart';
@RoutePage()
class ShellPage extends StatelessWidget {
@@ -22,50 +25,10 @@ class ShellPage extends StatelessWidget {
return Scaffold(
body: Row(
children: [
// NavigationRail with logo area at top
NavigationRail(
selectedIndex: tabsRouter.activeIndex,
_Sidebar(
activeIndex: tabsRouter.activeIndex,
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),
],
),
@@ -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,
),
),
),
),
);
}
}

View File

@@ -7,7 +7,7 @@ part of 'trainings_controller.dart';
// **************************************************************************
String _$trainingsControllerHash() =>
r'15c54eb8211e3b2549af6ef25a9cb451a7a9988a';
r'2da51cdda3db5f186bc32980544a6aeeab268274';
/// See also [TrainingsController].
@ProviderFor(TrainingsController)

View File

@@ -1,11 +1,15 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.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/constants/ui_constants.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/training_plan.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/dialogs/text_input_dialog.dart';
@@ -260,24 +264,22 @@ class _ExercisesTab extends StatelessWidget {
title: 'No exercises yet',
subtitle: 'Add exercises to use in your training plans',
)
: GridView.builder(
padding: const EdgeInsets.all(UIConstants.spacing16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 3.0,
crossAxisSpacing: UIConstants.spacing12,
mainAxisSpacing: UIConstants.spacing12,
: ListView.builder(
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing16,
vertical: UIConstants.spacing8,
),
itemCount: exercises.length,
itemBuilder: (context, index) {
final exercise = exercises[index];
return _ExerciseCard(
return _ExerciseListItem(
exercise: exercise,
onEdit: () =>
_showExerciseDialog(context, exercise: exercise),
onDelete: () => ref
.read(trainingsControllerProvider.notifier)
.deleteExercise(exercise.id),
onPreview: () => _showExercisePreview(context, exercise),
);
},
),
@@ -311,7 +313,7 @@ class _ExercisesTab extends StatelessWidget {
TextField(
controller: instructionsCtrl,
decoration: const InputDecoration(labelText: 'Instructions'),
maxLines: 2,
maxLines: 3,
),
const SizedBox(height: UIConstants.spacing12),
TextField(
@@ -323,7 +325,9 @@ class _ExercisesTab extends StatelessWidget {
const SizedBox(height: UIConstants.spacing12),
TextField(
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'),
),
FilledButton(
onPressed: () {
onPressed: () async {
if (nameCtrl.text.isEmpty) return;
if (exercise == null) {
ref
await ref
.read(trainingsControllerProvider.notifier)
.addExercise(
name: nameCtrl.text,
@@ -347,7 +351,7 @@ class _ExercisesTab extends StatelessWidget {
videoUrl: videoUrlCtrl.text,
);
} else {
ref
await ref
.read(trainingsControllerProvider.notifier)
.updateExercise(
exercise.copyWith(
@@ -358,7 +362,7 @@ class _ExercisesTab extends StatelessWidget {
),
);
}
Navigator.pop(context);
if (context.mounted) Navigator.pop(context);
},
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 VoidCallback onEdit;
final VoidCallback onDelete;
final VoidCallback onPreview;
const _ExerciseCard({
const _ExerciseListItem({
required this.exercise,
required this.onEdit,
required this.onDelete,
required this.onPreview,
});
@override
State<_ExerciseCard> createState() => _ExerciseCardState();
State<_ExerciseListItem> createState() => _ExerciseListItemState();
}
class _ExerciseCardState extends State<_ExerciseCard> {
class _ExerciseListItemState extends State<_ExerciseListItem> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
final hasVideo = widget.exercise.videoUrl != null &&
widget.exercise.videoUrl!.isNotEmpty;
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: Card(
margin: const EdgeInsets.only(bottom: UIConstants.spacing8),
child: InkWell(
onTap: widget.onEdit,
onTap: widget.onPreview,
borderRadius: UIConstants.cardBorderRadius,
child: Padding(
padding: const EdgeInsets.all(UIConstants.spacing12),
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing16,
vertical: UIConstants.spacing12,
),
child: Row(
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(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
widget.exercise.name,
@@ -410,12 +445,10 @@ class _ExerciseCardState extends State<_ExerciseCard> {
fontWeight: FontWeight.w600,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (widget.exercise.instructions != null &&
widget.exercise.instructions!.isNotEmpty) ...[
const SizedBox(height: 4),
const SizedBox(height: 2),
Text(
widget.exercise.instructions!,
style: const TextStyle(
@@ -433,7 +466,7 @@ class _ExerciseCardState extends State<_ExerciseCard> {
spacing: 4,
children: widget.exercise.tags!
.split(',')
.take(3)
.take(4)
.map(
(tag) => Container(
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) ...[
IconButton(
icon: const Icon(
Icons.open_in_new,
size: 16,
color: AppColors.textMuted,
),
onPressed: widget.onPreview,
tooltip: 'Preview',
),
IconButton(
icon: const Icon(Icons.edit, size: 16),
onPressed: widget.onEdit,
tooltip: 'Edit',
),
IconButton(
icon: const Icon(
@@ -481,6 +514,7 @@ class _ExerciseCardState extends State<_ExerciseCard> {
color: AppColors.destructive,
),
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')}';
}
}

View 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,
),
),
),
),
);
}
}

View 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,
),
),
),
],
),
);
}
}

View 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,
),
),
),
],
);
}
}

View 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,
),
),
),
),
),
),
);
}
}

View File

@@ -5,19 +5,25 @@ import 'package:trainhub_flutter/core/constants/ui_constants.dart';
class SessionControls extends StatelessWidget {
final bool isRunning;
final bool isFinished;
final bool isTimeBased;
final VoidCallback onPause;
final VoidCallback onPlay;
final VoidCallback onNext;
final VoidCallback onPrevious;
final VoidCallback onRewind;
final VoidCallback onFastForward;
const SessionControls({
super.key,
required this.isRunning,
required this.isFinished,
required this.isTimeBased,
required this.onPause,
required this.onPlay,
required this.onNext,
required this.onPrevious,
required this.onRewind,
required this.onFastForward,
});
@override
@@ -39,13 +45,20 @@ class SessionControls extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (isTimeBased) ...[
_ControlButton(
icon: Icons.replay_10,
onTap: onRewind,
size: 24,
),
const SizedBox(width: UIConstants.spacing8),
],
_ControlButton(
icon: Icons.skip_previous_rounded,
onTap: onPrevious,
size: 28,
),
const SizedBox(width: UIConstants.spacing24),
// Play/Pause - larger main button
Container(
width: 56,
height: 56,
@@ -79,6 +92,14 @@ class SessionControls extends StatelessWidget {
onTap: onNext,
size: 28,
),
if (isTimeBased) ...[
const SizedBox(width: UIConstants.spacing8),
_ControlButton(
icon: Icons.forward_10,
onTap: onFastForward,
size: 24,
),
],
],
),
);

View File

@@ -20,12 +20,20 @@ class WorkoutSessionController extends _$WorkoutSessionController {
final activities = _buildSequence(plan);
ref.onDispose(() => _timer?.cancel());
final initialState = WorkoutSessionState(activities: activities);
if (activities.isNotEmpty) {
final first = activities.first;
return initialState.copyWith(timeRemaining: first.duration);
if (activities.isEmpty) {
return WorkoutSessionState(activities: activities);
}
final first = activities.first;
final initialState = WorkoutSessionState(
activities: activities,
timeRemaining: first.duration,
);
if (first.isTimeBased) {
Future.microtask(startTimer);
}
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) {
if (state.value?.isFinished ?? true) return;
var currentState = state.value!;
@@ -98,7 +119,7 @@ class WorkoutSessionController extends _$WorkoutSessionController {
if (newState.timeRemaining > 0) {
newState = newState.copyWith(timeRemaining: newState.timeRemaining - 1);
} else {
state = AsyncValue.data(newState); // update interim state before next
state = AsyncValue.data(newState);
_goNext(newState);
return;
}
@@ -123,7 +144,7 @@ class WorkoutSessionController extends _$WorkoutSessionController {
state = AsyncValue.data(newState);
if (nextActivity.isRest) {
if (nextActivity.isTimeBased) {
startTimer();
} else {
pauseTimer();
@@ -152,9 +173,8 @@ class WorkoutSessionController extends _$WorkoutSessionController {
void jumpTo(int index) {
final currentState = state.value;
if (currentState != null &&
index >= 0 &&
index < currentState.activities.length) {
if (currentState == null) return;
if (index < 0 || index >= currentState.activities.length) return;
final activity = currentState.activities[index];
state = AsyncValue.data(
@@ -164,13 +184,12 @@ class WorkoutSessionController extends _$WorkoutSessionController {
),
);
if (activity.isRest) {
if (activity.isTimeBased) {
startTimer();
} else {
pauseTimer();
}
}
}
void _finish() {
pauseTimer();

View File

@@ -7,7 +7,7 @@ part of 'workout_session_controller.dart';
// **************************************************************************
String _$workoutSessionControllerHash() =>
r'd3f53d72c80963634c6edaeb44aa5b04c9ffba6d';
r'ba4c44e3bc2de98cced4eef96f8a337fd1e43665';
/// Copied from Dart SDK
class _SystemHash {

View File

@@ -1,11 +1,10 @@
import 'dart:math';
import 'dart:ui';
import 'dart:math' as math;
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
import 'package:trainhub_flutter/domain/entities/workout_activity.dart';
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/widgets/activity_card.dart';
@@ -55,12 +54,14 @@ class WorkoutSessionPage extends ConsumerWidget {
const SizedBox(height: UIConstants.spacing8),
Text(
'$err',
style: TextStyle(
color: AppColors.textMuted,
fontSize: 14,
),
style: TextStyle(color: AppColors.textMuted, fontSize: 14),
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(
state: state,
isRest: isRest,
controller: controller,
);
},
@@ -88,27 +86,32 @@ class WorkoutSessionPage extends ConsumerWidget {
}
}
// ---------------------------------------------------------------------------
// Active session view (gradient background + timer + controls)
// ---------------------------------------------------------------------------
class _ActiveSessionView extends StatelessWidget {
class _ActiveSessionView extends StatefulWidget {
final WorkoutSessionState state;
final bool isRest;
final WorkoutSessionController controller;
const _ActiveSessionView({
required this.state,
required this.isRest,
required this.controller,
});
@override
State<_ActiveSessionView> createState() => _ActiveSessionViewState();
}
class _ActiveSessionViewState extends State<_ActiveSessionView> {
bool _showActivitiesList = false;
@override
Widget build(BuildContext context) {
// Compute the time progress for the circular ring.
final activity = state.currentActivity;
final activity = widget.state.currentActivity;
final isRest = activity?.isRest ?? false;
final isTimeBased = activity?.isTimeBased ?? false;
final double timeProgress;
if (activity != null && activity.duration > 0) {
timeProgress = 1.0 - (state.timeRemaining / activity.duration);
timeProgress =
1.0 - (widget.state.timeRemaining / activity.duration);
} else {
timeProgress = 0.0;
}
@@ -118,7 +121,9 @@ class _ActiveSessionView extends StatelessWidget {
: AppColors.accent.withValues(alpha: 0.06);
final ringColor = isRest ? AppColors.info : AppColors.accent;
return Container(
return Stack(
children: [
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
@@ -133,18 +138,110 @@ class _ActiveSessionView extends StatelessWidget {
),
child: Column(
children: [
// -- Top progress bar --
SessionProgressBar(progress: state.progress),
// -- Elapsed time badge --
Padding(
padding: const EdgeInsets.only(
top: UIConstants.spacing16,
right: UIConstants.spacing24,
SessionProgressBar(progress: widget.state.progress),
_buildTopBar(context),
Expanded(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing24,
),
child: Align(
alignment: Alignment.centerRight,
child: Container(
child: Column(
mainAxisSize: MainAxisSize.min,
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(
horizontal: UIConstants.spacing12,
vertical: UIConstants.spacing4,
@@ -166,7 +263,7 @@ class _ActiveSessionView extends StatelessWidget {
),
const SizedBox(width: 4),
Text(
_formatDuration(state.totalTimeElapsed),
_formatDuration(widget.state.totalTimeElapsed),
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 13,
@@ -176,63 +273,36 @@ class _ActiveSessionView extends StatelessWidget {
),
],
),
),
),
),
);
}
// -- Central content --
Expanded(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing24,
Future<void> _confirmExit(BuildContext context) async {
final shouldExit = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Exit Workout?'),
content: const Text(
'Your progress will not be saved. Are you sure you want to exit?',
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Activity info card
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,
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
const SizedBox(height: UIConstants.spacing24),
// "Up next" pill
if (state.nextActivity != null)
_UpNextPill(
nextActivityName: state.nextActivity!.name,
isNextRest: state.nextActivity!.isRest,
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
style: FilledButton.styleFrom(
backgroundColor: AppColors.destructive,
),
],
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) {
@@ -242,28 +312,116 @@ class _ActiveSessionView extends StatelessWidget {
}
}
// ---------------------------------------------------------------------------
// Circular timer with arc progress ring
// ---------------------------------------------------------------------------
class _RepsDisplay extends StatelessWidget {
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 {
final int timeRemaining;
final double progress;
final Color ringColor;
final bool isRunning;
final bool isTimeBased;
const _CircularTimerDisplay({
required this.timeRemaining,
required this.progress,
required this.ringColor,
required this.isRunning,
required this.isTimeBased,
});
@override
Widget build(BuildContext context) {
const double size = 220;
const double strokeWidth = 6.0;
const double size = 260;
return SizedBox(
width: size,
@@ -271,49 +429,34 @@ class _CircularTimerDisplay extends StatelessWidget {
child: Stack(
alignment: Alignment.center,
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(
width: size * 0.7,
height: size * 0.7,
width: size * 0.75,
height: size * 0.75,
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: ringColor.withValues(alpha: 0.08),
blurRadius: 40,
color: ringColor.withValues(alpha: isRunning ? 0.15 : 0.05),
blurRadius: 60,
spreadRadius: 10,
),
],
),
),
// Timer text
TweenAnimationBuilder<double>(
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(
mainAxisSize: MainAxisSize.min,
children: [
@@ -321,39 +464,49 @@ class _CircularTimerDisplay extends StatelessWidget {
_formatTime(timeRemaining),
style: TextStyle(
color: AppColors.textPrimary,
fontSize: 52,
fontWeight: FontWeight.w300,
letterSpacing: 2,
fontSize: 64,
fontWeight: FontWeight.w200,
letterSpacing: -1,
fontFeatures: const [FontFeature.tabularFigures()],
fontFamily: 'monospace',
shadows: [
Shadow(
color: ringColor.withValues(alpha: 0.3),
blurRadius: 20,
offset: const Offset(0, 4),
),
],
),
),
if (!isTimeBased)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'UNTIMED',
style: TextStyle(
color: AppColors.textMuted,
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 2,
AnimatedOpacity(
opacity: isRunning ? 0.0 : 1.0,
duration: const Duration(milliseconds: 200),
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
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(
'PAUSED',
style: TextStyle(
color: AppColors.textMuted,
fontSize: 11,
fontSize: 12,
fontWeight: FontWeight.w600,
letterSpacing: 2,
),
),
),
),
),
],
),
],
@@ -368,9 +521,82 @@ class _CircularTimerDisplay extends StatelessWidget {
}
}
// ---------------------------------------------------------------------------
// "Up next" pill
// ---------------------------------------------------------------------------
class TimerRingPainter extends CustomPainter {
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 {
final String nextActivityName;
final bool isNextRest;
@@ -439,9 +665,180 @@ class _UpNextPill extends StatelessWidget {
}
}
// ---------------------------------------------------------------------------
// Completion screen
// ---------------------------------------------------------------------------
class _ActivitiesListPanel extends StatelessWidget {
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 {
final int totalTimeElapsed;
@@ -466,7 +863,6 @@ class _CompletionScreen extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Checkmark circle
Container(
width: 96,
height: 96,
@@ -491,9 +887,7 @@ class _CompletionScreen extends StatelessWidget {
size: 48,
),
),
const SizedBox(height: UIConstants.spacing24),
const Text(
'Workout Complete',
style: TextStyle(
@@ -503,9 +897,7 @@ class _CompletionScreen extends StatelessWidget {
letterSpacing: -0.5,
),
),
const SizedBox(height: UIConstants.spacing8),
Text(
'Great job! You crushed it.',
style: TextStyle(
@@ -513,10 +905,7 @@ class _CompletionScreen extends StatelessWidget {
fontSize: 15,
),
),
const SizedBox(height: UIConstants.spacing32),
// Total time card
Container(
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing24,
@@ -554,10 +943,7 @@ class _CompletionScreen extends StatelessWidget {
],
),
),
const SizedBox(height: UIConstants.spacing32),
// Finish button
SizedBox(
width: 200,
height: 48,

View File

@@ -6,17 +6,29 @@
#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 <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
#include <volume_controller/volume_controller_plugin.h>
#include <window_manager/window_manager_plugin.h>
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 =
fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverPlugin");
screen_retriever_plugin_register_with_registrar(screen_retriever_registrar);
g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin");
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 =
fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin");
window_manager_plugin_register_with_registrar(window_manager_registrar);

View File

@@ -3,8 +3,11 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
media_kit_libs_linux
media_kit_video
screen_retriever
sqlite3_flutter_libs
volume_controller
window_manager
)

View File

@@ -5,14 +5,22 @@
import FlutterMacOS
import Foundation
import media_kit_libs_macos_video
import media_kit_video
import package_info_plus
import screen_retriever
import sqlite3_flutter_libs
import video_player_avfoundation
import volume_controller
import wakelock_plus
import window_manager
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"))
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"))
}

View File

@@ -33,6 +33,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.3.9"
archive:
dependency: "direct main"
description:
name: archive
sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d
url: "https://pub.dev"
source: hosted
version: "3.6.1"
args:
dependency: transitive
description:
@@ -217,14 +225,6 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -249,6 +249,30 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: "direct main"
description:
@@ -461,14 +485,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.1"
html:
dependency: transitive
description:
name: html
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
url: "https://pub.dev"
source: hosted
version: "0.15.6"
http:
dependency: "direct main"
description:
@@ -493,6 +509,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d
url: "https://pub.dev"
source: hosted
version: "4.3.0"
intl:
dependency: transitive
description:
@@ -605,6 +629,70 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -645,6 +733,22 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: "direct main"
description:
@@ -797,6 +901,30 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -922,6 +1050,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
synchronized:
dependency: transitive
description:
name: synchronized
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
url: "https://pub.dev"
source: hosted
version: "3.4.0"
term_glyph:
dependency: transitive
description:
@@ -962,6 +1098,22 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: "direct main"
description:
@@ -1002,54 +1154,6 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -1058,6 +1162,30 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:

View File

@@ -36,9 +36,10 @@ dependencies:
fl_chart: ^0.66.0
animate_do: ^3.3.4
# Video
video_player: ^2.8.2
video_player_win: ^3.2.2
# Video (cross-platform: Linux, Windows, macOS, Android, iOS)
media_kit: ^1.1.10+1
media_kit_video: ^1.2.4
media_kit_libs_video: ^1.0.5
# Markdown
flutter_markdown: ^0.6.18+3
@@ -49,6 +50,10 @@ dependencies:
window_manager: ^0.3.9
uuid: ^4.5.1
# AI Model Download
dio: ^5.7.0
archive: ^3.6.0
dev_dependencies:
flutter_test:

View File

@@ -6,18 +6,24 @@
#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 <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>
void RegisterPlugins(flutter::PluginRegistry* registry) {
MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi"));
MediaKitVideoPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi"));
ScreenRetrieverPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ScreenRetrieverPlugin"));
Sqlite3FlutterLibsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin"));
VideoPlayerWinPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("VideoPlayerWinPluginCApi"));
VolumeControllerPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("VolumeControllerPluginCApi"));
WindowManagerPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("WindowManagerPlugin"));
}

View File

@@ -3,9 +3,11 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
media_kit_libs_windows_video
media_kit_video
screen_retriever
sqlite3_flutter_libs
video_player_win
volume_controller
window_manager
)