diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 39932b7..728efc7 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -8,7 +8,18 @@ "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:*)" ] } } diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index d72c55c..ea59930 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -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 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), ]; } diff --git a/lib/core/router/app_router.gr.dart b/lib/core/router/app_router.gr.dart index 78ebc59..c036ae3 100644 --- a/lib/core/router/app_router.gr.dart +++ b/lib/core/router/app_router.gr.dart @@ -74,6 +74,22 @@ class HomeRoute extends PageRouteInfo { ); } +/// generated route for +/// [KnowledgeBasePage] +class KnowledgeBaseRoute extends PageRouteInfo { + const KnowledgeBaseRoute({List? 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 { @@ -116,6 +132,22 @@ class PlanEditorRouteArgs { } } +/// generated route for +/// [SettingsPage] +class SettingsRoute extends PageRouteInfo { + const SettingsRoute({List? 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 { @@ -148,6 +180,22 @@ class TrainingsRoute extends PageRouteInfo { ); } +/// generated route for +/// [WelcomeScreen] +class WelcomeRoute extends PageRouteInfo { + const WelcomeRoute({List? 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 { diff --git a/lib/data/database/app_database.dart b/lib/data/database/app_database.dart index 45d4578..73e6f78 100644 --- a/lib/data/database/app_database.dart +++ b/lib/data/database/app_database.dart @@ -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 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` — the 768-dim Nomic embedding vector. + TextColumn get embedding => text()(); + + TextColumn get createdAt => text()(); + + @override + Set 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() { diff --git a/lib/data/database/app_database.g.dart b/lib/data/database/app_database.g.dart index cbabd97..1aa97bb 100644 --- a/lib/data/database/app_database.g.dart +++ b/lib/data/database/app_database.g.dart @@ -3250,6 +3250,375 @@ class ChatMessagesCompanion extends UpdateCompanion { } } +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 id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _sourceIdMeta = const VerificationMeta( + 'sourceId', + ); + @override + late final GeneratedColumn sourceId = GeneratedColumn( + 'source_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _contentMeta = const VerificationMeta( + 'content', + ); + @override + late final GeneratedColumn content = GeneratedColumn( + 'content', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _embeddingMeta = const VerificationMeta( + 'embedding', + ); + @override + late final GeneratedColumn embedding = GeneratedColumn( + 'embedding', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + @override + List 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 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 get $primaryKey => {id}; + @override + KnowledgeChunk map(Map 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 { + /// 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` — 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 toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['source_id'] = Variable(sourceId); + map['content'] = Variable(content); + map['embedding'] = Variable(embedding); + map['created_at'] = Variable(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 json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return KnowledgeChunk( + id: serializer.fromJson(json['id']), + sourceId: serializer.fromJson(json['sourceId']), + content: serializer.fromJson(json['content']), + embedding: serializer.fromJson(json['embedding']), + createdAt: serializer.fromJson(json['createdAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'sourceId': serializer.toJson(sourceId), + 'content': serializer.toJson(content), + 'embedding': serializer.toJson(embedding), + 'createdAt': serializer.toJson(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 { + final Value id; + final Value sourceId; + final Value content; + final Value embedding; + final Value createdAt; + final Value 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 custom({ + Expression? id, + Expression? sourceId, + Expression? content, + Expression? embedding, + Expression? createdAt, + Expression? 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? id, + Value? sourceId, + Value? content, + Value? embedding, + Value? createdAt, + Value? 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 toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (sourceId.present) { + map['source_id'] = Variable(sourceId.value); + } + if (content.present) { + map['content'] = Variable(content.value); + } + if (embedding.present) { + map['embedding'] = Variable(embedding.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(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> get allTables => allSchemaEntities.whereType>(); @@ -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 rowid, + }); +typedef $$KnowledgeChunksTableUpdateCompanionBuilder = + KnowledgeChunksCompanion Function({ + Value id, + Value sourceId, + Value content, + Value embedding, + Value createdAt, + Value rowid, + }); + +class $$KnowledgeChunksTableFilterComposer + extends Composer<_$AppDatabase, $KnowledgeChunksTable> { + $$KnowledgeChunksTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get sourceId => $composableBuilder( + column: $table.sourceId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get content => $composableBuilder( + column: $table.content, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get embedding => $composableBuilder( + column: $table.embedding, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters 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 get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get sourceId => $composableBuilder( + column: $table.sourceId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get content => $composableBuilder( + column: $table.content, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get embedding => $composableBuilder( + column: $table.embedding, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings 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 get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get sourceId => + $composableBuilder(column: $table.sourceId, builder: (column) => column); + + GeneratedColumn get content => + $composableBuilder(column: $table.content, builder: (column) => column); + + GeneratedColumn get embedding => + $composableBuilder(column: $table.embedding, builder: (column) => column); + + GeneratedColumn 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 id = const Value.absent(), + Value sourceId = const Value.absent(), + Value content = const Value.absent(), + Value embedding = const Value.absent(), + Value createdAt = const Value.absent(), + Value 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 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); } diff --git a/lib/data/database/daos/analysis_dao.dart b/lib/data/database/daos/analysis_dao.dart index 3cbb718..416f0e4 100644 --- a/lib/data/database/daos/analysis_dao.dart +++ b/lib/data/database/daos/analysis_dao.dart @@ -27,6 +27,20 @@ class AnalysisDao extends DatabaseAccessor Future insertAnnotation(AnnotationsCompanion entry) => into(annotations).insert(entry); + Future 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 deleteAnnotation(String id) => (delete(annotations)..where((t) => t.id.equals(id))).go(); } diff --git a/lib/data/database/daos/knowledge_chunk_dao.dart b/lib/data/database/daos/knowledge_chunk_dao.dart new file mode 100644 index 0000000..7e402b3 --- /dev/null +++ b/lib/data/database/daos/knowledge_chunk_dao.dart @@ -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 + with _$KnowledgeChunkDaoMixin { + KnowledgeChunkDao(super.db); + + Future 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> getAllChunks() => + (select(knowledgeChunks) + ..orderBy([ + (t) => + OrderingTerm(expression: t.createdAt, mode: OrderingMode.asc) + ])) + .get(); + + Future getCount() async { + final rows = await select(knowledgeChunks).get(); + return rows.length; + } + + Future deleteAll() => delete(knowledgeChunks).go(); + + Future deleteBySourceId(String sourceId) => + (delete(knowledgeChunks) + ..where((t) => t.sourceId.equals(sourceId))) + .go(); +} diff --git a/lib/data/database/daos/knowledge_chunk_dao.g.dart b/lib/data/database/daos/knowledge_chunk_dao.g.dart new file mode 100644 index 0000000..b2d7b4a --- /dev/null +++ b/lib/data/database/daos/knowledge_chunk_dao.g.dart @@ -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 { + $KnowledgeChunksTable get knowledgeChunks => attachedDatabase.knowledgeChunks; +} diff --git a/lib/data/repositories/analysis_repository_impl.dart b/lib/data/repositories/analysis_repository_impl.dart index 35a1c6a..73ca678 100644 --- a/lib/data/repositories/analysis_repository_impl.dart +++ b/lib/data/repositories/analysis_repository_impl.dart @@ -83,6 +83,21 @@ class AnalysisRepositoryImpl implements AnalysisRepository { ); } + @override + Future 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 deleteAnnotation(String id) async { await _dao.deleteAnnotation(id); diff --git a/lib/data/repositories/note_repository_impl.dart b/lib/data/repositories/note_repository_impl.dart new file mode 100644 index 0000000..a9772a4 --- /dev/null +++ b/lib/data/repositories/note_repository_impl.dart @@ -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/domain/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 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> 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) + .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 getChunkCount() => _dao.getCount(); + + @override + Future 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 _chunkText(String text, {int maxChars = 500}) { + final chunks = []; + + 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 a, List 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; +} diff --git a/lib/domain/entities/note_chunk.dart b/lib/domain/entities/note_chunk.dart new file mode 100644 index 0000000..1403e83 --- /dev/null +++ b/lib/domain/entities/note_chunk.dart @@ -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; +} diff --git a/lib/domain/entities/note_chunk.freezed.dart b/lib/domain/entities/note_chunk.freezed.dart new file mode 100644 index 0000000..cda0737 --- /dev/null +++ b/lib/domain/entities/note_chunk.freezed.dart @@ -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 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 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; +} diff --git a/lib/domain/repositories/analysis_repository.dart b/lib/domain/repositories/analysis_repository.dart index 6a031ae..3c7148e 100644 --- a/lib/domain/repositories/analysis_repository.dart +++ b/lib/domain/repositories/analysis_repository.dart @@ -15,5 +15,11 @@ abstract class AnalysisRepository { required double endTime, required String color, }); + Future updateAnnotation({ + required String id, + required String name, + required String description, + required String color, + }); Future deleteAnnotation(String id); } diff --git a/lib/domain/repositories/note_repository.dart b/lib/domain/repositories/note_repository.dart new file mode 100644 index 0000000..c6d9b48 --- /dev/null +++ b/lib/domain/repositories/note_repository.dart @@ -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 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> searchSimilar(String query, {int topK = 3}); + + /// Returns the total number of stored chunks. + Future getChunkCount(); + + /// Deletes every stored chunk (full knowledge-base reset). + Future clearAll(); +} diff --git a/lib/domain/services/ai_process_manager.dart b/lib/domain/services/ai_process_manager.dart new file mode 100644 index 0000000..1cdfdf3 --- /dev/null +++ b/lib/domain/services/ai_process_manager.dart @@ -0,0 +1,88 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +/// Manages the two llama.cpp server processes that provide AI features. +/// +/// Both processes are kept alive for the lifetime of the app and must be +/// killed on shutdown to prevent zombie processes from consuming RAM. +/// +/// - Qwen 2.5 7B → port 8080 (chat / completions) +/// - Nomic Embed → port 8081 (embeddings) +class AiProcessManager { + Process? _qwenProcess; + Process? _nomicProcess; + bool _running = false; + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + bool get isRunning => _running; + + /// Starts both inference servers. No-ops if already running or if the + /// llama-server binary is not present on disk. + Future startServers() async { + if (_running) return; + + final dir = await getApplicationDocumentsDirectory(); + final base = dir.path; + final serverBin = p.join( + base, + Platform.isWindows ? 'llama-server.exe' : 'llama-server', + ); + + if (!File(serverBin).existsSync()) return; + + try { + // ── Qwen 2.5 7B chat server ────────────────────────────────────────── + _qwenProcess = await Process.start( + serverBin, + [ + '-m', p.join(base, 'qwen2.5-7b-instruct-q4_k_m.gguf'), + '--port', '8080', + '--ctx-size', '4096', + '-ngl', '99', // offload all layers to GPU + ], + runInShell: false, + ); + // Drain pipes so the process is never blocked by a full buffer. + _qwenProcess!.stdout.drain>(); + _qwenProcess!.stderr.drain>(); + + // ── Nomic embedding server ─────────────────────────────────────────── + _nomicProcess = await Process.start( + serverBin, + [ + '-m', p.join(base, 'nomic-embed-text-v1.5.Q4_K_M.gguf'), + '--port', '8081', + '--ctx-size', '8192', + '--embedding', + ], + runInShell: false, + ); + _nomicProcess!.stdout.drain>(); + _nomicProcess!.stderr.drain>(); + + _running = true; + } catch (_) { + // Clean up any partially-started processes before rethrowing. + _qwenProcess?.kill(); + _nomicProcess?.kill(); + _qwenProcess = null; + _nomicProcess = null; + rethrow; + } + } + + /// Kills both processes and resets the running flag. + /// Safe to call even if servers were never started. + Future stopServers() async { + _qwenProcess?.kill(); + _nomicProcess?.kill(); + _qwenProcess = null; + _nomicProcess = null; + _running = false; + } +} diff --git a/lib/domain/services/embedding_service.dart b/lib/domain/services/embedding_service.dart new file mode 100644 index 0000000..aad1879 --- /dev/null +++ b/lib/domain/services/embedding_service.dart @@ -0,0 +1,31 @@ +import 'package:dio/dio.dart'; + +/// Wraps the Nomic embedding server (llama.cpp, port 8081). +/// Returns a 768-dimensional float vector for any input text. +class EmbeddingService { + final _dio = Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 60), + ), + ); + + static const _url = 'http://localhost:8081/v1/embeddings'; + + /// Returns the embedding vector for [text]. + /// Throws a [DioException] if the Nomic server is unreachable. + Future> embed(String text) async { + final response = await _dio.post>( + _url, + data: { + 'input': text, + 'model': 'nomic-embed-text-v1.5.Q4_K_M', + }, + ); + + final raw = + (response.data!['data'] as List)[0]['embedding'] + as List; + return raw.map((e) => (e as num).toDouble()).toList(); + } +} diff --git a/lib/injection.dart b/lib/injection.dart index e411a48..52ac610 100644 --- a/lib/injection.dart +++ b/lib/injection.dart @@ -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/domain/services/ai_process_manager.dart'; +import 'package:trainhub_flutter/domain/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(). + getIt.registerSingleton(AiProcessManager()); + // Database getIt.registerSingleton(AppDatabase()); @@ -30,6 +39,12 @@ void init() { getIt.registerSingleton(ProgramDao(getIt())); getIt.registerSingleton(AnalysisDao(getIt())); getIt.registerSingleton(ChatDao(getIt())); + getIt.registerSingleton( + KnowledgeChunkDao(getIt()), + ); + + // Services + getIt.registerSingleton(EmbeddingService()); // Repositories getIt.registerLazySingleton( @@ -47,4 +62,10 @@ void init() { getIt.registerLazySingleton( () => ChatRepositoryImpl(getIt()), ); + getIt.registerLazySingleton( + () => NoteRepositoryImpl( + getIt(), + getIt(), + ), + ); } diff --git a/lib/main.dart b/lib/main.dart index dc7aa95..6000369 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,18 +1,22 @@ 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/domain/services/ai_process_manager.dart'; import 'package:trainhub_flutter/injection.dart' as di; +import 'package:trainhub_flutter/presentation/settings/ai_model_settings_controller.dart'; +import 'package:trainhub_flutter/presentation/settings/ai_model_settings_state.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + 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 +34,75 @@ void main() async { runApp(const ProviderScope(child: TrainHubApp())); } -class TrainHubApp extends StatelessWidget { +// ============================================================================= +// Root application widget +// ============================================================================= +class TrainHubApp extends ConsumerStatefulWidget { const TrainHubApp({super.key}); + @override + ConsumerState createState() => _TrainHubAppState(); +} + +class _TrainHubAppState extends ConsumerState + with WindowListener { + // Create the router once and reuse it across rebuilds. + final _appRouter = AppRouter(); + + // Guard flag so we never start the servers more than once per app session. + bool _serversStarted = false; + + // ------------------------------------------------------------------------- + // Lifecycle + // ------------------------------------------------------------------------- + + @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().stopServers(); + await windowManager.destroy(); + } + + // ------------------------------------------------------------------------- + // Build + // ------------------------------------------------------------------------- + @override Widget build(BuildContext context) { - final appRouter = AppRouter(); + // Watch the model-settings state and start servers exactly once, the + // first time models are confirmed to be present on disk. + ref.listen( + aiModelSettingsControllerProvider, + (prev, next) { + if (!_serversStarted && next.areModelsValidated) { + _serversStarted = true; + di.getIt().startServers(); + } + }, + ); return MaterialApp.router( title: 'TrainHub', theme: AppTheme.dark, - routerConfig: appRouter.config(), + routerConfig: _appRouter.config(), debugShowCheckedModeBanner: false, ); } diff --git a/lib/presentation/analysis/analysis_controller.dart b/lib/presentation/analysis/analysis_controller.dart index acafbb1..b268643 100644 --- a/lib/presentation/analysis/analysis_controller.dart +++ b/lib/presentation/analysis/analysis_controller.dart @@ -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 build() async { _repo = getIt(); + _exerciseRepo = getIt(); 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 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 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 deleteAnnotation(String id) async { await _repo.deleteAnnotation(id); final current = state.valueOrNull; diff --git a/lib/presentation/analysis/analysis_controller.g.dart b/lib/presentation/analysis/analysis_controller.g.dart index 0d35726..137f5d8 100644 --- a/lib/presentation/analysis/analysis_controller.g.dart +++ b/lib/presentation/analysis/analysis_controller.g.dart @@ -7,7 +7,7 @@ part of 'analysis_controller.dart'; // ************************************************************************** String _$analysisControllerHash() => - r'855d4ab55b8dc398e10c19d0ed245a60f104feed'; + r'f6ad8fc731654c59a90d8ce64438b8490bbfa231'; /// See also [AnalysisController]. @ProviderFor(AnalysisController) diff --git a/lib/presentation/analysis/widgets/analysis_viewer.dart b/lib/presentation/analysis/widgets/analysis_viewer.dart index 67260d7..ba1c5c3 100644 --- a/lib/presentation/analysis/widgets/analysis_viewer.dart +++ b/lib/presentation/analysis/widgets/analysis_viewer.dart @@ -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 { - 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 _positionSubscription; + late StreamSubscription _durationSubscription; + late StreamSubscription _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 { } } + 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); + }); + + _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 _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!) { - _seekTo(_inPoint ?? 0.0); - return; - } - - if (isPlaying != _isPlaying || (position - _currentPosition).abs() > 0.1) { - setState(() { - _isPlaying = isPlaying; - _currentPosition = position; - }); - } + 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 { } void _toggleLoop() { - setState(() { - _isLooping = !_isLooping; - }); + setState(() => _isLooping = !_isLooping); } void _playRange(double start, double end) { @@ -149,7 +165,7 @@ class _AnalysisViewerState extends ConsumerState { _isLooping = true; }); _seekTo(start); - _videoController?.play(); + _player.play(); } @override @@ -158,32 +174,40 @@ class _AnalysisViewerState extends ConsumerState { return Column( children: [ - // Video Area Expanded( flex: 3, child: Container( color: Colors.black, alignment: Alignment.center, - child: - _videoController != null && - _videoController!.value.isInitialized - ? Stack( - alignment: Alignment.bottomCenter, - children: [ - Center( - child: AspectRatio( - aspectRatio: _videoController!.value.aspectRatio, - child: VideoPlayer(_videoController!), + 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), ), - ), - _buildTimelineControls(), - ], + ], + ), ) - : const Center(child: CircularProgressIndicator()), + : _isInitialized + ? Stack( + alignment: Alignment.bottomCenter, + children: [ + Video( + controller: _videoController, + controls: NoVideoControls, + fit: BoxFit.contain, + ), + _buildTimelineControls(), + ], + ) + : const Center(child: CircularProgressIndicator()), ), ), - - // Annotations List Expanded( flex: 2, child: Column( @@ -197,78 +221,48 @@ class _AnalysisViewerState extends ConsumerState { 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( - separatorBuilder: (context, index) => - const Divider(height: 1), - itemCount: widget.annotations.length, - itemBuilder: (context, index) { - final note = widget.annotations[index]; - final bool isActive = - _currentPosition >= note.startTime && - _currentPosition <= note.endTime; + 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, + itemBuilder: (context, index) { + final note = widget.annotations[index]; + final bool isActive = + _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: () => - _playRange(note.startTime, note.endTime), - tooltip: "Play Range", - ), - IconButton( - icon: const Icon(Icons.delete_outline), - onPressed: () => - controller.deleteAnnotation(note.id), - ), - ], - ), + return _AnnotationListItem( + annotation: note, + isActive: isActive, + onPlay: () => + _playRange(note.startTime, note.endTime), + onEdit: () => + _showEditAnnotationDialog(controller, note), + onDelete: () => controller.deleteAnnotation(note.id), + ); + }, ), - ); - }, - ), ), Padding( padding: const EdgeInsets.all(8.0), @@ -289,163 +283,227 @@ class _AnalysisViewerState extends ConsumerState { 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( - height: 20, - child: LayoutBuilder( - builder: (context, constraints) { - return Stack( - children: [ - // Annotations - ...widget.annotations.map((note) { - final left = - (note.startTime / _totalDuration) * - constraints.maxWidth; - final width = - ((note.endTime - note.startTime) / _totalDuration) * - constraints.maxWidth; - return Positioned( - left: left, - width: width, - top: 4, - bottom: 4, - child: Container( - decoration: BoxDecoration( - color: _parseColor( - note.color ?? 'grey', - ).withOpacity(0.6), - borderRadius: BorderRadius.circular(2), - ), - ), - ); - }), - // IN Point - if (_inPoint != null) - Positioned( - 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, - top: 0, - bottom: 0, - child: Container(width: 2, color: Colors.red), - ), - ], - ); - }, - ), - ), + _buildAnnotationTimeline(), + _buildSeekSlider(), + _buildControlsRow(), + ], + ), + ); + } - // Slider - SliderTheme( - data: SliderTheme.of(context).copyWith( - thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6), - overlayShape: const RoundSliderOverlayShape(overlayRadius: 10), - trackHeight: 2, - ), - child: Slider( - value: _currentPosition.clamp(0.0, _totalDuration), - min: 0.0, - max: _totalDuration, - onChanged: (value) => _seekTo(value), - activeColor: Theme.of(context).colorScheme.primary, - inactiveColor: Colors.grey, - ), - ), - - // Controls Row - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - _formatDuration(_currentPosition), - style: const TextStyle(color: Colors.white, fontSize: 12), - ), - const SizedBox(width: 16), - - IconButton( - icon: const Icon( - Icons.keyboard_arrow_left, - color: Colors.white, - ), - onPressed: () => _seekTo(_currentPosition - 1), - ), - IconButton( - icon: Icon( - _isPlaying ? Icons.pause : Icons.play_arrow, - color: Colors.white, - ), - onPressed: _togglePlay, - ), - IconButton( - 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", - onPressed: _setInPoint, - ), - IconButton( - icon: const Icon(Icons.logout, color: Colors.red), - tooltip: "Set OUT Point", - onPressed: _setOutPoint, - ), - IconButton( - icon: Icon( - Icons.loop, - color: _isLooping - ? Theme.of(context).colorScheme.primary - : Colors.white, - ), - tooltip: "Toggle Loop", - onPressed: _toggleLoop, - ), - if (_inPoint != null || _outPoint != null) - IconButton( - icon: const Icon( - Icons.cancel_outlined, - color: Colors.white, + Widget _buildAnnotationTimeline() { + return SizedBox( + height: 20, + child: LayoutBuilder( + builder: (context, constraints) { + return Stack( + children: [ + ...widget.annotations.map((note) { + final left = + (note.startTime / _totalDuration) * constraints.maxWidth; + final width = + ((note.endTime - note.startTime) / _totalDuration) * + constraints.maxWidth; + return Positioned( + left: left, + width: width.clamp(2.0, constraints.maxWidth), + top: 4, + bottom: 4, + child: Container( + decoration: BoxDecoration( + color: _parseColor( + note.color ?? 'grey', + ).withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(2), ), - tooltip: "Clear Points", - onPressed: _clearPoints, ), - - const Spacer(), - Text( - _formatDuration(_totalDuration), - style: const TextStyle(color: Colors.white, fontSize: 12), + ); + }), + if (_inPoint != null) + Positioned( + left: (_inPoint! / _totalDuration) * constraints.maxWidth, + top: 0, + bottom: 0, + child: Container(width: 2, color: Colors.green), ), - ], + if (_outPoint != null) + Positioned( + left: (_outPoint! / _totalDuration) * constraints.maxWidth, + top: 0, + bottom: 0, + child: Container(width: 2, color: Colors.red), + ), + ], + ); + }, + ), + ); + } + + Widget _buildSeekSlider() { + return SliderTheme( + data: SliderTheme.of(context).copyWith( + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 10), + trackHeight: 2, + ), + child: Slider( + value: _currentPosition.clamp(0.0, _totalDuration), + min: 0.0, + max: _totalDuration, + onChanged: _seekTo, + activeColor: Theme.of(context).colorScheme.primary, + inactiveColor: Colors.grey, + ), + ); + } + + Widget _buildControlsRow() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _formatDuration(_currentPosition), + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + const SizedBox(width: 16), + IconButton( + icon: const Icon(Icons.keyboard_arrow_left, color: Colors.white), + onPressed: () => _seekTo(_currentPosition - 1), + ), + IconButton( + icon: Icon( + _isPlaying ? Icons.pause : Icons.play_arrow, + color: Colors.white, ), + onPressed: _togglePlay, + ), + IconButton( + icon: const Icon(Icons.keyboard_arrow_right, color: Colors.white), + onPressed: () => _seekTo(_currentPosition + 1), + ), + const SizedBox(width: 16), + IconButton( + icon: const Icon(Icons.login, color: Colors.green), + tooltip: 'Set IN Point', + onPressed: _setInPoint, + ), + IconButton( + icon: const Icon(Icons.logout, color: Colors.red), + tooltip: 'Set OUT Point', + onPressed: _setOutPoint, + ), + IconButton( + icon: Icon( + Icons.loop, + color: _isLooping + ? Theme.of(context).colorScheme.primary + : Colors.white, + ), + tooltip: 'Toggle Loop', + onPressed: _toggleLoop, + ), + if (_inPoint != null || _outPoint != null) + IconButton( + icon: const Icon(Icons.cancel_outlined, color: Colors.white), + tooltip: 'Clear Points', + onPressed: _clearPoints, + ), + const Spacer(), + Text( + _formatDuration(_totalDuration), + style: const TextStyle(color: Colors.white, fontSize: 12), ), ], ), ); } + 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, + ), + ); + }, + ), + ); + } + String _formatDuration(double seconds) { final duration = Duration(milliseconds: (seconds * 1000).toInt()); final mins = duration.inMinutes; @@ -463,6 +521,343 @@ class _AnalysisViewerState extends ConsumerState { 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 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 _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 _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; } diff --git a/lib/presentation/calendar/calendar_controller.g.dart b/lib/presentation/calendar/calendar_controller.g.dart index 48d7456..ace7c7c 100644 --- a/lib/presentation/calendar/calendar_controller.g.dart +++ b/lib/presentation/calendar/calendar_controller.g.dart @@ -7,7 +7,7 @@ part of 'calendar_controller.dart'; // ************************************************************************** String _$calendarControllerHash() => - r'747a59ba47bf4d1b6a66e3bcc82276e4ad81eb1a'; + r'd26afbe4d0a107aa6d0067e9b6f44e5ba079d37c'; /// See also [CalendarController]. @ProviderFor(CalendarController) diff --git a/lib/presentation/calendar/widgets/program_week_view.dart b/lib/presentation/calendar/widgets/program_week_view.dart index 4c8245a..86b01bf 100644 --- a/lib/presentation/calendar/widgets/program_week_view.dart +++ b/lib/presentation/calendar/widgets/program_week_view.dart @@ -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,120 +48,14 @@ class ProgramWeekView extends StatelessWidget { .toList(); return Expanded( - child: Container( - decoration: BoxDecoration( - border: dayIndex < 6 - ? const Border( - right: BorderSide( - color: Colors.grey, - width: 0.5, - ), - ) - : null, - color: dayIndex % 2 == 0 - ? Theme.of(context).colorScheme.surfaceContainerLow - : null, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHigh, - border: const Border( - bottom: BorderSide( - color: Colors.grey, - width: 0.5, - ), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - _getDayName(dayNum), - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - ), - InkWell( - onTap: () => - _showAddWorkoutSheet(context, dayNum), - borderRadius: BorderRadius.circular(16), - child: const Icon( - Icons.add_circle_outline, - size: 20, - ), - ), - ], - ), - ), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(4), - child: Column( - children: [ - if (dayWorkouts.isEmpty) - Padding( - padding: const EdgeInsets.only(top: 16.0), - child: Center( - child: Text( - "Rest", - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - fontSize: 12, - ), - ), - ), - ) - 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 - }, - ), - ), - ), - ], - ), - ), - ), - ], - ), + child: _DayColumn( + dayNum: dayNum, + dayIndex: dayIndex, + dayWorkouts: dayWorkouts, + availablePlans: availablePlans, + week: week, + onAddWorkout: onAddWorkout, + onDeleteWorkout: onDeleteWorkout, ), ); }), @@ -170,75 +66,399 @@ class ProgramWeekView extends StatelessWidget { ), ); } +} + +class _DayColumn extends StatelessWidget { + final int dayNum; + final int dayIndex; + final List dayWorkouts; + final List 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), + ) + : null, + color: dayIndex % 2 == 0 + ? Theme.of(context).colorScheme.surfaceContainerLow + : null, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + border: const Border( + bottom: BorderSide(color: Colors.grey, width: 0.5), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _getDayName(dayNum), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + InkWell( + onTap: () => _showAddWorkoutSheet(context), + borderRadius: BorderRadius.circular(16), + child: const Icon(Icons.add_circle_outline, size: 20), + ), + ], + ), + ), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(4), + child: Column( + children: [ + if (dayWorkouts.isEmpty) + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: Center( + child: Text( + 'Rest', + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + fontSize: 12, + ), + ), + ), + ) + else + ...dayWorkouts.map( + (workout) => _WorkoutCard( + workout: workout, + onDelete: () => onDeleteWorkout(workout.id), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } 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( - padding: const EdgeInsets.symmetric(vertical: 16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 8.0, - ), - child: Text( - "Select Training Plan", - style: Theme.of(context).textTheme.titleMedium, - ), - ), - const Divider(), - if (availablePlans.isEmpty) - const Padding( - padding: EdgeInsets.all(16.0), - child: Center( - child: Text("No training plans available. Create one first!"), - ), - ), - ...availablePlans - .map( - (plan) => ListTile( - leading: const Icon(Icons.fitness_center), - title: Text(plan.name), - subtitle: Text("${plan.totalExercises} exercises"), - onTap: () { - final newWorkout = ProgramWorkoutEntity( - id: IdGenerator.generate(), - programId: week.programId, - weekId: week.id, - day: dayNum.toString(), - type: 'workout', - name: plan.name, - refId: plan.id, - description: "${plan.sections.length} sections", - completed: false, - ); - onAddWorkout(newWorkout); - Navigator.pop(context); - }, - ), - ) - .toList(), - ], + 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 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), + child: Text( + 'Add to Schedule', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + 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 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!'), + ), + ); + } + + 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'), + onTap: () { + final newWorkout = ProgramWorkoutEntity( + id: IdGenerator.generate(), + programId: week.programId, + weekId: week.id, + day: dayNum.toString(), + type: 'workout', + name: plan.name, + refId: plan.id, + description: '${plan.sections.length} sections', + completed: false, + ); + onAddWorkout(newWorkout); + }, + ); + }, + ); + } +} + +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'), + ), + ), + ], + ), + ); + } +} diff --git a/lib/presentation/chat/chat_controller.dart b/lib/presentation/chat/chat_controller.dart index 81ae674..25e75eb 100644 --- a/lib/presentation/chat/chat_controller.dart +++ b/lib/presentation/chat/chat_controller.dart @@ -1,21 +1,45 @@ +import 'package:dio/dio.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:trainhub_flutter/injection.dart'; import 'package:trainhub_flutter/domain/repositories/chat_repository.dart'; +import 'package:trainhub_flutter/domain/repositories/note_repository.dart'; +import 'package:trainhub_flutter/injection.dart'; import 'package:trainhub_flutter/presentation/chat/chat_state.dart'; part 'chat_controller.g.dart'; +const _chatApiUrl = 'http://localhost:8080/v1/chat/completions'; + +/// Base system prompt that is always included. +const _baseSystemPrompt = + 'You are a helpful AI fitness assistant for personal trainers. ' + 'Help users design training plans, analyse exercise technique, ' + 'and answer questions about sports science and nutrition.'; + @riverpod class ChatController extends _$ChatController { late ChatRepository _repo; + late NoteRepository _noteRepo; + + // Shared Dio client — generous timeout for 7B models running on CPU. + final _dio = Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 30), + receiveTimeout: const Duration(minutes: 5), + ), + ); @override Future build() async { _repo = getIt(); + _noteRepo = getIt(); final sessions = await _repo.getAllSessions(); return ChatState(sessions: sessions); } + // ------------------------------------------------------------------------- + // Session management (unchanged) + // ------------------------------------------------------------------------- + Future createSession() async { final session = await _repo.createSession(); final sessions = await _repo.getAllSessions(); @@ -48,9 +72,15 @@ class ChatController extends _$ChatController { ); } + // ------------------------------------------------------------------------- + // Send message (RAG + Step D) + // ------------------------------------------------------------------------- + Future sendMessage(String content) async { final current = state.valueOrNull; if (current == null) return; + + // ── 1. Resolve / create a session ───────────────────────────────────── String sessionId; if (current.activeSession == null) { final session = await _repo.createSession(); @@ -62,6 +92,8 @@ class ChatController extends _$ChatController { } else { sessionId = current.activeSession!.id; } + + // ── 2. Persist user message & show typing indicator ─────────────────── await _repo.addMessage( sessionId: sessionId, role: 'user', @@ -69,22 +101,73 @@ 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, + ), ); - await Future.delayed(const Duration(seconds: 1)); - final String response = _getMockResponse(content); + + // ── 3. RAG: retrieve relevant chunks from the knowledge base ────────── + // Gracefully degrades — if Nomic server is unavailable or no chunks + // exist, the chat still works with the base system prompt alone. + List contextChunks = []; + try { + contextChunks = await _noteRepo.searchSimilar(content, topK: 3); + } catch (_) { + // Nomic server not running or no chunks stored — continue without RAG. + } + + // ── 4. Build enriched system prompt (Step D) ────────────────────────── + final systemPrompt = _buildSystemPrompt(contextChunks); + + // Build the full conversation history so the model maintains context. + final history = messagesAfterUser + .map( + (m) => { + 'role': m.isUser ? 'user' : 'assistant', + 'content': m.content, + }, + ) + .toList(); + + // ── 5. POST to Qwen (http://localhost:8080/v1/chat/completions) ──────── + String aiResponse; + try { + final response = await _dio.post>( + _chatApiUrl, + data: { + 'messages': [ + {'role': 'system', 'content': systemPrompt}, + ...history, + ], + 'temperature': 0.7, + }, + ); + aiResponse = + response.data!['choices'][0]['message']['content'] as String; + } on DioException catch (e) { + aiResponse = + 'Could not reach the AI server (${e.message}). ' + 'Make sure AI models are downloaded and the inference servers have ' + 'had time to start.'; + } catch (e) { + aiResponse = 'An unexpected error occurred: $e'; + } + + // ── 6. Persist response & update session title on first exchange ─────── 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 = + content.length > 30 ? '${content.substring(0, 30)}…' : content; await _repo.updateSessionTitle(sessionId, title); } + final sessions = await _repo.getAllSessions(); state = AsyncValue.data( state.valueOrNull!.copyWith( @@ -95,15 +178,25 @@ class ChatController extends _$ChatController { ); } - 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."; - } - return "I'm your AI training assistant. How can I help you today?"; + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /// Builds the system prompt, injecting RAG context when available. + static String _buildSystemPrompt(List chunks) { + if (chunks.isEmpty) return _baseSystemPrompt; + + final contextBlock = chunks + .asMap() + .entries + .map((e) => '[${e.key + 1}] ${e.value}') + .join('\n\n'); + + return '$_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.'; } } diff --git a/lib/presentation/chat/chat_controller.g.dart b/lib/presentation/chat/chat_controller.g.dart index adc9aff..d85f627 100644 --- a/lib/presentation/chat/chat_controller.g.dart +++ b/lib/presentation/chat/chat_controller.g.dart @@ -6,7 +6,7 @@ part of 'chat_controller.dart'; // RiverpodGenerator // ************************************************************************** -String _$chatControllerHash() => r'44a3d0e906eaad16f7a9c292fe847b8bd144c835'; +String _$chatControllerHash() => r'06ffc6b53c1d878ffc0a758da4f7ee1261ae1340'; /// See also [ChatController]. @ProviderFor(ChatController) diff --git a/lib/presentation/chat/chat_page.dart b/lib/presentation/chat/chat_page.dart index 4fcb123..dfd46bc 100644 --- a/lib/presentation/chat/chat_page.dart +++ b/lib/presentation/chat/chat_page.dart @@ -3,12 +3,15 @@ import 'dart:math' as math; import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; import 'package:trainhub_flutter/core/constants/ui_constants.dart'; +import 'package:trainhub_flutter/core/router/app_router.dart'; import 'package:trainhub_flutter/core/theme/app_colors.dart'; import 'package:trainhub_flutter/domain/entities/chat_message.dart'; import 'package:trainhub_flutter/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/settings/ai_model_settings_controller.dart'; @RoutePage() class ChatPage extends ConsumerStatefulWidget { @@ -72,6 +75,13 @@ class _ChatPageState extends ConsumerState { @override Widget build(BuildContext context) { + // ── Gate: check whether AI models are present on disk ───────────────── + final modelsValidated = ref + .watch(aiModelSettingsControllerProvider) + .areModelsValidated; + + // Watch chat state regardless of gate so Riverpod keeps the provider + // alive and the scroll listener is always registered. final state = ref.watch(chatControllerProvider); final controller = ref.read(chatControllerProvider.notifier); @@ -81,22 +91,28 @@ class _ChatPageState extends ConsumerState { 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(); } }); + // ── Show "models missing" placeholder ───────────────────────────────── + if (!modelsValidated) { + return const Scaffold( + backgroundColor: AppColors.surface, + body: _MissingModelsState(), + ); + } + + // ── Normal chat UI ───────────────────────────────────────────────────── 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)), ], ), ); @@ -119,7 +135,6 @@ class _ChatPageState extends ConsumerState { ), child: Column( children: [ - // New Chat button Padding( padding: const EdgeInsets.all(UIConstants.spacing12), child: SizedBox( @@ -130,7 +145,6 @@ class _ChatPageState extends ConsumerState { const Divider(height: 1, color: AppColors.border), - // Session list Expanded( child: asyncState.when( data: (data) { @@ -168,7 +182,10 @@ class _ChatPageState extends ConsumerState { error: (_, __) => Center( child: Text( 'Error loading sessions', - style: TextStyle(color: AppColors.textMuted, fontSize: 13), + style: TextStyle( + color: AppColors.textMuted, + fontSize: 13, + ), ), ), loading: () => const Center( @@ -198,9 +215,7 @@ class _ChatPageState extends ConsumerState { 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,7 +235,8 @@ class _ChatPageState extends ConsumerState { : 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), @@ -251,7 +267,6 @@ class _ChatPageState extends ConsumerState { ), ), ), - // Delete button appears on hover AnimatedOpacity( duration: const Duration(milliseconds: 150), opacity: isHovered ? 1.0 : 0.0, @@ -290,21 +305,17 @@ class _ChatPageState extends ConsumerState { ) { 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( horizontal: UIConstants.spacing24, vertical: UIConstants.spacing16, ), - itemCount: - data.messages.length + (data.isTyping ? 1 : 0), + itemCount: data.messages.length + (data.isTyping ? 1 : 0), itemBuilder: (context, index) { if (index == data.messages.length) { return const _TypingIndicator(); @@ -349,8 +360,6 @@ class _ChatPageState extends ConsumerState { ), ), ), - - // Input area _buildInputBar(asyncState, controller), ], ); @@ -386,10 +395,7 @@ class _ChatPageState extends ConsumerState { 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), ), ], ), @@ -408,9 +414,7 @@ class _ChatPageState extends ConsumerState { 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, @@ -469,7 +473,8 @@ class _ChatPageState extends ConsumerState { onTap: isTyping ? null : () => _sendMessage(controller), child: Icon( Icons.arrow_upward_rounded, - color: isTyping ? AppColors.textMuted : AppColors.zinc950, + color: + isTyping ? AppColors.textMuted : AppColors.zinc950, size: 20, ), ), @@ -481,6 +486,124 @@ class _ChatPageState extends ConsumerState { } } +// ============================================================================= +// Missing Models State — shown when AI files have not been downloaded yet +// ============================================================================= +class _MissingModelsState extends StatelessWidget { + const _MissingModelsState(); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Icon + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: AppColors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(18), + ), + child: const Icon( + Icons.cloud_download_outlined, + size: 36, + color: AppColors.textMuted, + ), + ), + + const SizedBox(height: UIConstants.spacing24), + + Text( + 'AI models are missing.', + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + letterSpacing: -0.2, + ), + ), + + const SizedBox(height: UIConstants.spacing8), + + Text( + 'Please download them to use the AI chat.', + style: GoogleFonts.inter( + fontSize: 14, + color: AppColors.textSecondary, + ), + ), + + const SizedBox(height: UIConstants.spacing32), + + // "Go to Settings" button + _GoToSettingsButton(), + ], + ), + ); + } +} + +class _GoToSettingsButton extends StatefulWidget { + @override + State<_GoToSettingsButton> createState() => _GoToSettingsButtonState(); +} + +class _GoToSettingsButtonState extends State<_GoToSettingsButton> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: AnimatedContainer( + duration: UIConstants.animationDuration, + height: 44, + decoration: BoxDecoration( + color: _hovered + ? AppColors.accent.withValues(alpha: 0.85) + : AppColors.accent, + borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: + BorderRadius.circular(UIConstants.smallBorderRadius), + onTap: () => context.router.push(const SettingsRoute()), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: UIConstants.spacing24, + ), + 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, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + // ============================================================================= // New Chat Button // ============================================================================= @@ -507,7 +630,8 @@ class _NewChatButtonState extends State<_NewChatButton> { color: _isHovered ? AppColors.zinc700 : AppColors.surfaceContainerHigh, - borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), + borderRadius: + BorderRadius.circular(UIConstants.smallBorderRadius), border: Border.all(color: AppColors.border, width: 1), ), child: Material( @@ -590,8 +714,9 @@ class _MessageBubble extends StatelessWidget { ], Flexible( child: Column( - crossAxisAlignment: - isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, + crossAxisAlignment: isUser + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, children: [ Container( constraints: BoxConstraints(maxWidth: maxWidth), @@ -606,10 +731,12 @@ class _MessageBubble extends StatelessWidget { 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), + bottomLeft: isUser + ? const Radius.circular(16) + : const Radius.circular(4), + bottomRight: isUser + ? const Radius.circular(4) + : const Radius.circular(16), ), border: isUser ? null @@ -617,7 +744,7 @@ class _MessageBubble extends StatelessWidget { ), child: SelectableText( message.content, - style: TextStyle( + style: const TextStyle( color: AppColors.textPrimary, fontSize: 14, height: 1.5, @@ -731,17 +858,12 @@ class _TypingIndicatorState extends State<_TypingIndicator> 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, - ), + padding: EdgeInsets.only(left: index == 0 ? 0 : 4), child: Transform.translate( offset: Offset(0, -bounce.abs()), child: Container( diff --git a/lib/presentation/plan_editor/models/exercise_drag_data.dart b/lib/presentation/plan_editor/models/exercise_drag_data.dart new file mode 100644 index 0000000..d659a3d --- /dev/null +++ b/lib/presentation/plan_editor/models/exercise_drag_data.dart @@ -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; +} diff --git a/lib/presentation/plan_editor/plan_editor_controller.dart b/lib/presentation/plan_editor/plan_editor_controller.dart index 6f2edd5..a202678 100644 --- a/lib/presentation/plan_editor/plan_editor_controller.dart +++ b/lib/presentation/plan_editor/plan_editor_controller.dart @@ -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 build(String planId) async { _planRepo = getIt(); - final ExerciseRepository exerciseRepo = getIt(); + _exerciseRepo = getIt(); 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.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.from(current.plan.sections); + final fromExercises = List.from( + sections[fromSectionIndex].exercises, + ); + final toExercises = List.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 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 save() async { final current = state.valueOrNull; if (current == null) return; diff --git a/lib/presentation/plan_editor/plan_editor_controller.g.dart b/lib/presentation/plan_editor/plan_editor_controller.g.dart index 1d901a5..a6138e3 100644 --- a/lib/presentation/plan_editor/plan_editor_controller.g.dart +++ b/lib/presentation/plan_editor/plan_editor_controller.g.dart @@ -7,7 +7,7 @@ part of 'plan_editor_controller.dart'; // ************************************************************************** String _$planEditorControllerHash() => - r'4045493829126f28b3a58695b68ade53519c1412'; + r'6c6c2f74725e250bd41401cab12c1a62306d10ea'; /// Copied from Dart SDK class _SystemHash { diff --git a/lib/presentation/plan_editor/plan_editor_page.dart b/lib/presentation/plan_editor/plan_editor_page.dart index 68bcc28..7a6ad2c 100644 --- a/lib/presentation/plan_editor/plan_editor_page.dart +++ b/lib/presentation/plan_editor/plan_editor_page.dart @@ -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,64 +19,182 @@ class PlanEditorPage extends ConsumerWidget { final controller = ref.read(planEditorControllerProvider(planId).notifier); return Scaffold( - appBar: AppBar( - title: state.when( - data: (data) => TextField( - controller: TextEditingController(text: data.plan.name) - ..selection = TextSelection.fromPosition( - TextPosition(offset: data.plan.name.length), - ), - decoration: const InputDecoration( - border: InputBorder.none, - hintText: 'Plan Name', + backgroundColor: AppColors.surface, + body: Column( + children: [ + // --- Custom header bar --- + Container( + decoration: const BoxDecoration( + color: AppColors.surfaceContainer, + border: Border(bottom: BorderSide(color: AppColors.border)), ), - style: Theme.of(context).textTheme.titleLarge, - onChanged: controller.updatePlanName, - ), - error: (_, __) => const Text('Error'), - loading: () => const Text('Loading...'), - ), - actions: [ - state.maybeWhen( - data: (data) => IconButton( - icon: Icon( - Icons.save, - color: data.isDirty ? Theme.of(context).primaryColor : null, - ), - onPressed: data.isDirty ? () => controller.save() : null, + padding: const EdgeInsets.symmetric( + horizontal: UIConstants.spacing24, + vertical: UIConstants.spacing12, ), - 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( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: ElevatedButton.icon( - onPressed: controller.addSection, - icon: const Icon(Icons.add), - label: const Text('Add Section'), + 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, + ), + ), ), ), - ); - } - final section = data.plan.sections[index]; - return PlanSectionCard( - section: section, - sectionIndex: index, - plan: data.plan, - availableExercises: data.availableExercises, - ); - }, - ), - error: (e, s) => Center(child: Text('Error: $e')), - loading: () => const Center(child: CircularProgressIndicator()), + 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( + TextPosition(offset: data.plan.name.length), + ), + decoration: const InputDecoration( + border: InputBorder.none, + 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, + ), + onChanged: controller.updatePlanName, + ), + orElse: () => Text( + 'Loading...', + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w600, + color: AppColors.textMuted, + ), + ), + ), + ), + + // Unsaved changes badge + save button + state.maybeWhen( + data: (data) => data.isDirty + ? Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: UIConstants.spacing12, + vertical: 4, + ), + 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 --- + Expanded( + child: state.when( + data: (data) => ReorderableListView.builder( + padding: const EdgeInsets.all(UIConstants.spacing24), + onReorder: controller.reorderSection, + footer: Center( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: UIConstants.spacing16, + ), + child: OutlinedButton.icon( + onPressed: controller.addSection, + icon: const Icon(Icons.add, size: 16), + label: const Text('Add Section'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.textSecondary, + side: const BorderSide(color: AppColors.border), + padding: const EdgeInsets.symmetric( + horizontal: UIConstants.spacing24, + vertical: UIConstants.spacing12, + ), + ), + ), + ), + ), + itemCount: data.plan.sections.length, + itemBuilder: (context, index) { + final section = data.plan.sections[index]; + return PlanSectionCard( + key: ValueKey(section.id), + section: section, + sectionIndex: index, + plan: data.plan, + availableExercises: data.availableExercises, + ); + }, + ), + error: (e, s) => Center( + child: Text( + 'Error: $e', + style: const TextStyle(color: AppColors.destructive), + ), + ), + loading: () => const Center(child: CircularProgressIndicator()), + ), + ), + ], ), ); } diff --git a/lib/presentation/plan_editor/widgets/plan_exercise_tile.dart b/lib/presentation/plan_editor/widgets/plan_exercise_tile.dart index f310900..6cb415f 100644 --- a/lib/presentation/plan_editor/widgets/plan_exercise_tile.dart +++ b/lib/presentation/plan_editor/widgets/plan_exercise_tile.dart @@ -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 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 { + 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( + 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, ), ), - IconButton( - icon: const Icon(Icons.close, size: 18), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - onPressed: () => controller.removeExerciseFromSection( - sectionIndex, - exerciseIndex, + 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, + ), + ), ), ), ], ), 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: Container( - padding: const EdgeInsets.all(8), + child: AnimatedContainer( + duration: UIConstants.animationDuration, + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 6, + ), 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); }, ), ), diff --git a/lib/presentation/plan_editor/widgets/plan_section_card.dart b/lib/presentation/plan_editor/widgets/plan_section_card.dart index ab5b667..f0b52bc 100644 --- a/lib/presentation/plan_editor/widgets/plan_section_card.dart +++ b/lib/presentation/plan_editor/widgets/plan_section_card.dart @@ -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,73 +25,251 @@ class PlanSectionCard extends ConsumerWidget { }); @override - Widget build(BuildContext context, WidgetRef ref) { - final controller = ref.read(planEditorControllerProvider(plan.id).notifier); + ConsumerState createState() => _PlanSectionCardState(); +} - return Card( - margin: const EdgeInsets.only(bottom: 16), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( +class _PlanSectionCardState extends ConsumerState { + bool _isDragOver = false; + bool _isHovered = false; + + @override + Widget build(BuildContext context) { + final controller = ref.read( + planEditorControllerProvider(widget.plan.id).notifier, + ); + + return DragTarget( + 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: [ - Expanded( - child: TextField( - controller: TextEditingController(text: section.name) - ..selection = TextSelection.fromPosition( - TextPosition(offset: section.name.length), + // --- 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, ), - decoration: const InputDecoration( - border: InputBorder.none, - hintText: 'Section Name', - isDense: true, - ), - style: Theme.of(context).textTheme.titleMedium, - onChanged: (val) => - controller.updateSectionName(sectionIndex, val), + const SizedBox(width: UIConstants.spacing8), + Expanded( + child: TextField( + controller: TextEditingController( + text: widget.section.name, + )..selection = TextSelection.fromPosition( + TextPosition( + offset: widget.section.name.length, + ), + ), + decoration: const InputDecoration( + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + filled: false, + hintText: 'Section name', + isDense: true, + contentPadding: EdgeInsets.zero, + ), + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + onChanged: (val) => controller.updateSectionName( + widget.sectionIndex, + val, + ), + ), + ), + // 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), + ], ), ), - IconButton( - icon: const Icon(Icons.delete_outline), - onPressed: () => controller.deleteSection(sectionIndex), + + 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, + ), + ), + ], + ), + ), + ), + + // --- 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: widget.section.exercises.length, + onReorder: (oldIndex, newIndex) => + controller.reorderExercise( + widget.sectionIndex, + oldIndex, + newIndex, + ), + itemBuilder: (context, index) { + final exercise = widget.section.exercises[index]; + return PlanExerciseTile( + key: ValueKey(exercise.instanceId), + exercise: exercise, + sectionIndex: widget.sectionIndex, + exerciseIndex: index, + plan: widget.plan, + ); + }, + ), + ), + + // --- 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( + widget.sectionIndex, + exercise, + ); + }), + ), + const SizedBox(width: UIConstants.spacing8), + _ActionButton( + icon: Icons.create_outlined, + label: 'Create New', + onTap: () => + _showCreateExerciseDialog(context, controller), + ), + ], + ), ), ], ), - const Divider(), - ReorderableListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: section.exercises.length, - onReorder: (oldIndex, newIndex) => - controller.reorderExercise(sectionIndex, oldIndex, newIndex), - itemBuilder: (context, index) { - final exercise = section.exercises[index]; - return PlanExerciseTile( - key: ValueKey(exercise.instanceId), - exercise: exercise, - sectionIndex: sectionIndex, - exerciseIndex: index, - plan: plan, - ); - }, - ), - const SizedBox(height: 8), - Center( - child: TextButton.icon( - onPressed: () { - _showExercisePicker(context, (exercise) { - controller.addExerciseToSection(sectionIndex, exercise); - }); - }, - icon: const Icon(Icons.add), - label: const Text('Add Exercise'), - ), - ), - ], - ), - ), + ), + ); + }, ); } @@ -107,22 +289,77 @@ class PlanSectionCard extends ConsumerWidget { builder: (context, scrollController) { return Column( children: [ - Padding( - padding: const EdgeInsets.all(16.0), - child: Text( - 'Select Exercise', - style: Theme.of(context).textTheme.titleLarge, + 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: 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, + ), + ), + ], + ), + ), + ), + ); + } } diff --git a/lib/presentation/settings/ai_model_settings_controller.dart b/lib/presentation/settings/ai_model_settings_controller.dart new file mode 100644 index 0000000..b1588fe --- /dev/null +++ b/lib/presentation/settings/ai_model_settings_controller.dart @@ -0,0 +1,263 @@ +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/presentation/settings/ai_model_settings_state.dart'; + +part 'ai_model_settings_controller.g.dart'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const _llamaBuild = 'b8130'; + +const _nomicModelFile = 'nomic-embed-text-v1.5.Q4_K_M.gguf'; +const _qwenModelFile = 'qwen2.5-7b-instruct-q4_k_m.gguf'; + +const _nomicModelUrl = + 'https://huggingface.co/nomic-ai/nomic-embed-text-v1.5-GGUF/resolve/main/nomic-embed-text-v1.5.Q4_K_M.gguf'; +const _qwenModelUrl = + 'https://huggingface.co/Qwen/Qwen2.5-7B-Instruct-GGUF/resolve/main/qwen2.5-7b-instruct-q4_k_m.gguf'; + +// --------------------------------------------------------------------------- +// Platform helpers +// --------------------------------------------------------------------------- + +/// Returns the llama.cpp archive download URL for the current platform. +/// Throws [UnsupportedError] if the platform is not supported. +Future _llamaArchiveUrl() async { + if (Platform.isMacOS) { + // Detect CPU architecture via `uname -m` + final result = await Process.run('uname', ['-m']); + final arch = (result.stdout as String).trim(); + if (arch == 'arm64') { + return 'https://github.com/ggml-org/llama.cpp/releases/download/$_llamaBuild/llama-$_llamaBuild-bin-macos-arm64.tar.gz'; + } else { + return 'https://github.com/ggml-org/llama.cpp/releases/download/$_llamaBuild/llama-$_llamaBuild-bin-macos-x64.tar.gz'; + } + } else if (Platform.isWindows) { + return 'https://github.com/ggml-org/llama.cpp/releases/download/$_llamaBuild/llama-$_llamaBuild-bin-win-vulkan-x64.zip'; + } else if (Platform.isLinux) { + return 'https://github.com/ggml-org/llama.cpp/releases/download/$_llamaBuild/llama-$_llamaBuild-bin-ubuntu-vulkan-x64.tar.gz'; + } + throw UnsupportedError('Unsupported platform: ${Platform.operatingSystem}'); +} + +/// The expected llama-server binary name for the current platform. +String get _serverBinaryName => + Platform.isWindows ? 'llama-server.exe' : 'llama-server'; + +// --------------------------------------------------------------------------- +// Controller +// --------------------------------------------------------------------------- + +@riverpod +class AiModelSettingsController extends _$AiModelSettingsController { + final _dio = Dio(); + + @override + AiModelSettingsState build() => const AiModelSettingsState(); + + // ------------------------------------------------------------------------- + // Validation + // ------------------------------------------------------------------------- + + /// Checks whether all required files exist on disk and updates + /// [AiModelSettingsState.areModelsValidated]. + Future validateModels() async { + state = state.copyWith( + currentTask: 'Checking installed files…', + errorMessage: null, + ); + + try { + final dir = await getApplicationDocumentsDirectory(); + final base = dir.path; + + final serverBin = File(p.join(base, _serverBinaryName)); + final nomicModel = File(p.join(base, _nomicModelFile)); + final qwenModel = File(p.join(base, _qwenModelFile)); + + final validated = serverBin.existsSync() && + 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(), + ); + } + } + + // ------------------------------------------------------------------------- + // Download & Install + // ------------------------------------------------------------------------- + + /// Downloads and installs the llama.cpp binary and both model files. + Future downloadAll() async { + if (state.isDownloading) return; + + state = state.copyWith( + isDownloading: true, + progress: 0.0, + areModelsValidated: false, + errorMessage: null, + ); + + try { + final dir = await getApplicationDocumentsDirectory(); + + // -- 1. llama.cpp binary ----------------------------------------------- + final archiveUrl = await _llamaArchiveUrl(); + final archiveExt = archiveUrl.endsWith('.zip') ? '.zip' : '.tar.gz'; + final archivePath = p.join(dir.path, 'llama_binary$archiveExt'); + + await _downloadFile( + url: archiveUrl, + savePath: archivePath, + 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); + + // Clean up the archive once extracted + final archiveFile = File(archivePath); + if (archiveFile.existsSync()) archiveFile.deleteSync(); + + // -- 2. Nomic embedding model ------------------------------------------ + await _downloadFile( + url: _nomicModelUrl, + savePath: p.join(dir.path, _nomicModelFile), + taskLabel: 'Downloading Nomic embedding model…', + overallStart: 0.2, + overallEnd: 0.55, + ); + + // -- 3. Qwen chat model ------------------------------------------------ + await _downloadFile( + url: _qwenModelUrl, + savePath: p.join(dir.path, _qwenModelFile), + 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(), + ); + } + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + /// Downloads a single file with progress mapped into [overallStart]..[overallEnd]. + Future _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), + ), + ); + } + + /// Extracts the downloaded archive and moves `llama-server[.exe]` to [destDir]. + Future _extractBinary(String archivePath, String destDir) async { + final extractDir = p.join(destDir, '_llama_extract_tmp'); + final extractDirObj = Directory(extractDir); + if (extractDirObj.existsSync()) extractDirObj.deleteSync(recursive: true); + extractDirObj.createSync(recursive: true); + + try { + if (archivePath.endsWith('.zip')) { + await extractFileToDisk(archivePath, extractDir); + } else { + // .tar.gz — use extractFileToDisk which handles both via the archive package + await extractFileToDisk(archivePath, extractDir); + } + + // Walk the extracted tree to find the server binary + final binary = _findFile(extractDirObj, _serverBinaryName); + if (binary == null) { + throw FileSystemException( + 'llama-server binary not found in archive.', + archivePath, + ); + } + + final destBin = p.join(destDir, _serverBinaryName); + binary.copySync(destBin); + + // Make executable on POSIX systems + if (Platform.isMacOS || Platform.isLinux) { + await Process.run('chmod', ['+x', destBin]); + } + } finally { + // Always clean up the temp extraction directory + if (extractDirObj.existsSync()) { + extractDirObj.deleteSync(recursive: true); + } + } + } + + /// Recursively searches [dir] for a file named [name]. + File? _findFile(Directory dir, String name) { + for (final entity in dir.listSync(recursive: true)) { + if (entity is File && p.basename(entity.path) == name) { + return entity; + } + } + return null; + } +} diff --git a/lib/presentation/settings/ai_model_settings_controller.g.dart b/lib/presentation/settings/ai_model_settings_controller.g.dart new file mode 100644 index 0000000..d1dcdee --- /dev/null +++ b/lib/presentation/settings/ai_model_settings_controller.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'ai_model_settings_controller.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$aiModelSettingsControllerHash() => + r'5bf80e85e734016b0fa80c6bb84315925f2595b3'; + +/// 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; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/presentation/settings/ai_model_settings_state.dart b/lib/presentation/settings/ai_model_settings_state.dart new file mode 100644 index 0000000..573cff8 --- /dev/null +++ b/lib/presentation/settings/ai_model_settings_state.dart @@ -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; +} diff --git a/lib/presentation/settings/ai_model_settings_state.freezed.dart b/lib/presentation/settings/ai_model_settings_state.freezed.dart new file mode 100644 index 0000000..c3b0aa1 --- /dev/null +++ b/lib/presentation/settings/ai_model_settings_state.freezed.dart @@ -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 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 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; +} diff --git a/lib/presentation/settings/knowledge_base_controller.dart b/lib/presentation/settings/knowledge_base_controller.dart new file mode 100644 index 0000000..0a316cb --- /dev/null +++ b/lib/presentation/settings/knowledge_base_controller.dart @@ -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(); + // Load the current chunk count asynchronously after first build. + _loadCount(); + return const KnowledgeBaseState(); + } + + // ------------------------------------------------------------------------- + // Load chunk count + // ------------------------------------------------------------------------- + + Future _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 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 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'; + } +} diff --git a/lib/presentation/settings/knowledge_base_controller.g.dart b/lib/presentation/settings/knowledge_base_controller.g.dart new file mode 100644 index 0000000..95bb468 --- /dev/null +++ b/lib/presentation/settings/knowledge_base_controller.g.dart @@ -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; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/presentation/settings/knowledge_base_page.dart b/lib/presentation/settings/knowledge_base_page.dart new file mode 100644 index 0000000..c0556bc --- /dev/null +++ b/lib/presentation/settings/knowledge_base_page.dart @@ -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 createState() => _KnowledgeBasePageState(); +} + +class _KnowledgeBasePageState extends ConsumerState { + final _textController = TextEditingController(); + + @override + void dispose() { + _textController.dispose(); + super.dispose(); + } + + Future _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 _clear(KnowledgeBaseController controller) async { + final confirmed = await _showConfirmDialog(); + if (!confirmed) return; + await controller.clearKnowledgeBase(); + } + + Future _showConfirmDialog() async { + return await showDialog( + 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(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, + ), + ), + ), + ); + } +} diff --git a/lib/presentation/settings/knowledge_base_state.dart b/lib/presentation/settings/knowledge_base_state.dart new file mode 100644 index 0000000..16d95db --- /dev/null +++ b/lib/presentation/settings/knowledge_base_state.dart @@ -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; +} diff --git a/lib/presentation/settings/knowledge_base_state.freezed.dart b/lib/presentation/settings/knowledge_base_state.freezed.dart new file mode 100644 index 0000000..c2b8480 --- /dev/null +++ b/lib/presentation/settings/knowledge_base_state.freezed.dart @@ -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 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 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; +} diff --git a/lib/presentation/settings/settings_page.dart b/lib/presentation/settings/settings_page.dart new file mode 100644 index 0000000..e8020f3 --- /dev/null +++ b/lib/presentation/settings/settings_page.dart @@ -0,0 +1,697 @@ +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/core/router/app_router.dart'; +import 'package:trainhub_flutter/presentation/settings/ai_model_settings_controller.dart'; +import 'package:trainhub_flutter/presentation/settings/ai_model_settings_state.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: [ + // ── Top bar ────────────────────────────────────────────────────── + _TopBar(onBack: () => context.router.maybePop()), + + // ── Scrollable 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: [ + // Page title + Text( + 'Settings', + style: GoogleFonts.inter( + fontSize: 22, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + letterSpacing: -0.3, + ), + ), + const SizedBox(height: UIConstants.spacing32), + + // AI Models section + _AiModelsSection( + modelState: modelState, + onDownload: controller.downloadAll, + onValidate: controller.validateModels, + ), + + const SizedBox(height: UIConstants.spacing32), + + // Knowledge Base section + _KnowledgeBaseSection( + onTap: () => context.router + .push(const KnowledgeBaseRoute()), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +// ============================================================================= +// Top bar +// ============================================================================= +class _TopBar extends StatelessWidget { + const _TopBar({required this.onBack}); + + final VoidCallback onBack; + + @override + Widget build(BuildContext context) { + return Container( + height: 52, + padding: const EdgeInsets.symmetric(horizontal: UIConstants.spacing16), + decoration: const BoxDecoration( + color: AppColors.surfaceContainer, + border: Border(bottom: BorderSide(color: AppColors.border)), + ), + child: Row( + children: [ + _IconBtn( + icon: Icons.arrow_back_rounded, + tooltip: 'Go back', + onTap: onBack, + ), + const SizedBox(width: UIConstants.spacing12), + Text( + 'Settings', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ], + ), + ); + } +} + +// ============================================================================= +// AI Models section +// ============================================================================= +class _AiModelsSection extends StatelessWidget { + const _AiModelsSection({ + required this.modelState, + required this.onDownload, + required this.onValidate, + }); + + final AiModelSettingsState modelState; + final VoidCallback onDownload; + final VoidCallback onValidate; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Section heading + Text( + 'AI Models', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textMuted, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: UIConstants.spacing12), + + // Card + Container( + decoration: BoxDecoration( + color: AppColors.surfaceContainer, + borderRadius: BorderRadius.circular(UIConstants.borderRadius), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Status rows + const _ModelRow( + name: 'llama-server binary', + description: 'llama.cpp inference server (build b8130)', + icon: Icons.terminal_rounded, + ), + const Divider(height: 1, color: AppColors.border), + const _ModelRow( + name: 'Nomic Embed v1.5 Q4_K_M', + description: 'Text embedding model (~300 MB)', + icon: Icons.hub_outlined, + ), + const Divider(height: 1, color: AppColors.border), + const _ModelRow( + name: 'Qwen 2.5 7B Instruct Q4_K_M', + description: 'Chat / reasoning model (~4.7 GB)', + icon: Icons.psychology_outlined, + ), + + // Divider before status / actions + const Divider(height: 1, color: AppColors.border), + + Padding( + padding: const EdgeInsets.all(UIConstants.spacing16), + child: _StatusAndActions( + modelState: modelState, + onDownload: onDownload, + onValidate: onValidate, + ), + ), + ], + ), + ), + ], + ); + } +} + +// ============================================================================= +// Single model info row +// ============================================================================= +class _ModelRow extends StatelessWidget { + const _ModelRow({ + required this.name, + required this.description, + required this.icon, + }); + + final String name; + final String description; + final IconData icon; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: UIConstants.spacing16, + vertical: UIConstants.spacing12, + ), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: AppColors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, size: 16, color: AppColors.textSecondary), + ), + const SizedBox(width: UIConstants.spacing12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + description, + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textMuted, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +// ============================================================================= +// Status badge + action buttons +// ============================================================================= +class _StatusAndActions extends StatelessWidget { + const _StatusAndActions({ + required this.modelState, + required this.onDownload, + required this.onValidate, + }); + + final AiModelSettingsState modelState; + final VoidCallback onDownload; + final VoidCallback onValidate; + + @override + Widget build(BuildContext context) { + // While downloading, show progress UI + if (modelState.isDownloading) { + return _DownloadingView(modelState: modelState); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Status badge + _StatusBadge(validated: modelState.areModelsValidated), + + if (modelState.errorMessage != null) ...[ + const SizedBox(height: UIConstants.spacing12), + _ErrorRow(message: modelState.errorMessage!), + ], + + const SizedBox(height: UIConstants.spacing16), + + // Action buttons + if (!modelState.areModelsValidated) + _ActionButton( + label: 'Download AI Models (~5 GB)', + icon: Icons.download_rounded, + color: AppColors.accent, + textColor: AppColors.zinc950, + onPressed: onDownload, + ) + else + _ActionButton( + label: 'Re-validate Files', + icon: Icons.verified_outlined, + color: Colors.transparent, + textColor: AppColors.textSecondary, + borderColor: AppColors.border, + onPressed: onValidate, + ), + ], + ); + } +} + +class _StatusBadge extends StatelessWidget { + const _StatusBadge({required this.validated}); + + final bool validated; + + @override + Widget build(BuildContext context) { + final color = validated ? AppColors.success : AppColors.textMuted; + final bgColor = + validated ? AppColors.successMuted : AppColors.surfaceContainerHigh; + final label = validated ? 'Ready' : 'Missing'; + final icon = + validated ? Icons.check_circle_outline : Icons.radio_button_unchecked; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Status: ', + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + ), + ), + const SizedBox(width: UIConstants.spacing4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 13, color: color), + const SizedBox(width: 5), + Text( + label, + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ], + ), + ), + ], + ); + } +} + +class _DownloadingView extends StatelessWidget { + const _DownloadingView({required this.modelState}); + + final AiModelSettingsState modelState; + + @override + Widget build(BuildContext context) { + final pct = (modelState.progress * 100).toStringAsFixed(1); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + modelState.currentTask, + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Text( + '$pct %', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.accent, + ), + ), + ], + ), + const SizedBox(height: UIConstants.spacing8), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: modelState.progress, + minHeight: 6, + backgroundColor: AppColors.zinc800, + valueColor: + const AlwaysStoppedAnimation(AppColors.accent), + ), + ), + if (modelState.errorMessage != null) ...[ + const SizedBox(height: UIConstants.spacing12), + _ErrorRow(message: modelState.errorMessage!), + ], + ], + ); + } +} + +class _ErrorRow extends StatelessWidget { + const _ErrorRow({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.error_outline_rounded, + color: AppColors.destructive, + size: 14, + ), + const SizedBox(width: UIConstants.spacing8), + Expanded( + child: Text( + message, + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.destructive, + height: 1.4, + ), + ), + ), + ], + ); + } +} + +class _ActionButton extends StatefulWidget { + const _ActionButton({ + required this.label, + required this.icon, + required this.color, + required this.textColor, + required this.onPressed, + this.borderColor, + }); + + final String label; + final IconData icon; + final Color color; + final Color textColor; + final Color? borderColor; + final VoidCallback onPressed; + + @override + State<_ActionButton> createState() => _ActionButtonState(); +} + +class _ActionButtonState extends State<_ActionButton> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final hasBorder = widget.borderColor != null; + + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: AnimatedContainer( + duration: UIConstants.animationDuration, + height: 40, + decoration: BoxDecoration( + color: hasBorder + ? (_hovered ? AppColors.zinc800 : Colors.transparent) + : (_hovered + ? widget.color.withValues(alpha: 0.85) + : widget.color), + borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), + border: hasBorder + ? Border.all(color: widget.borderColor!) + : null, + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: + BorderRadius.circular(UIConstants.smallBorderRadius), + onTap: widget.onPressed, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: UIConstants.spacing16, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(widget.icon, size: 16, color: widget.textColor), + const SizedBox(width: UIConstants.spacing8), + Text( + widget.label, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + color: widget.textColor, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +// ============================================================================= +// Generic icon button +// ============================================================================= +class _IconBtn extends StatefulWidget { + const _IconBtn({ + required this.icon, + required this.onTap, + this.tooltip = '', + }); + + final IconData icon; + final VoidCallback onTap; + final String tooltip; + + @override + State<_IconBtn> createState() => _IconBtnState(); +} + +class _IconBtnState extends State<_IconBtn> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + return Tooltip( + message: widget.tooltip, + child: MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + duration: UIConstants.animationDuration, + width: 32, + height: 32, + decoration: BoxDecoration( + color: _hovered ? AppColors.zinc800 : Colors.transparent, + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + widget.icon, + size: 18, + color: + _hovered ? AppColors.textPrimary : AppColors.textSecondary, + ), + ), + ), + ), + ); + } +} + +// ============================================================================= +// Knowledge Base navigation section +// ============================================================================= +class _KnowledgeBaseSection extends StatelessWidget { + const _KnowledgeBaseSection({required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Knowledge Base', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textMuted, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: UIConstants.spacing12), + _KnowledgeBaseCard(onTap: onTap), + ], + ); + } +} + +class _KnowledgeBaseCard extends StatefulWidget { + const _KnowledgeBaseCard({required this.onTap}); + + final VoidCallback onTap; + + @override + State<_KnowledgeBaseCard> createState() => _KnowledgeBaseCardState(); +} + +class _KnowledgeBaseCardState extends State<_KnowledgeBaseCard> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + duration: UIConstants.animationDuration, + decoration: BoxDecoration( + color: _hovered + ? AppColors.surfaceContainerHigh + : AppColors.surfaceContainer, + borderRadius: BorderRadius.circular(UIConstants.borderRadius), + border: Border.all( + color: _hovered + ? AppColors.accent.withValues(alpha: 0.3) + : AppColors.border, + ), + ), + padding: const EdgeInsets.symmetric( + horizontal: UIConstants.spacing16, + vertical: UIConstants.spacing16, + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppColors.accentMuted, + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.hub_outlined, + color: AppColors.accent, + size: 20, + ), + ), + const SizedBox(width: UIConstants.spacing16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Manage Knowledge Base', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 3), + Text( + 'Add trainer notes to give the AI context-aware answers.', + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textMuted, + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right_rounded, + color: + _hovered ? AppColors.accent : AppColors.textMuted, + size: 20, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/presentation/shell/shell_page.dart b/lib/presentation/shell/shell_page.dart index 439a2ad..b7bc52b 100644 --- a/lib/presentation/shell/shell_page.dart +++ b/lib/presentation/shell/shell_page.dart @@ -1,7 +1,9 @@ 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/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 +24,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 +36,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 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( + 'v2.0.0', + 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, + ), + ), + ), + ), + ); + } +} diff --git a/lib/presentation/trainings/trainings_controller.g.dart b/lib/presentation/trainings/trainings_controller.g.dart index 7d33ed0..5fdcb32 100644 --- a/lib/presentation/trainings/trainings_controller.g.dart +++ b/lib/presentation/trainings/trainings_controller.g.dart @@ -7,7 +7,7 @@ part of 'trainings_controller.dart'; // ************************************************************************** String _$trainingsControllerHash() => - r'15c54eb8211e3b2549af6ef25a9cb451a7a9988a'; + r'2da51cdda3db5f186bc32980544a6aeeab268274'; /// See also [TrainingsController]. @ProviderFor(TrainingsController) diff --git a/lib/presentation/trainings/trainings_page.dart b/lib/presentation/trainings/trainings_page.dart index 5979346..90f4606 100644 --- a/lib/presentation/trainings/trainings_page.dart +++ b/lib/presentation/trainings/trainings_page.dart @@ -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? _positionSub; + StreamSubscription? _durationSub; + StreamSubscription? _playingSub; + StreamSubscription? _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 _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')}'; + } +} diff --git a/lib/presentation/welcome/welcome_screen.dart b/lib/presentation/welcome/welcome_screen.dart new file mode 100644 index 0000000..a4d1a90 --- /dev/null +++ b/lib/presentation/welcome/welcome_screen.dart @@ -0,0 +1,535 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:trainhub_flutter/core/constants/ui_constants.dart'; +import 'package:trainhub_flutter/core/router/app_router.dart'; +import 'package:trainhub_flutter/core/theme/app_colors.dart'; +import 'package:trainhub_flutter/presentation/settings/ai_model_settings_controller.dart'; +import 'package:trainhub_flutter/presentation/settings/ai_model_settings_state.dart'; + +@RoutePage() +class WelcomeScreen extends ConsumerStatefulWidget { + const WelcomeScreen({super.key}); + + @override + ConsumerState createState() => _WelcomeScreenState(); +} + +class _WelcomeScreenState extends ConsumerState { + bool _hasNavigated = false; + + @override + void initState() { + super.initState(); + // Validate after the first frame so the provider is ready + 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()); + } + + void _skip() => _navigateToApp(); + + void _startDownload() { + ref + .read(aiModelSettingsControllerProvider.notifier) + .downloadAll(); + } + + @override + Widget build(BuildContext context) { + final modelState = ref.watch(aiModelSettingsControllerProvider); + + // Navigate once download completes and models are validated + ref.listen(aiModelSettingsControllerProvider, + (prev, next) { + if (!_hasNavigated && + 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: _startDownload, + onSkip: _skip, + ), + ), + ), + ), + ); + } +} + +// ============================================================================= +// Initial prompt card — shown when models are missing +// ============================================================================= +class _InitialPrompt extends StatelessWidget { + const _InitialPrompt({ + required this.onDownload, + required this.onSkip, + }); + + final VoidCallback onDownload; + final VoidCallback onSkip; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Logo + wordmark + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.accentMuted, + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.fitness_center, + color: AppColors.accent, + size: 24, + ), + ), + const SizedBox(width: UIConstants.spacing12), + Text( + 'TrainHub', + style: GoogleFonts.inter( + fontSize: 26, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + letterSpacing: -0.5, + ), + ), + ], + ), + + const SizedBox(height: UIConstants.spacing32), + + // Headline + Text( + 'AI-powered coaching,\nright on your device.', + style: GoogleFonts.inter( + fontSize: 28, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + height: 1.25, + letterSpacing: -0.5, + ), + ), + + const SizedBox(height: UIConstants.spacing16), + + Text( + 'TrainHub uses on-device AI models to give you intelligent ' + 'training advice, exercise analysis, and personalised chat — ' + 'with zero data sent to the cloud.', + style: GoogleFonts.inter( + fontSize: 14, + color: AppColors.textSecondary, + height: 1.6, + ), + ), + + const SizedBox(height: UIConstants.spacing24), + + // Feature list + const _FeatureRow( + icon: Icons.lock_outline_rounded, + label: '100 % local — your data never leaves this machine.', + ), + const SizedBox(height: UIConstants.spacing12), + const _FeatureRow( + icon: Icons.psychology_outlined, + label: 'Qwen 2.5 7B chat model for training advice.', + ), + const SizedBox(height: UIConstants.spacing12), + const _FeatureRow( + icon: Icons.search_rounded, + label: 'Nomic embedding model for semantic exercise search.', + ), + + const SizedBox(height: UIConstants.spacing32), + + // Download size notice + Container( + padding: const EdgeInsets.symmetric( + horizontal: UIConstants.spacing16, + vertical: UIConstants.spacing12, + ), + decoration: BoxDecoration( + color: AppColors.accentMuted, + borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), + border: Border.all( + color: AppColors.accent.withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + const Icon( + Icons.download_outlined, + color: AppColors.accent, + size: 18, + ), + const SizedBox(width: UIConstants.spacing12), + Expanded( + child: Text( + 'The download is ~5 GB and only needs to happen once. ' + 'You can skip now and download later from Settings.', + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.accent, + height: 1.5, + ), + ), + ), + ], + ), + ), + + const SizedBox(height: UIConstants.spacing32), + + // Action buttons + Row( + children: [ + Expanded( + child: _PrimaryButton( + label: 'Download Now', + icon: Icons.download_rounded, + onPressed: onDownload, + ), + ), + const SizedBox(width: UIConstants.spacing12), + Expanded( + child: _SecondaryButton( + label: 'Skip for Now', + onPressed: onSkip, + ), + ), + ], + ), + ], + ); + } +} + +// ============================================================================= +// Download progress card +// ============================================================================= +class _DownloadProgress extends StatelessWidget { + const _DownloadProgress({required this.modelState}); + + final AiModelSettingsState modelState; + + @override + Widget build(BuildContext context) { + final pct = (modelState.progress * 100).toStringAsFixed(1); + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Animated download icon + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: AppColors.accentMuted, + borderRadius: BorderRadius.circular(14), + ), + child: const Icon( + Icons.download_rounded, + color: AppColors.accent, + size: 28, + ), + ), + + const SizedBox(height: UIConstants.spacing24), + + Text( + 'Setting up AI models…', + style: GoogleFonts.inter( + fontSize: 22, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + letterSpacing: -0.3, + ), + ), + + const SizedBox(height: UIConstants.spacing8), + + Text( + 'Please keep the app open. This only happens once.', + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + ), + ), + + const SizedBox(height: UIConstants.spacing32), + + // Progress bar + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: modelState.progress, + minHeight: 6, + backgroundColor: AppColors.zinc800, + valueColor: + const AlwaysStoppedAnimation(AppColors.accent), + ), + ), + + const SizedBox(height: UIConstants.spacing12), + + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + modelState.currentTask, + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Text( + '$pct %', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.accent, + ), + ), + ], + ), + + if (modelState.errorMessage != null) ...[ + const SizedBox(height: UIConstants.spacing16), + _ErrorBanner(message: modelState.errorMessage!), + ], + ], + ); + } +} + +// ============================================================================= +// Small reusable widgets +// ============================================================================= + +class _FeatureRow extends StatelessWidget { + const _FeatureRow({required this.icon, required this.label}); + + final IconData icon; + final String label; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: AppColors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(6), + ), + child: Icon(icon, size: 14, color: AppColors.accent), + ), + const SizedBox(width: UIConstants.spacing12), + Expanded( + child: Text( + label, + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + height: 1.5, + ), + ), + ), + ], + ); + } +} + +class _PrimaryButton extends StatefulWidget { + const _PrimaryButton({ + required this.label, + required this.icon, + required this.onPressed, + }); + + final String label; + final IconData icon; + final VoidCallback onPressed; + + @override + State<_PrimaryButton> createState() => _PrimaryButtonState(); +} + +class _PrimaryButtonState extends State<_PrimaryButton> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: AnimatedContainer( + duration: UIConstants.animationDuration, + height: 44, + decoration: BoxDecoration( + color: _hovered + ? AppColors.accent.withValues(alpha: 0.85) + : AppColors.accent, + borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: + BorderRadius.circular(UIConstants.smallBorderRadius), + onTap: widget.onPressed, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(widget.icon, color: AppColors.zinc950, size: 16), + const SizedBox(width: UIConstants.spacing8), + Text( + widget.label, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.zinc950, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _SecondaryButton extends StatefulWidget { + const _SecondaryButton({required this.label, required this.onPressed}); + + final String label; + final VoidCallback onPressed; + + @override + State<_SecondaryButton> createState() => _SecondaryButtonState(); +} + +class _SecondaryButtonState extends State<_SecondaryButton> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: AnimatedContainer( + duration: UIConstants.animationDuration, + height: 44, + decoration: BoxDecoration( + color: _hovered ? AppColors.zinc800 : Colors.transparent, + borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), + border: Border.all(color: AppColors.border), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: + BorderRadius.circular(UIConstants.smallBorderRadius), + onTap: widget.onPressed, + child: Center( + child: Text( + widget.label, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ErrorBanner extends StatelessWidget { + const _ErrorBanner({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UIConstants.spacing12), + decoration: BoxDecoration( + color: AppColors.destructiveMuted, + borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), + border: Border.all( + color: AppColors.destructive.withValues(alpha: 0.4), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.error_outline_rounded, + color: AppColors.destructive, + size: 16, + ), + const SizedBox(width: UIConstants.spacing8), + Expanded( + child: Text( + message, + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.destructive, + height: 1.5, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/presentation/workout_session/widgets/session_controls.dart b/lib/presentation/workout_session/widgets/session_controls.dart index ec3442a..d343ddc 100644 --- a/lib/presentation/workout_session/widgets/session_controls.dart +++ b/lib/presentation/workout_session/widgets/session_controls.dart @@ -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, + ), + ], ], ), ); diff --git a/lib/presentation/workout_session/workout_session_controller.dart b/lib/presentation/workout_session/workout_session_controller.dart index e558e65..4d508db 100644 --- a/lib/presentation/workout_session/workout_session_controller.dart +++ b/lib/presentation/workout_session/workout_session_controller.dart @@ -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,23 +173,21 @@ class WorkoutSessionController extends _$WorkoutSessionController { void jumpTo(int index) { final currentState = state.value; - if (currentState != null && - index >= 0 && - index < currentState.activities.length) { - final activity = currentState.activities[index]; + if (currentState == null) return; + if (index < 0 || index >= currentState.activities.length) return; + final activity = currentState.activities[index]; - state = AsyncValue.data( - currentState.copyWith( - currentIndex: index, - timeRemaining: activity.duration, - ), - ); + state = AsyncValue.data( + currentState.copyWith( + currentIndex: index, + timeRemaining: activity.duration, + ), + ); - if (activity.isRest) { - startTimer(); - } else { - pauseTimer(); - } + if (activity.isTimeBased) { + startTimer(); + } else { + pauseTimer(); } } diff --git a/lib/presentation/workout_session/workout_session_controller.g.dart b/lib/presentation/workout_session/workout_session_controller.g.dart index 9df754c..26a5a76 100644 --- a/lib/presentation/workout_session/workout_session_controller.g.dart +++ b/lib/presentation/workout_session/workout_session_controller.g.dart @@ -7,7 +7,7 @@ part of 'workout_session_controller.dart'; // ************************************************************************** String _$workoutSessionControllerHash() => - r'd3f53d72c80963634c6edaeb44aa5b04c9ffba6d'; + r'ba4c44e3bc2de98cced4eef96f8a337fd1e43665'; /// Copied from Dart SDK class _SystemHash { diff --git a/lib/presentation/workout_session/workout_session_page.dart b/lib/presentation/workout_session/workout_session_page.dart index f282a3c..df34e5d 100644 --- a/lib/presentation/workout_session/workout_session_page.dart +++ b/lib/presentation/workout_session/workout_session_page.dart @@ -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,123 +121,190 @@ class _ActiveSessionView extends StatelessWidget { : AppColors.accent.withValues(alpha: 0.06); final ringColor = isRest ? AppColors.info : AppColors.accent; - return Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - AppColors.zinc950, - accentTint, - AppColors.zinc950, - ], - stops: const [0.0, 0.5, 1.0], - ), - ), - child: Column( - children: [ - // -- Top progress bar -- - SessionProgressBar(progress: state.progress), - - // -- Elapsed time badge -- - Padding( - padding: const EdgeInsets.only( - top: UIConstants.spacing16, - right: UIConstants.spacing24, + return Stack( + children: [ + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.zinc950, + accentTint, + AppColors.zinc950, + ], + stops: const [0.0, 0.5, 1.0], ), - child: Align( - alignment: Alignment.centerRight, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: UIConstants.spacing12, - vertical: UIConstants.spacing4, - ), - decoration: BoxDecoration( - color: AppColors.zinc800.withValues(alpha: 0.6), - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: AppColors.border.withValues(alpha: 0.5), + ), + child: Column( + children: [ + SessionProgressBar(progress: widget.state.progress), + _buildTopBar(context), + Expanded( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: UIConstants.spacing24, + ), + 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, + ), + ], + ), ), ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.timer_outlined, - size: 14, - color: AppColors.textMuted, - ), - const SizedBox(width: 4), - Text( - _formatDuration(state.totalTimeElapsed), - style: const TextStyle( - color: AppColors.textSecondary, - fontSize: 13, - fontWeight: FontWeight.w500, - fontFeatures: [FontFeature.tabularFigures()], - ), - ), - ], - ), ), - ), - ), - - // -- Central content -- - Expanded( - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric( - horizontal: UIConstants.spacing24, - ), - 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, - ), - - const SizedBox(height: UIConstants.spacing24), - - // "Up next" pill - if (state.nextActivity != null) - _UpNextPill( - nextActivityName: state.nextActivity!.name, - isNextRest: 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', ), - - // -- Bottom controls -- - SessionControls( - isRunning: state.isRunning, - isFinished: state.isFinished, - onPause: controller.pauseTimer, - onPlay: controller.startTimer, - onNext: controller.next, - onPrevious: controller.previous, + 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', ), - - const SizedBox(height: UIConstants.spacing24), ], ), ); } + Widget _buildElapsedTimeBadge() { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: UIConstants.spacing12, + vertical: UIConstants.spacing4, + ), + decoration: BoxDecoration( + color: AppColors.zinc800.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: AppColors.border.withValues(alpha: 0.5), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.timer_outlined, + size: 14, + color: AppColors.textMuted, + ), + const SizedBox(width: 4), + Text( + _formatDuration(widget.state.totalTimeElapsed), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 13, + fontWeight: FontWeight.w500, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ], + ), + ); + } + + Future _confirmExit(BuildContext context) async { + final shouldExit = await showDialog( + 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?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + style: FilledButton.styleFrom( + backgroundColor: AppColors.destructive, + ), + child: const Text('Exit'), + ), + ], + ), + ); + if (shouldExit == true && context.mounted) { + widget.controller.pauseTimer(); + context.router.maybePop(); + } + } + String _formatDuration(int seconds) { final m = seconds ~/ 60; final s = seconds % 60; @@ -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( + tween: Tween(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, - ), - ), - ), - if (isTimeBased && !isRunning) - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( - 'PAUSED', - 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), + ), + ), + child: Text( + 'PAUSED', + style: TextStyle( + color: AppColors.textMuted, + 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 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, diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 7885232..3121168 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,17 +6,29 @@ #include "generated_plugin_registrant.h" +#include +#include #include #include +#include #include 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); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index f732440..433c999 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -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 ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index f02eec8..39e15ec 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) } diff --git a/pubspec.lock b/pubspec.lock index b3c086d..b7c0c98 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: @@ -141,10 +149,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" charcode: dependency: transitive description: @@ -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: @@ -593,18 +617,82 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.18" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" + media_kit: + dependency: "direct main" + description: + 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: @@ -934,10 +1070,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.9" timing: 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: diff --git a/pubspec.yaml b/pubspec.yaml index a47d015..80ec483 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 99d0961..75f0666 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,18 +6,24 @@ #include "generated_plugin_registrant.h" +#include +#include #include #include -#include +#include #include 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")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index bfb35f9..2a03a38 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -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 )