Refactoring
Some checks failed
Build Linux App / build (push) Failing after 1m33s

This commit is contained in:
2026-02-23 10:02:23 -05:00
parent 21f1387fa8
commit 0c9eb8878d
57 changed files with 8179 additions and 1114 deletions

View File

@@ -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:*)"
]
}
}

View File

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

View File

@@ -74,6 +74,22 @@ class HomeRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [KnowledgeBasePage]
class KnowledgeBaseRoute extends PageRouteInfo<void> {
const KnowledgeBaseRoute({List<PageRouteInfo>? children})
: super(KnowledgeBaseRoute.name, initialChildren: children);
static const String name = 'KnowledgeBaseRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const KnowledgeBasePage();
},
);
}
/// generated route for
/// [PlanEditorPage]
class PlanEditorRoute extends PageRouteInfo<PlanEditorRouteArgs> {
@@ -116,6 +132,22 @@ class PlanEditorRouteArgs {
}
}
/// generated route for
/// [SettingsPage]
class SettingsRoute extends PageRouteInfo<void> {
const SettingsRoute({List<PageRouteInfo>? children})
: super(SettingsRoute.name, initialChildren: children);
static const String name = 'SettingsRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const SettingsPage();
},
);
}
/// generated route for
/// [ShellPage]
class ShellRoute extends PageRouteInfo<void> {
@@ -148,6 +180,22 @@ class TrainingsRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [WelcomeScreen]
class WelcomeRoute extends PageRouteInfo<void> {
const WelcomeRoute({List<PageRouteInfo>? children})
: super(WelcomeRoute.name, initialChildren: children);
static const String name = 'WelcomeRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const WelcomeScreen();
},
);
}
/// generated route for
/// [WorkoutSessionPage]
class WorkoutSessionRoute extends PageRouteInfo<WorkoutSessionRouteArgs> {

View File

@@ -1,17 +1,23 @@
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
import 'package:trainhub_flutter/core/constants/app_constants.dart';
import 'package:trainhub_flutter/data/database/daos/exercise_dao.dart';
import 'package:trainhub_flutter/data/database/daos/training_plan_dao.dart';
import 'package:trainhub_flutter/data/database/daos/program_dao.dart';
import 'package:trainhub_flutter/data/database/daos/analysis_dao.dart';
import 'package:trainhub_flutter/data/database/daos/chat_dao.dart';
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:trainhub_flutter/core/constants/app_constants.dart';
import 'package:trainhub_flutter/data/database/daos/analysis_dao.dart';
import 'package:trainhub_flutter/data/database/daos/chat_dao.dart';
import 'package:trainhub_flutter/data/database/daos/exercise_dao.dart';
import 'package:trainhub_flutter/data/database/daos/knowledge_chunk_dao.dart';
import 'package:trainhub_flutter/data/database/daos/program_dao.dart';
import 'package:trainhub_flutter/data/database/daos/training_plan_dao.dart';
part 'app_database.g.dart';
// ---------------------------------------------------------------------------
// Existing tables (unchanged)
// ---------------------------------------------------------------------------
class Exercises extends Table {
TextColumn get id => text()();
TextColumn get name => text()();
@@ -116,6 +122,35 @@ class ChatMessages extends Table {
Set<Column> get primaryKey => {id};
}
// ---------------------------------------------------------------------------
// v2: Knowledge base chunks
// ---------------------------------------------------------------------------
/// Stores text chunks and their JSON-encoded embedding vectors.
/// Used for Retrieval-Augmented Generation (RAG) in the AI chat.
class KnowledgeChunks extends Table {
/// UUID for this individual chunk.
TextColumn get id => text()();
/// All chunks from the same `addNote()` call share this ID.
TextColumn get sourceId => text()();
/// The raw text of the chunk (max ~500 chars).
TextColumn get content => text()();
/// JSON-encoded `List<double>` — the 768-dim Nomic embedding vector.
TextColumn get embedding => text()();
TextColumn get createdAt => text()();
@override
Set<Column> get primaryKey => {id};
}
// ---------------------------------------------------------------------------
// Database class
// ---------------------------------------------------------------------------
@DriftDatabase(
tables: [
Exercises,
@@ -127,6 +162,7 @@ class ChatMessages extends Table {
Annotations,
ChatSessions,
ChatMessages,
KnowledgeChunks, // added in schema v2
],
daos: [
ExerciseDao,
@@ -134,13 +170,24 @@ class ChatMessages extends Table {
ProgramDao,
AnalysisDao,
ChatDao,
KnowledgeChunkDao, // added in schema v2
],
)
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
@override
int get schemaVersion => 1;
int get schemaVersion => 2;
@override
MigrationStrategy get migration => MigrationStrategy(
onUpgrade: (migrator, from, to) async {
// v1 → v2: add knowledge chunks table
if (from < 2) {
await migrator.createTable(knowledgeChunks);
}
},
);
}
LazyDatabase _openConnection() {

View File

@@ -3250,6 +3250,375 @@ class ChatMessagesCompanion extends UpdateCompanion<ChatMessage> {
}
}
class $KnowledgeChunksTable extends KnowledgeChunks
with TableInfo<$KnowledgeChunksTable, KnowledgeChunk> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$KnowledgeChunksTable(this.attachedDatabase, [this._alias]);
static const VerificationMeta _idMeta = const VerificationMeta('id');
@override
late final GeneratedColumn<String> id = GeneratedColumn<String>(
'id',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _sourceIdMeta = const VerificationMeta(
'sourceId',
);
@override
late final GeneratedColumn<String> sourceId = GeneratedColumn<String>(
'source_id',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _contentMeta = const VerificationMeta(
'content',
);
@override
late final GeneratedColumn<String> content = GeneratedColumn<String>(
'content',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _embeddingMeta = const VerificationMeta(
'embedding',
);
@override
late final GeneratedColumn<String> embedding = GeneratedColumn<String>(
'embedding',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _createdAtMeta = const VerificationMeta(
'createdAt',
);
@override
late final GeneratedColumn<String> createdAt = GeneratedColumn<String>(
'created_at',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
@override
List<GeneratedColumn> get $columns => [
id,
sourceId,
content,
embedding,
createdAt,
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'knowledge_chunks';
@override
VerificationContext validateIntegrity(
Insertable<KnowledgeChunk> instance, {
bool isInserting = false,
}) {
final context = VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('id')) {
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
} else if (isInserting) {
context.missing(_idMeta);
}
if (data.containsKey('source_id')) {
context.handle(
_sourceIdMeta,
sourceId.isAcceptableOrUnknown(data['source_id']!, _sourceIdMeta),
);
} else if (isInserting) {
context.missing(_sourceIdMeta);
}
if (data.containsKey('content')) {
context.handle(
_contentMeta,
content.isAcceptableOrUnknown(data['content']!, _contentMeta),
);
} else if (isInserting) {
context.missing(_contentMeta);
}
if (data.containsKey('embedding')) {
context.handle(
_embeddingMeta,
embedding.isAcceptableOrUnknown(data['embedding']!, _embeddingMeta),
);
} else if (isInserting) {
context.missing(_embeddingMeta);
}
if (data.containsKey('created_at')) {
context.handle(
_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta),
);
} else if (isInserting) {
context.missing(_createdAtMeta);
}
return context;
}
@override
Set<GeneratedColumn> get $primaryKey => {id};
@override
KnowledgeChunk map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return KnowledgeChunk(
id: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}id'],
)!,
sourceId: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}source_id'],
)!,
content: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}content'],
)!,
embedding: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}embedding'],
)!,
createdAt: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}created_at'],
)!,
);
}
@override
$KnowledgeChunksTable createAlias(String alias) {
return $KnowledgeChunksTable(attachedDatabase, alias);
}
}
class KnowledgeChunk extends DataClass implements Insertable<KnowledgeChunk> {
/// UUID for this individual chunk.
final String id;
/// All chunks from the same `addNote()` call share this ID.
final String sourceId;
/// The raw text of the chunk (max ~500 chars).
final String content;
/// JSON-encoded `List<double>` — the 768-dim Nomic embedding vector.
final String embedding;
final String createdAt;
const KnowledgeChunk({
required this.id,
required this.sourceId,
required this.content,
required this.embedding,
required this.createdAt,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<String>(id);
map['source_id'] = Variable<String>(sourceId);
map['content'] = Variable<String>(content);
map['embedding'] = Variable<String>(embedding);
map['created_at'] = Variable<String>(createdAt);
return map;
}
KnowledgeChunksCompanion toCompanion(bool nullToAbsent) {
return KnowledgeChunksCompanion(
id: Value(id),
sourceId: Value(sourceId),
content: Value(content),
embedding: Value(embedding),
createdAt: Value(createdAt),
);
}
factory KnowledgeChunk.fromJson(
Map<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return KnowledgeChunk(
id: serializer.fromJson<String>(json['id']),
sourceId: serializer.fromJson<String>(json['sourceId']),
content: serializer.fromJson<String>(json['content']),
embedding: serializer.fromJson<String>(json['embedding']),
createdAt: serializer.fromJson<String>(json['createdAt']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<String>(id),
'sourceId': serializer.toJson<String>(sourceId),
'content': serializer.toJson<String>(content),
'embedding': serializer.toJson<String>(embedding),
'createdAt': serializer.toJson<String>(createdAt),
};
}
KnowledgeChunk copyWith({
String? id,
String? sourceId,
String? content,
String? embedding,
String? createdAt,
}) => KnowledgeChunk(
id: id ?? this.id,
sourceId: sourceId ?? this.sourceId,
content: content ?? this.content,
embedding: embedding ?? this.embedding,
createdAt: createdAt ?? this.createdAt,
);
KnowledgeChunk copyWithCompanion(KnowledgeChunksCompanion data) {
return KnowledgeChunk(
id: data.id.present ? data.id.value : this.id,
sourceId: data.sourceId.present ? data.sourceId.value : this.sourceId,
content: data.content.present ? data.content.value : this.content,
embedding: data.embedding.present ? data.embedding.value : this.embedding,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
);
}
@override
String toString() {
return (StringBuffer('KnowledgeChunk(')
..write('id: $id, ')
..write('sourceId: $sourceId, ')
..write('content: $content, ')
..write('embedding: $embedding, ')
..write('createdAt: $createdAt')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(id, sourceId, content, embedding, createdAt);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is KnowledgeChunk &&
other.id == this.id &&
other.sourceId == this.sourceId &&
other.content == this.content &&
other.embedding == this.embedding &&
other.createdAt == this.createdAt);
}
class KnowledgeChunksCompanion extends UpdateCompanion<KnowledgeChunk> {
final Value<String> id;
final Value<String> sourceId;
final Value<String> content;
final Value<String> embedding;
final Value<String> createdAt;
final Value<int> rowid;
const KnowledgeChunksCompanion({
this.id = const Value.absent(),
this.sourceId = const Value.absent(),
this.content = const Value.absent(),
this.embedding = const Value.absent(),
this.createdAt = const Value.absent(),
this.rowid = const Value.absent(),
});
KnowledgeChunksCompanion.insert({
required String id,
required String sourceId,
required String content,
required String embedding,
required String createdAt,
this.rowid = const Value.absent(),
}) : id = Value(id),
sourceId = Value(sourceId),
content = Value(content),
embedding = Value(embedding),
createdAt = Value(createdAt);
static Insertable<KnowledgeChunk> custom({
Expression<String>? id,
Expression<String>? sourceId,
Expression<String>? content,
Expression<String>? embedding,
Expression<String>? createdAt,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
if (sourceId != null) 'source_id': sourceId,
if (content != null) 'content': content,
if (embedding != null) 'embedding': embedding,
if (createdAt != null) 'created_at': createdAt,
if (rowid != null) 'rowid': rowid,
});
}
KnowledgeChunksCompanion copyWith({
Value<String>? id,
Value<String>? sourceId,
Value<String>? content,
Value<String>? embedding,
Value<String>? createdAt,
Value<int>? rowid,
}) {
return KnowledgeChunksCompanion(
id: id ?? this.id,
sourceId: sourceId ?? this.sourceId,
content: content ?? this.content,
embedding: embedding ?? this.embedding,
createdAt: createdAt ?? this.createdAt,
rowid: rowid ?? this.rowid,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (id.present) {
map['id'] = Variable<String>(id.value);
}
if (sourceId.present) {
map['source_id'] = Variable<String>(sourceId.value);
}
if (content.present) {
map['content'] = Variable<String>(content.value);
}
if (embedding.present) {
map['embedding'] = Variable<String>(embedding.value);
}
if (createdAt.present) {
map['created_at'] = Variable<String>(createdAt.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('KnowledgeChunksCompanion(')
..write('id: $id, ')
..write('sourceId: $sourceId, ')
..write('content: $content, ')
..write('embedding: $embedding, ')
..write('createdAt: $createdAt, ')
..write('rowid: $rowid')
..write(')'))
.toString();
}
}
abstract class _$AppDatabase extends GeneratedDatabase {
_$AppDatabase(QueryExecutor e) : super(e);
$AppDatabaseManager get managers => $AppDatabaseManager(this);
@@ -3266,6 +3635,9 @@ abstract class _$AppDatabase extends GeneratedDatabase {
late final $AnnotationsTable annotations = $AnnotationsTable(this);
late final $ChatSessionsTable chatSessions = $ChatSessionsTable(this);
late final $ChatMessagesTable chatMessages = $ChatMessagesTable(this);
late final $KnowledgeChunksTable knowledgeChunks = $KnowledgeChunksTable(
this,
);
late final ExerciseDao exerciseDao = ExerciseDao(this as AppDatabase);
late final TrainingPlanDao trainingPlanDao = TrainingPlanDao(
this as AppDatabase,
@@ -3273,6 +3645,9 @@ abstract class _$AppDatabase extends GeneratedDatabase {
late final ProgramDao programDao = ProgramDao(this as AppDatabase);
late final AnalysisDao analysisDao = AnalysisDao(this as AppDatabase);
late final ChatDao chatDao = ChatDao(this as AppDatabase);
late final KnowledgeChunkDao knowledgeChunkDao = KnowledgeChunkDao(
this as AppDatabase,
);
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@@ -3287,6 +3662,7 @@ abstract class _$AppDatabase extends GeneratedDatabase {
annotations,
chatSessions,
chatMessages,
knowledgeChunks,
];
@override
StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([
@@ -6224,6 +6600,212 @@ typedef $$ChatMessagesTableProcessedTableManager =
ChatMessage,
PrefetchHooks Function({bool sessionId})
>;
typedef $$KnowledgeChunksTableCreateCompanionBuilder =
KnowledgeChunksCompanion Function({
required String id,
required String sourceId,
required String content,
required String embedding,
required String createdAt,
Value<int> rowid,
});
typedef $$KnowledgeChunksTableUpdateCompanionBuilder =
KnowledgeChunksCompanion Function({
Value<String> id,
Value<String> sourceId,
Value<String> content,
Value<String> embedding,
Value<String> createdAt,
Value<int> rowid,
});
class $$KnowledgeChunksTableFilterComposer
extends Composer<_$AppDatabase, $KnowledgeChunksTable> {
$$KnowledgeChunksTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<String> get id => $composableBuilder(
column: $table.id,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get sourceId => $composableBuilder(
column: $table.sourceId,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get content => $composableBuilder(
column: $table.content,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get embedding => $composableBuilder(
column: $table.embedding,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => ColumnFilters(column),
);
}
class $$KnowledgeChunksTableOrderingComposer
extends Composer<_$AppDatabase, $KnowledgeChunksTable> {
$$KnowledgeChunksTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnOrderings<String> get id => $composableBuilder(
column: $table.id,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get sourceId => $composableBuilder(
column: $table.sourceId,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get content => $composableBuilder(
column: $table.content,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get embedding => $composableBuilder(
column: $table.embedding,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => ColumnOrderings(column),
);
}
class $$KnowledgeChunksTableAnnotationComposer
extends Composer<_$AppDatabase, $KnowledgeChunksTable> {
$$KnowledgeChunksTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
GeneratedColumn<String> get id =>
$composableBuilder(column: $table.id, builder: (column) => column);
GeneratedColumn<String> get sourceId =>
$composableBuilder(column: $table.sourceId, builder: (column) => column);
GeneratedColumn<String> get content =>
$composableBuilder(column: $table.content, builder: (column) => column);
GeneratedColumn<String> get embedding =>
$composableBuilder(column: $table.embedding, builder: (column) => column);
GeneratedColumn<String> get createdAt =>
$composableBuilder(column: $table.createdAt, builder: (column) => column);
}
class $$KnowledgeChunksTableTableManager
extends
RootTableManager<
_$AppDatabase,
$KnowledgeChunksTable,
KnowledgeChunk,
$$KnowledgeChunksTableFilterComposer,
$$KnowledgeChunksTableOrderingComposer,
$$KnowledgeChunksTableAnnotationComposer,
$$KnowledgeChunksTableCreateCompanionBuilder,
$$KnowledgeChunksTableUpdateCompanionBuilder,
(
KnowledgeChunk,
BaseReferences<
_$AppDatabase,
$KnowledgeChunksTable,
KnowledgeChunk
>,
),
KnowledgeChunk,
PrefetchHooks Function()
> {
$$KnowledgeChunksTableTableManager(
_$AppDatabase db,
$KnowledgeChunksTable table,
) : super(
TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
$$KnowledgeChunksTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
$$KnowledgeChunksTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () =>
$$KnowledgeChunksTableAnnotationComposer($db: db, $table: table),
updateCompanionCallback:
({
Value<String> id = const Value.absent(),
Value<String> sourceId = const Value.absent(),
Value<String> content = const Value.absent(),
Value<String> embedding = const Value.absent(),
Value<String> createdAt = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => KnowledgeChunksCompanion(
id: id,
sourceId: sourceId,
content: content,
embedding: embedding,
createdAt: createdAt,
rowid: rowid,
),
createCompanionCallback:
({
required String id,
required String sourceId,
required String content,
required String embedding,
required String createdAt,
Value<int> rowid = const Value.absent(),
}) => KnowledgeChunksCompanion.insert(
id: id,
sourceId: sourceId,
content: content,
embedding: embedding,
createdAt: createdAt,
rowid: rowid,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
),
);
}
typedef $$KnowledgeChunksTableProcessedTableManager =
ProcessedTableManager<
_$AppDatabase,
$KnowledgeChunksTable,
KnowledgeChunk,
$$KnowledgeChunksTableFilterComposer,
$$KnowledgeChunksTableOrderingComposer,
$$KnowledgeChunksTableAnnotationComposer,
$$KnowledgeChunksTableCreateCompanionBuilder,
$$KnowledgeChunksTableUpdateCompanionBuilder,
(
KnowledgeChunk,
BaseReferences<_$AppDatabase, $KnowledgeChunksTable, KnowledgeChunk>,
),
KnowledgeChunk,
PrefetchHooks Function()
>;
class $AppDatabaseManager {
final _$AppDatabase _db;
@@ -6246,4 +6828,6 @@ class $AppDatabaseManager {
$$ChatSessionsTableTableManager(_db, _db.chatSessions);
$$ChatMessagesTableTableManager get chatMessages =>
$$ChatMessagesTableTableManager(_db, _db.chatMessages);
$$KnowledgeChunksTableTableManager get knowledgeChunks =>
$$KnowledgeChunksTableTableManager(_db, _db.knowledgeChunks);
}

View File

@@ -27,6 +27,20 @@ class AnalysisDao extends DatabaseAccessor<AppDatabase>
Future<void> insertAnnotation(AnnotationsCompanion entry) =>
into(annotations).insert(entry);
Future<void> updateAnnotation({
required String id,
required String name,
required String description,
required String color,
}) =>
(update(annotations)..where((t) => t.id.equals(id))).write(
AnnotationsCompanion(
name: Value(name),
description: Value(description),
color: Value(color),
),
);
Future<void> deleteAnnotation(String id) =>
(delete(annotations)..where((t) => t.id.equals(id))).go();
}

View File

@@ -0,0 +1,35 @@
import 'package:drift/drift.dart';
import 'package:trainhub_flutter/data/database/app_database.dart';
part 'knowledge_chunk_dao.g.dart';
@DriftAccessor(tables: [KnowledgeChunks])
class KnowledgeChunkDao extends DatabaseAccessor<AppDatabase>
with _$KnowledgeChunkDaoMixin {
KnowledgeChunkDao(super.db);
Future<void> insertChunk(KnowledgeChunksCompanion entry) =>
into(knowledgeChunks).insert(entry);
/// Returns every stored chunk, including its JSON-encoded embedding.
/// Loaded into memory for in-process cosine similarity scoring.
Future<List<KnowledgeChunk>> getAllChunks() =>
(select(knowledgeChunks)
..orderBy([
(t) =>
OrderingTerm(expression: t.createdAt, mode: OrderingMode.asc)
]))
.get();
Future<int> getCount() async {
final rows = await select(knowledgeChunks).get();
return rows.length;
}
Future<void> deleteAll() => delete(knowledgeChunks).go();
Future<void> deleteBySourceId(String sourceId) =>
(delete(knowledgeChunks)
..where((t) => t.sourceId.equals(sourceId)))
.go();
}

View File

@@ -0,0 +1,8 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'knowledge_chunk_dao.dart';
// ignore_for_file: type=lint
mixin _$KnowledgeChunkDaoMixin on DatabaseAccessor<AppDatabase> {
$KnowledgeChunksTable get knowledgeChunks => attachedDatabase.knowledgeChunks;
}

View File

@@ -83,6 +83,21 @@ class AnalysisRepositoryImpl implements AnalysisRepository {
);
}
@override
Future<void> updateAnnotation({
required String id,
required String name,
required String description,
required String color,
}) async {
await _dao.updateAnnotation(
id: id,
name: name,
description: description,
color: color,
);
}
@override
Future<void> deleteAnnotation(String id) async {
await _dao.deleteAnnotation(id);

View File

@@ -0,0 +1,142 @@
import 'dart:convert';
import 'dart:math' show sqrt;
import 'package:drift/drift.dart';
import 'package:trainhub_flutter/data/database/app_database.dart';
import 'package:trainhub_flutter/data/database/daos/knowledge_chunk_dao.dart';
import 'package:trainhub_flutter/domain/repositories/note_repository.dart';
import 'package:trainhub_flutter/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<void> addNote(String text) async {
final chunks = _chunkText(text);
if (chunks.isEmpty) return;
final sourceId = _uuid.v4();
final now = DateTime.now().toIso8601String();
for (final chunk in chunks) {
final embedding = await _embeddingService.embed(chunk);
await _dao.insertChunk(
KnowledgeChunksCompanion(
id: Value(_uuid.v4()),
sourceId: Value(sourceId),
content: Value(chunk),
embedding: Value(jsonEncode(embedding)),
createdAt: Value(now),
),
);
}
}
@override
Future<List<String>> searchSimilar(String query, {int topK = 3}) async {
final allRows = await _dao.getAllChunks();
if (allRows.isEmpty) return [];
final queryEmbedding = await _embeddingService.embed(query);
final scored = allRows.map((row) {
final emb =
(jsonDecode(row.embedding) as List<dynamic>)
.map((e) => (e as num).toDouble())
.toList();
return _Scored(
score: _cosineSimilarity(queryEmbedding, emb),
text: row.content,
);
}).toList()
..sort((a, b) => b.score.compareTo(a.score));
return scored.take(topK).map((s) => s.text).toList();
}
@override
Future<int> getChunkCount() => _dao.getCount();
@override
Future<void> clearAll() => _dao.deleteAll();
// -------------------------------------------------------------------------
// Text chunking
// -------------------------------------------------------------------------
/// Splits [text] into semantically meaningful chunks of at most [maxChars].
/// Strategy:
/// 1. Split by blank lines (paragraph boundaries).
/// 2. If a paragraph is still too long, split further by sentence.
/// 3. Accumulate sentences until the chunk would exceed [maxChars].
static List<String> _chunkText(String text, {int maxChars = 500}) {
final chunks = <String>[];
for (final paragraph in text.split(RegExp(r'\n{2,}'))) {
final p = paragraph.trim();
if (p.isEmpty) continue;
if (p.length <= maxChars) {
chunks.add(p);
continue;
}
// Split long paragraph by sentence boundaries (. ! ?)
final sentences =
p.split(RegExp(r'(?<=[.!?])\s+'));
var current = '';
for (final sentence in sentences) {
final candidate =
current.isEmpty ? sentence : '$current $sentence';
if (candidate.length <= maxChars) {
current = candidate;
} else {
if (current.isNotEmpty) chunks.add(current);
// If a single sentence is longer than maxChars, include it as-is
// rather than discarding it.
current = sentence.length > maxChars ? '' : sentence;
if (sentence.length > maxChars) chunks.add(sentence);
}
}
if (current.isNotEmpty) chunks.add(current);
}
return chunks;
}
// -------------------------------------------------------------------------
// Cosine similarity
// -------------------------------------------------------------------------
static double _cosineSimilarity(List<double> a, List<double> b) {
var dot = 0.0;
var normA = 0.0;
var normB = 0.0;
final len = a.length < b.length ? a.length : b.length;
for (var i = 0; i < len; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
if (normA == 0.0 || normB == 0.0) return 0.0;
return dot / (sqrt(normA) * sqrt(normB));
}
}
// Simple value holder used for sorting — not exported.
class _Scored {
const _Scored({required this.score, required this.text});
final double score;
final String text;
}

View File

@@ -0,0 +1,16 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'note_chunk.freezed.dart';
/// A single text chunk produced by splitting a trainer's note.
/// [sourceId] groups all chunks that came from the same original note
/// (useful for bulk deletion later).
@freezed
class NoteChunkEntity with _$NoteChunkEntity {
const factory NoteChunkEntity({
required String id,
required String text,
required String sourceId,
required String createdAt,
}) = _NoteChunkEntity;
}

View File

@@ -0,0 +1,215 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'note_chunk.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
);
/// @nodoc
mixin _$NoteChunkEntity {
String get id => throw _privateConstructorUsedError;
String get text => throw _privateConstructorUsedError;
String get sourceId => throw _privateConstructorUsedError;
String get createdAt => throw _privateConstructorUsedError;
/// Create a copy of NoteChunkEntity
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$NoteChunkEntityCopyWith<NoteChunkEntity> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $NoteChunkEntityCopyWith<$Res> {
factory $NoteChunkEntityCopyWith(
NoteChunkEntity value,
$Res Function(NoteChunkEntity) then,
) = _$NoteChunkEntityCopyWithImpl<$Res, NoteChunkEntity>;
@useResult
$Res call({String id, String text, String sourceId, String createdAt});
}
/// @nodoc
class _$NoteChunkEntityCopyWithImpl<$Res, $Val extends NoteChunkEntity>
implements $NoteChunkEntityCopyWith<$Res> {
_$NoteChunkEntityCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of NoteChunkEntity
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? text = null,
Object? sourceId = null,
Object? createdAt = null,
}) {
return _then(
_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
text: null == text
? _value.text
: text // ignore: cast_nullable_to_non_nullable
as String,
sourceId: null == sourceId
? _value.sourceId
: sourceId // ignore: cast_nullable_to_non_nullable
as String,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as String,
)
as $Val,
);
}
}
/// @nodoc
abstract class _$$NoteChunkEntityImplCopyWith<$Res>
implements $NoteChunkEntityCopyWith<$Res> {
factory _$$NoteChunkEntityImplCopyWith(
_$NoteChunkEntityImpl value,
$Res Function(_$NoteChunkEntityImpl) then,
) = __$$NoteChunkEntityImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({String id, String text, String sourceId, String createdAt});
}
/// @nodoc
class __$$NoteChunkEntityImplCopyWithImpl<$Res>
extends _$NoteChunkEntityCopyWithImpl<$Res, _$NoteChunkEntityImpl>
implements _$$NoteChunkEntityImplCopyWith<$Res> {
__$$NoteChunkEntityImplCopyWithImpl(
_$NoteChunkEntityImpl _value,
$Res Function(_$NoteChunkEntityImpl) _then,
) : super(_value, _then);
/// Create a copy of NoteChunkEntity
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? text = null,
Object? sourceId = null,
Object? createdAt = null,
}) {
return _then(
_$NoteChunkEntityImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
text: null == text
? _value.text
: text // ignore: cast_nullable_to_non_nullable
as String,
sourceId: null == sourceId
? _value.sourceId
: sourceId // ignore: cast_nullable_to_non_nullable
as String,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as String,
),
);
}
}
/// @nodoc
class _$NoteChunkEntityImpl implements _NoteChunkEntity {
const _$NoteChunkEntityImpl({
required this.id,
required this.text,
required this.sourceId,
required this.createdAt,
});
@override
final String id;
@override
final String text;
@override
final String sourceId;
@override
final String createdAt;
@override
String toString() {
return 'NoteChunkEntity(id: $id, text: $text, sourceId: $sourceId, createdAt: $createdAt)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$NoteChunkEntityImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.text, text) || other.text == text) &&
(identical(other.sourceId, sourceId) ||
other.sourceId == sourceId) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt));
}
@override
int get hashCode => Object.hash(runtimeType, id, text, sourceId, createdAt);
/// Create a copy of NoteChunkEntity
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$NoteChunkEntityImplCopyWith<_$NoteChunkEntityImpl> get copyWith =>
__$$NoteChunkEntityImplCopyWithImpl<_$NoteChunkEntityImpl>(
this,
_$identity,
);
}
abstract class _NoteChunkEntity implements NoteChunkEntity {
const factory _NoteChunkEntity({
required final String id,
required final String text,
required final String sourceId,
required final String createdAt,
}) = _$NoteChunkEntityImpl;
@override
String get id;
@override
String get text;
@override
String get sourceId;
@override
String get createdAt;
/// Create a copy of NoteChunkEntity
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$NoteChunkEntityImplCopyWith<_$NoteChunkEntityImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

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

View File

@@ -0,0 +1,21 @@
/// Persistence interface for the trainer's knowledge base.
///
/// The implementation splits raw text into chunks, generates embeddings
/// via the Nomic server, stores them in the local database, and provides
/// semantic search for RAG context injection.
abstract class NoteRepository {
/// Splits [text] into overlapping chunks, generates an embedding for each,
/// and persists them under a shared [sourceId].
Future<void> addNote(String text);
/// Returns the [topK] most semantically similar chunk texts for [query].
/// Returns an empty list if no chunks are stored or the embedding server
/// is unavailable.
Future<List<String>> searchSimilar(String query, {int topK = 3});
/// Returns the total number of stored chunks.
Future<int> getChunkCount();
/// Deletes every stored chunk (full knowledge-base reset).
Future<void> clearAll();
}

View File

@@ -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<void> startServers() async {
if (_running) return;
final dir = await getApplicationDocumentsDirectory();
final base = dir.path;
final serverBin = p.join(
base,
Platform.isWindows ? 'llama-server.exe' : 'llama-server',
);
if (!File(serverBin).existsSync()) return;
try {
// ── Qwen 2.5 7B chat server ──────────────────────────────────────────
_qwenProcess = await Process.start(
serverBin,
[
'-m', p.join(base, 'qwen2.5-7b-instruct-q4_k_m.gguf'),
'--port', '8080',
'--ctx-size', '4096',
'-ngl', '99', // offload all layers to GPU
],
runInShell: false,
);
// Drain pipes so the process is never blocked by a full buffer.
_qwenProcess!.stdout.drain<List<int>>();
_qwenProcess!.stderr.drain<List<int>>();
// ── Nomic embedding server ───────────────────────────────────────────
_nomicProcess = await Process.start(
serverBin,
[
'-m', p.join(base, 'nomic-embed-text-v1.5.Q4_K_M.gguf'),
'--port', '8081',
'--ctx-size', '8192',
'--embedding',
],
runInShell: false,
);
_nomicProcess!.stdout.drain<List<int>>();
_nomicProcess!.stderr.drain<List<int>>();
_running = true;
} catch (_) {
// Clean up any partially-started processes before rethrowing.
_qwenProcess?.kill();
_nomicProcess?.kill();
_qwenProcess = null;
_nomicProcess = null;
rethrow;
}
}
/// Kills both processes and resets the running flag.
/// Safe to call even if servers were never started.
Future<void> stopServers() async {
_qwenProcess?.kill();
_nomicProcess?.kill();
_qwenProcess = null;
_nomicProcess = null;
_running = false;
}
}

View File

@@ -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<List<double>> embed(String text) async {
final response = await _dio.post<Map<String, dynamic>>(
_url,
data: {
'input': text,
'model': 'nomic-embed-text-v1.5.Q4_K_M',
},
);
final raw =
(response.data!['data'] as List<dynamic>)[0]['embedding']
as List<dynamic>;
return raw.map((e) => (e as num).toDouble()).toList();
}
}

View File

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

View File

@@ -1,18 +1,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<TrainHubApp> createState() => _TrainHubAppState();
}
class _TrainHubAppState extends ConsumerState<TrainHubApp>
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<AiProcessManager>().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<AiModelSettingsState>(
aiModelSettingsControllerProvider,
(prev, next) {
if (!_serversStarted && next.areModelsValidated) {
_serversStarted = true;
di.getIt<AiProcessManager>().startServers();
}
},
);
return MaterialApp.router(
title: 'TrainHub',
theme: AppTheme.dark,
routerConfig: appRouter.config(),
routerConfig: _appRouter.config(),
debugShowCheckedModeBanner: false,
);
}

View File

@@ -1,6 +1,8 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:trainhub_flutter/injection.dart';
import 'package:trainhub_flutter/domain/entities/annotation.dart';
import 'package:trainhub_flutter/domain/repositories/analysis_repository.dart';
import 'package:trainhub_flutter/domain/repositories/exercise_repository.dart';
import 'package:trainhub_flutter/presentation/analysis/analysis_state.dart';
part 'analysis_controller.g.dart';
@@ -8,10 +10,12 @@ part 'analysis_controller.g.dart';
@riverpod
class AnalysisController extends _$AnalysisController {
late AnalysisRepository _repo;
late ExerciseRepository _exerciseRepo;
@override
Future<AnalysisState> build() async {
_repo = getIt<AnalysisRepository>();
_exerciseRepo = getIt<ExerciseRepository>();
final sessions = await _repo.getAllSessions();
return AnalysisState(sessions: sessions);
}
@@ -35,10 +39,7 @@ class AnalysisController extends _$AnalysisController {
final annotations = await _repo.getAnnotations(id);
final current = state.valueOrNull ?? const AnalysisState();
state = AsyncValue.data(
current.copyWith(
activeSession: session,
annotations: annotations,
),
current.copyWith(activeSession: session, annotations: annotations),
);
}
@@ -77,6 +78,34 @@ class AnalysisController extends _$AnalysisController {
state = AsyncValue.data(current.copyWith(annotations: annotations));
}
Future<void> updateAnnotation(AnnotationEntity annotation) async {
final current = state.valueOrNull;
if (current?.activeSession == null) return;
await _repo.updateAnnotation(
id: annotation.id,
name: annotation.name ?? '',
description: annotation.description ?? '',
color: annotation.color ?? 'grey',
);
final annotations = await _repo.getAnnotations(current!.activeSession!.id);
state = AsyncValue.data(current.copyWith(annotations: annotations));
}
Future<void> createExerciseFromAnnotation({
required String name,
required String instructions,
required String videoPath,
required double startTime,
required double endTime,
}) async {
final videoRef = '$videoPath#t=${startTime.toStringAsFixed(2)},${endTime.toStringAsFixed(2)}';
await _exerciseRepo.create(
name: name,
instructions: instructions.isEmpty ? null : instructions,
videoUrl: videoRef,
);
}
Future<void> deleteAnnotation(String id) async {
await _repo.deleteAnnotation(id);
final current = state.valueOrNull;

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,4 +1,6 @@
import 'package:flutter/material.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/utils/id_generator.dart';
import 'package:trainhub_flutter/domain/entities/program_week.dart';
import 'package:trainhub_flutter/domain/entities/program_workout.dart';
@@ -31,12 +33,12 @@ class ProgramWeekView extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Week ${week.position}",
'Week ${week.position}',
style: Theme.of(context).textTheme.headlineSmall,
),
const Divider(),
SizedBox(
height: 500, // Fixed height for the week grid, or make it dynamic
height: 500,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: List.generate(7, (dayIndex) {
@@ -46,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<ProgramWorkoutEntity> dayWorkouts;
final List<TrainingPlanEntity> availablePlans;
final ProgramWeekEntity week;
final Function(ProgramWorkoutEntity) onAddWorkout;
final Function(String) onDeleteWorkout;
const _DayColumn({
required this.dayNum,
required this.dayIndex,
required this.dayWorkouts,
required this.availablePlans,
required this.week,
required this.onAddWorkout,
required this.onDeleteWorkout,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
border: dayIndex < 6
? const Border(
right: BorderSide(color: Colors.grey, width: 0.5),
)
: 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<TrainingPlanEntity> availablePlans;
final ProgramWeekEntity week;
final int dayNum;
final Function(ProgramWorkoutEntity) onAddWorkout;
const _AddWorkoutSheet({
required this.availablePlans,
required this.week,
required this.dayNum,
required this.onAddWorkout,
});
@override
State<_AddWorkoutSheet> createState() => _AddWorkoutSheetState();
}
class _AddWorkoutSheetState extends State<_AddWorkoutSheet>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 16),
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.6,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
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<TrainingPlanEntity> availablePlans;
final ProgramWeekEntity week;
final int dayNum;
final Function(ProgramWorkoutEntity) onAddWorkout;
const _PlanPickerTab({
required this.availablePlans,
required this.week,
required this.dayNum,
required this.onAddWorkout,
});
@override
Widget build(BuildContext context) {
if (availablePlans.isEmpty) {
return const Padding(
padding: EdgeInsets.all(16.0),
child: Center(
child: Text('No training plans available. Create one first!'),
),
);
}
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'),
),
),
],
),
);
}
}

View File

@@ -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<ChatState> build() async {
_repo = getIt<ChatRepository>();
_noteRepo = getIt<NoteRepository>();
final sessions = await _repo.getAllSessions();
return ChatState(sessions: sessions);
}
// -------------------------------------------------------------------------
// Session management (unchanged)
// -------------------------------------------------------------------------
Future<void> 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<void> 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<void>.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<String> 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) => <String, String>{
'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<Map<String, dynamic>>(
_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<String> 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.';
}
}

View File

@@ -6,7 +6,7 @@ part of 'chat_controller.dart';
// RiverpodGenerator
// **************************************************************************
String _$chatControllerHash() => r'44a3d0e906eaad16f7a9c292fe847b8bd144c835';
String _$chatControllerHash() => r'06ffc6b53c1d878ffc0a758da4f7ee1261ae1340';
/// See also [ChatController].
@ProviderFor(ChatController)

View File

@@ -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<ChatPage> {
@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<ChatPage> {
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<ChatPage> {
),
child: Column(
children: [
// New Chat button
Padding(
padding: const EdgeInsets.all(UIConstants.spacing12),
child: SizedBox(
@@ -130,7 +145,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
const Divider(height: 1, color: AppColors.border),
// Session list
Expanded(
child: asyncState.when(
data: (data) {
@@ -168,7 +182,10 @@ class _ChatPageState extends ConsumerState<ChatPage> {
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<ChatPage> {
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<ChatPage> {
: isHovered
? AppColors.zinc800.withValues(alpha: 0.6)
: Colors.transparent,
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
borderRadius:
BorderRadius.circular(UIConstants.smallBorderRadius),
border: isActive
? Border.all(
color: AppColors.accent.withValues(alpha: 0.3),
@@ -251,7 +267,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
),
),
),
// 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<ChatPage> {
) {
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<ChatPage> {
),
),
),
// Input area
_buildInputBar(asyncState, controller),
],
);
@@ -386,10 +395,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
const SizedBox(height: UIConstants.spacing8),
const Text(
'Start a conversation to get personalized advice.',
style: TextStyle(
color: AppColors.textMuted,
fontSize: 13,
),
style: TextStyle(color: AppColors.textMuted, fontSize: 13),
),
],
),
@@ -408,9 +414,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
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<ChatPage> {
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<ChatPage> {
}
}
// =============================================================================
// 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(

View File

@@ -0,0 +1,13 @@
import 'package:trainhub_flutter/domain/entities/training_exercise.dart';
class ExerciseDragData {
const ExerciseDragData({
required this.fromSectionIndex,
required this.exerciseIndex,
required this.exercise,
});
final int fromSectionIndex;
final int exerciseIndex;
final TrainingExerciseEntity exercise;
}

View File

@@ -3,7 +3,6 @@ import 'package:trainhub_flutter/core/utils/id_generator.dart';
import 'package:trainhub_flutter/injection.dart';
import 'package:trainhub_flutter/domain/entities/exercise.dart';
import 'package:trainhub_flutter/domain/entities/training_exercise.dart';
import 'package:trainhub_flutter/domain/entities/training_plan.dart';
import 'package:trainhub_flutter/domain/entities/training_section.dart';
import 'package:trainhub_flutter/domain/repositories/exercise_repository.dart';
import 'package:trainhub_flutter/domain/repositories/training_plan_repository.dart';
@@ -14,14 +13,15 @@ part 'plan_editor_controller.g.dart';
@riverpod
class PlanEditorController extends _$PlanEditorController {
late TrainingPlanRepository _planRepo;
late ExerciseRepository _exerciseRepo;
@override
Future<PlanEditorState> build(String planId) async {
_planRepo = getIt<TrainingPlanRepository>();
final ExerciseRepository exerciseRepo = getIt<ExerciseRepository>();
_exerciseRepo = getIt<ExerciseRepository>();
final plan = await _planRepo.getById(planId);
final exercises = await exerciseRepo.getAll();
final exercises = await _exerciseRepo.getAll();
return PlanEditorState(plan: plan, availableExercises: exercises);
}
@@ -64,6 +64,21 @@ class PlanEditorController extends _$PlanEditorController {
);
}
void reorderSection(int oldIndex, int newIndex) {
final current = state.valueOrNull;
if (current == null) return;
final sections = List<TrainingSectionEntity>.from(current.plan.sections);
if (oldIndex < newIndex) newIndex -= 1;
final item = sections.removeAt(oldIndex);
sections.insert(newIndex, item);
state = AsyncValue.data(
current.copyWith(
plan: current.plan.copyWith(sections: sections),
isDirty: true,
),
);
}
void updateSectionName(int sectionIndex, String name) {
final current = state.valueOrNull;
if (current == null) return;
@@ -116,6 +131,37 @@ class PlanEditorController extends _$PlanEditorController {
);
}
void moveExerciseBetweenSections({
required int fromSectionIndex,
required int exerciseIndex,
required int toSectionIndex,
}) {
final current = state.valueOrNull;
if (current == null) return;
if (fromSectionIndex == toSectionIndex) return;
final sections = List<TrainingSectionEntity>.from(current.plan.sections);
final fromExercises = List<TrainingExerciseEntity>.from(
sections[fromSectionIndex].exercises,
);
final toExercises = List<TrainingExerciseEntity>.from(
sections[toSectionIndex].exercises,
);
final exercise = fromExercises.removeAt(exerciseIndex);
toExercises.add(exercise);
sections[fromSectionIndex] = sections[fromSectionIndex].copyWith(
exercises: fromExercises,
);
sections[toSectionIndex] = sections[toSectionIndex].copyWith(
exercises: toExercises,
);
state = AsyncValue.data(
current.copyWith(
plan: current.plan.copyWith(sections: sections),
isDirty: true,
),
);
}
void updateExerciseParams(
int sectionIndex,
int exerciseIndex, {
@@ -168,6 +214,25 @@ class PlanEditorController extends _$PlanEditorController {
);
}
Future<ExerciseEntity> createExercise({
required String name,
String? instructions,
String? tags,
String? videoUrl,
}) async {
final current = state.valueOrNull;
if (current == null) throw StateError('Controller state not loaded');
final exercise = await _exerciseRepo.create(
name: name,
instructions: instructions,
tags: tags,
videoUrl: videoUrl,
);
final exercises = await _exerciseRepo.getAll();
state = AsyncValue.data(current.copyWith(availableExercises: exercises));
return exercise;
}
Future<void> save() async {
final current = state.valueOrNull;
if (current == null) return;

View File

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

View File

@@ -1,6 +1,9 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
import 'package:trainhub_flutter/presentation/plan_editor/plan_editor_controller.dart';
import 'package:trainhub_flutter/presentation/plan_editor/widgets/plan_section_card.dart';
@@ -16,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()),
),
),
],
),
);
}

View File

@@ -1,10 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
import 'package:trainhub_flutter/domain/entities/training_exercise.dart';
import 'package:trainhub_flutter/domain/entities/training_plan.dart';
import 'package:trainhub_flutter/presentation/plan_editor/models/exercise_drag_data.dart';
import 'package:trainhub_flutter/presentation/plan_editor/plan_editor_controller.dart';
class PlanExerciseTile extends ConsumerWidget {
class PlanExerciseTile extends ConsumerStatefulWidget {
final TrainingExerciseEntity exercise;
final int sectionIndex;
final int exerciseIndex;
@@ -19,100 +23,192 @@ class PlanExerciseTile extends ConsumerWidget {
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = ref.read(planEditorControllerProvider(plan.id).notifier);
ConsumerState<PlanExerciseTile> createState() => _PlanExerciseTileState();
}
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
elevation: 0,
color: Theme.of(context).colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(
side: BorderSide(color: Theme.of(context).colorScheme.outlineVariant),
class _PlanExerciseTileState extends ConsumerState<PlanExerciseTile> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
final controller = ref.read(
planEditorControllerProvider(widget.plan.id).notifier,
);
final dragData = ExerciseDragData(
fromSectionIndex: widget.sectionIndex,
exerciseIndex: widget.exerciseIndex,
exercise: widget.exercise,
);
return LongPressDraggable<ExerciseDragData>(
data: dragData,
feedback: Material(
elevation: 8,
borderRadius: BorderRadius.circular(8),
child: Container(
width: 220,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: AppColors.zinc800,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.accent.withValues(alpha: 0.5)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.drag_indicator,
size: 15,
color: AppColors.accent,
),
const SizedBox(width: 8),
Flexible(
child: Text(
widget.exercise.name,
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
childWhenDragging: Opacity(
opacity: 0.35,
child: _buildContent(controller),
),
child: MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: _buildContent(controller),
),
);
}
Widget _buildContent(PlanEditorController controller) {
return AnimatedContainer(
duration: UIConstants.animationDuration,
margin: const EdgeInsets.symmetric(vertical: 3),
decoration: BoxDecoration(
color: _isHovered ? AppColors.zinc800 : AppColors.zinc900,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _isHovered ? AppColors.zinc700 : AppColors.border,
),
),
child: Padding(
padding: const EdgeInsets.all(8.0),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Column(
children: [
// --- Header: drag handle + name + delete ---
Row(
children: [
const Icon(
Icons.drag_handle,
size: 15,
color: AppColors.zinc600,
),
const SizedBox(width: 8),
Expanded(
child: Text(
exercise.name,
style: const TextStyle(fontWeight: FontWeight.bold),
widget.exercise.name,
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.textPrimary,
),
overflow: TextOverflow.ellipsis,
),
),
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);
},
),
),

View File

@@ -1,12 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
import 'package:trainhub_flutter/domain/entities/exercise.dart';
import 'package:trainhub_flutter/domain/entities/training_plan.dart';
import 'package:trainhub_flutter/domain/entities/training_section.dart';
import 'package:trainhub_flutter/presentation/plan_editor/models/exercise_drag_data.dart';
import 'package:trainhub_flutter/presentation/plan_editor/plan_editor_controller.dart';
import 'package:trainhub_flutter/presentation/plan_editor/widgets/plan_exercise_tile.dart';
class PlanSectionCard extends ConsumerWidget {
class PlanSectionCard extends ConsumerStatefulWidget {
final TrainingSectionEntity section;
final int sectionIndex;
final TrainingPlanEntity plan;
@@ -21,73 +25,251 @@ class PlanSectionCard extends ConsumerWidget {
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = ref.read(planEditorControllerProvider(plan.id).notifier);
ConsumerState<PlanSectionCard> createState() => _PlanSectionCardState();
}
return Card(
margin: const EdgeInsets.only(bottom: 16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
class _PlanSectionCardState extends ConsumerState<PlanSectionCard> {
bool _isDragOver = false;
bool _isHovered = false;
@override
Widget build(BuildContext context) {
final controller = ref.read(
planEditorControllerProvider(widget.plan.id).notifier,
);
return DragTarget<ExerciseDragData>(
onWillAcceptWithDetails: (details) =>
details.data.fromSectionIndex != widget.sectionIndex,
onAcceptWithDetails: (details) {
controller.moveExerciseBetweenSections(
fromSectionIndex: details.data.fromSectionIndex,
exerciseIndex: details.data.exerciseIndex,
toSectionIndex: widget.sectionIndex,
);
setState(() => _isDragOver = false);
},
onLeave: (_) => setState(() => _isDragOver = false),
onMove: (_) => setState(() => _isDragOver = true),
builder: (context, candidateData, rejectedData) {
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: AnimatedContainer(
duration: UIConstants.animationDuration,
margin: const EdgeInsets.only(bottom: UIConstants.spacing16),
decoration: BoxDecoration(
color: AppColors.surfaceContainer,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _isDragOver ? AppColors.accent : AppColors.border,
width: _isDragOver ? 2 : 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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,
),
),
],
),
),
),
);
}
}

View File

@@ -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<String> _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<void> validateModels() async {
state = state.copyWith(
currentTask: 'Checking installed files…',
errorMessage: null,
);
try {
final dir = await getApplicationDocumentsDirectory();
final base = dir.path;
final serverBin = File(p.join(base, _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<void> 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<void> _downloadFile({
required String url,
required String savePath,
required String taskLabel,
required double overallStart,
required double overallEnd,
}) async {
state = state.copyWith(currentTask: taskLabel, progress: overallStart);
await _dio.download(
url,
savePath,
onReceiveProgress: (received, total) {
if (total <= 0) return;
final fileProgress = received / total;
final overall =
overallStart + fileProgress * (overallEnd - overallStart);
state = state.copyWith(progress: overall);
},
options: Options(
followRedirects: true,
maxRedirects: 5,
receiveTimeout: const Duration(hours: 2),
),
);
}
/// Extracts the downloaded archive and moves `llama-server[.exe]` to [destDir].
Future<void> _extractBinary(String archivePath, String destDir) async {
final extractDir = p.join(destDir, '_llama_extract_tmp');
final extractDirObj = Directory(extractDir);
if (extractDirObj.existsSync()) extractDirObj.deleteSync(recursive: true);
extractDirObj.createSync(recursive: true);
try {
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;
}
}

View File

@@ -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<AiModelSettingsState>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -0,0 +1,14 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'ai_model_settings_state.freezed.dart';
@freezed
class AiModelSettingsState with _$AiModelSettingsState {
const factory AiModelSettingsState({
@Default(false) bool isDownloading,
@Default(0.0) double progress,
@Default('') String currentTask,
@Default(false) bool areModelsValidated,
String? errorMessage,
}) = _AiModelSettingsState;
}

View File

@@ -0,0 +1,263 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'ai_model_settings_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
);
/// @nodoc
mixin _$AiModelSettingsState {
bool get isDownloading => throw _privateConstructorUsedError;
double get progress => throw _privateConstructorUsedError;
String get currentTask => throw _privateConstructorUsedError;
bool get areModelsValidated => throw _privateConstructorUsedError;
String? get errorMessage => throw _privateConstructorUsedError;
/// Create a copy of AiModelSettingsState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$AiModelSettingsStateCopyWith<AiModelSettingsState> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $AiModelSettingsStateCopyWith<$Res> {
factory $AiModelSettingsStateCopyWith(
AiModelSettingsState value,
$Res Function(AiModelSettingsState) then,
) = _$AiModelSettingsStateCopyWithImpl<$Res, AiModelSettingsState>;
@useResult
$Res call({
bool isDownloading,
double progress,
String currentTask,
bool areModelsValidated,
String? errorMessage,
});
}
/// @nodoc
class _$AiModelSettingsStateCopyWithImpl<
$Res,
$Val extends AiModelSettingsState
>
implements $AiModelSettingsStateCopyWith<$Res> {
_$AiModelSettingsStateCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of AiModelSettingsState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? isDownloading = null,
Object? progress = null,
Object? currentTask = null,
Object? areModelsValidated = null,
Object? errorMessage = freezed,
}) {
return _then(
_value.copyWith(
isDownloading: null == isDownloading
? _value.isDownloading
: isDownloading // ignore: cast_nullable_to_non_nullable
as bool,
progress: null == progress
? _value.progress
: progress // ignore: cast_nullable_to_non_nullable
as double,
currentTask: null == currentTask
? _value.currentTask
: currentTask // ignore: cast_nullable_to_non_nullable
as String,
areModelsValidated: null == areModelsValidated
? _value.areModelsValidated
: areModelsValidated // ignore: cast_nullable_to_non_nullable
as bool,
errorMessage: freezed == errorMessage
? _value.errorMessage
: errorMessage // ignore: cast_nullable_to_non_nullable
as String?,
)
as $Val,
);
}
}
/// @nodoc
abstract class _$$AiModelSettingsStateImplCopyWith<$Res>
implements $AiModelSettingsStateCopyWith<$Res> {
factory _$$AiModelSettingsStateImplCopyWith(
_$AiModelSettingsStateImpl value,
$Res Function(_$AiModelSettingsStateImpl) then,
) = __$$AiModelSettingsStateImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({
bool isDownloading,
double progress,
String currentTask,
bool areModelsValidated,
String? errorMessage,
});
}
/// @nodoc
class __$$AiModelSettingsStateImplCopyWithImpl<$Res>
extends _$AiModelSettingsStateCopyWithImpl<$Res, _$AiModelSettingsStateImpl>
implements _$$AiModelSettingsStateImplCopyWith<$Res> {
__$$AiModelSettingsStateImplCopyWithImpl(
_$AiModelSettingsStateImpl _value,
$Res Function(_$AiModelSettingsStateImpl) _then,
) : super(_value, _then);
/// Create a copy of AiModelSettingsState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? isDownloading = null,
Object? progress = null,
Object? currentTask = null,
Object? areModelsValidated = null,
Object? errorMessage = freezed,
}) {
return _then(
_$AiModelSettingsStateImpl(
isDownloading: null == isDownloading
? _value.isDownloading
: isDownloading // ignore: cast_nullable_to_non_nullable
as bool,
progress: null == progress
? _value.progress
: progress // ignore: cast_nullable_to_non_nullable
as double,
currentTask: null == currentTask
? _value.currentTask
: currentTask // ignore: cast_nullable_to_non_nullable
as String,
areModelsValidated: null == areModelsValidated
? _value.areModelsValidated
: areModelsValidated // ignore: cast_nullable_to_non_nullable
as bool,
errorMessage: freezed == errorMessage
? _value.errorMessage
: errorMessage // ignore: cast_nullable_to_non_nullable
as String?,
),
);
}
}
/// @nodoc
class _$AiModelSettingsStateImpl implements _AiModelSettingsState {
const _$AiModelSettingsStateImpl({
this.isDownloading = false,
this.progress = 0.0,
this.currentTask = '',
this.areModelsValidated = false,
this.errorMessage,
});
@override
@JsonKey()
final bool isDownloading;
@override
@JsonKey()
final double progress;
@override
@JsonKey()
final String currentTask;
@override
@JsonKey()
final bool areModelsValidated;
@override
final String? errorMessage;
@override
String toString() {
return 'AiModelSettingsState(isDownloading: $isDownloading, progress: $progress, currentTask: $currentTask, areModelsValidated: $areModelsValidated, errorMessage: $errorMessage)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$AiModelSettingsStateImpl &&
(identical(other.isDownloading, isDownloading) ||
other.isDownloading == isDownloading) &&
(identical(other.progress, progress) ||
other.progress == progress) &&
(identical(other.currentTask, currentTask) ||
other.currentTask == currentTask) &&
(identical(other.areModelsValidated, areModelsValidated) ||
other.areModelsValidated == areModelsValidated) &&
(identical(other.errorMessage, errorMessage) ||
other.errorMessage == errorMessage));
}
@override
int get hashCode => Object.hash(
runtimeType,
isDownloading,
progress,
currentTask,
areModelsValidated,
errorMessage,
);
/// Create a copy of AiModelSettingsState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$AiModelSettingsStateImplCopyWith<_$AiModelSettingsStateImpl>
get copyWith =>
__$$AiModelSettingsStateImplCopyWithImpl<_$AiModelSettingsStateImpl>(
this,
_$identity,
);
}
abstract class _AiModelSettingsState implements AiModelSettingsState {
const factory _AiModelSettingsState({
final bool isDownloading,
final double progress,
final String currentTask,
final bool areModelsValidated,
final String? errorMessage,
}) = _$AiModelSettingsStateImpl;
@override
bool get isDownloading;
@override
double get progress;
@override
String get currentTask;
@override
bool get areModelsValidated;
@override
String? get errorMessage;
/// Create a copy of AiModelSettingsState
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$AiModelSettingsStateImplCopyWith<_$AiModelSettingsStateImpl>
get copyWith => throw _privateConstructorUsedError;
}

View File

@@ -0,0 +1,102 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:trainhub_flutter/domain/repositories/note_repository.dart';
import 'package:trainhub_flutter/injection.dart';
import 'package:trainhub_flutter/presentation/settings/knowledge_base_state.dart';
part 'knowledge_base_controller.g.dart';
@riverpod
class KnowledgeBaseController extends _$KnowledgeBaseController {
late NoteRepository _repo;
@override
KnowledgeBaseState build() {
_repo = getIt<NoteRepository>();
// Load the current chunk count asynchronously after first build.
_loadCount();
return const KnowledgeBaseState();
}
// -------------------------------------------------------------------------
// Load chunk count
// -------------------------------------------------------------------------
Future<void> _loadCount() async {
try {
final count = await _repo.getChunkCount();
state = state.copyWith(chunkCount: count);
} catch (_) {
// Non-fatal — UI stays at 0.
}
}
// -------------------------------------------------------------------------
// Save note
// -------------------------------------------------------------------------
/// Chunks [text], generates embeddings via Nomic, and stores the result.
Future<void> saveNote(String text) async {
if (text.trim().isEmpty) return;
state = state.copyWith(
isLoading: true,
successMessage: null,
errorMessage: null,
);
try {
await _repo.addNote(text.trim());
final count = await _repo.getChunkCount();
state = state.copyWith(
isLoading: false,
chunkCount: count,
successMessage: 'Saved! Knowledge base now has $count chunks.',
);
} catch (e) {
state = state.copyWith(
isLoading: false,
errorMessage: _friendlyError(e),
);
}
}
// -------------------------------------------------------------------------
// Clear knowledge base
// -------------------------------------------------------------------------
Future<void> clearKnowledgeBase() async {
state = state.copyWith(
isLoading: true,
successMessage: null,
errorMessage: null,
);
try {
await _repo.clearAll();
state = state.copyWith(
isLoading: false,
chunkCount: 0,
successMessage: 'Knowledge base cleared.',
);
} catch (e) {
state = state.copyWith(
isLoading: false,
errorMessage: _friendlyError(e),
);
}
}
// -------------------------------------------------------------------------
// Helper
// -------------------------------------------------------------------------
String _friendlyError(Object e) {
final msg = e.toString();
if (msg.contains('Connection refused') ||
msg.contains('SocketException')) {
return 'Cannot reach the embedding server. '
'Make sure AI models are downloaded and the app has had time to '
'start the inference servers.';
}
return 'Error: $msg';
}
}

View File

@@ -0,0 +1,30 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'knowledge_base_controller.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$knowledgeBaseControllerHash() =>
r'1b18418c3e7a66c6517dbbd7167e7406e16c8748';
/// See also [KnowledgeBaseController].
@ProviderFor(KnowledgeBaseController)
final knowledgeBaseControllerProvider =
AutoDisposeNotifierProvider<
KnowledgeBaseController,
KnowledgeBaseState
>.internal(
KnowledgeBaseController.new,
name: r'knowledgeBaseControllerProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$knowledgeBaseControllerHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$KnowledgeBaseController = AutoDisposeNotifier<KnowledgeBaseState>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -0,0 +1,726 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
import 'package:trainhub_flutter/presentation/settings/knowledge_base_controller.dart';
import 'package:trainhub_flutter/presentation/settings/knowledge_base_state.dart';
@RoutePage()
class KnowledgeBasePage extends ConsumerStatefulWidget {
const KnowledgeBasePage({super.key});
@override
ConsumerState<KnowledgeBasePage> createState() => _KnowledgeBasePageState();
}
class _KnowledgeBasePageState extends ConsumerState<KnowledgeBasePage> {
final _textController = TextEditingController();
@override
void dispose() {
_textController.dispose();
super.dispose();
}
Future<void> _save(KnowledgeBaseController controller) async {
await controller.saveNote(_textController.text);
// Only clear the field if save succeeded (no error in state).
if (!mounted) return;
final s = ref.read(knowledgeBaseControllerProvider);
if (s.successMessage != null) _textController.clear();
}
Future<void> _clear(KnowledgeBaseController controller) async {
final confirmed = await _showConfirmDialog();
if (!confirmed) return;
await controller.clearKnowledgeBase();
}
Future<bool> _showConfirmDialog() async {
return await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.surfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(UIConstants.borderRadius),
side: const BorderSide(color: AppColors.border),
),
title: Text(
'Clear knowledge base?',
style: GoogleFonts.inter(
color: AppColors.textPrimary,
fontWeight: FontWeight.w600,
fontSize: 15,
),
),
content: Text(
'This will permanently delete all stored chunks and embeddings. '
'This action cannot be undone.',
style: GoogleFonts.inter(
color: AppColors.textSecondary,
fontSize: 13,
height: 1.5,
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: Text(
'Cancel',
style: GoogleFonts.inter(color: AppColors.textMuted),
),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: Text(
'Clear',
style: GoogleFonts.inter(color: AppColors.destructive),
),
),
],
),
) ??
false;
}
@override
Widget build(BuildContext context) {
final kbState = ref.watch(knowledgeBaseControllerProvider);
final controller = ref.read(knowledgeBaseControllerProvider.notifier);
// Show success SnackBar when a note is saved successfully.
ref.listen<KnowledgeBaseState>(knowledgeBaseControllerProvider,
(prev, next) {
if (next.successMessage != null &&
next.successMessage != prev?.successMessage) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
next.successMessage!,
style: GoogleFonts.inter(
color: AppColors.textPrimary,
fontSize: 13,
),
),
backgroundColor: AppColors.surfaceContainerHigh,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(UIConstants.smallBorderRadius),
side: const BorderSide(
color: AppColors.success,
width: 1,
),
),
duration: const Duration(seconds: 3),
),
);
}
});
return Scaffold(
backgroundColor: AppColors.surface,
body: Column(
children: [
// ── Top bar ──────────────────────────────────────────────────────
_TopBar(onBack: () => context.router.maybePop()),
// ── Content ──────────────────────────────────────────────────────
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(UIConstants.pagePadding),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 680),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Heading
Text(
'Knowledge Base',
style: GoogleFonts.inter(
fontSize: 22,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
letterSpacing: -0.3,
),
),
const SizedBox(height: UIConstants.spacing8),
Text(
'Paste your fitness or university notes below. '
'They will be split into chunks, embedded with the '
'Nomic model, and used as context when you chat with '
'the AI — no internet required.',
style: GoogleFonts.inter(
fontSize: 13,
color: AppColors.textSecondary,
height: 1.6,
),
),
const SizedBox(height: UIConstants.spacing24),
// ── Status card ──────────────────────────────────────
_StatusCard(chunkCount: kbState.chunkCount),
const SizedBox(height: UIConstants.spacing24),
// ── Text input ───────────────────────────────────────
_SectionLabel('Paste Notes'),
const SizedBox(height: UIConstants.spacing8),
Container(
decoration: BoxDecoration(
color: AppColors.surfaceContainer,
borderRadius: BorderRadius.circular(
UIConstants.borderRadius,
),
border: Border.all(color: AppColors.border),
),
child: TextField(
controller: _textController,
style: GoogleFonts.inter(
fontSize: 13,
color: AppColors.textPrimary,
height: 1.6,
),
maxLines: 14,
minLines: 8,
decoration: InputDecoration(
hintText:
'Paste lecture notes, programming guides, '
'exercise descriptions, research summaries…',
hintStyle: GoogleFonts.inter(
fontSize: 13,
color: AppColors.textMuted,
height: 1.6,
),
contentPadding: const EdgeInsets.all(
UIConstants.cardPadding,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
),
enabled: !kbState.isLoading,
),
),
// ── Error message ────────────────────────────────────
if (kbState.errorMessage != null) ...[
const SizedBox(height: UIConstants.spacing12),
_ErrorBanner(message: kbState.errorMessage!),
],
const SizedBox(height: UIConstants.spacing16),
// ── Action buttons ───────────────────────────────────
if (kbState.isLoading)
_LoadingIndicator()
else
Row(
children: [
Expanded(
child: _SaveButton(
onPressed: () => _save(controller),
),
),
if (kbState.chunkCount > 0) ...[
const SizedBox(width: UIConstants.spacing12),
_ClearButton(
onPressed: () => _clear(controller),
),
],
],
),
const SizedBox(height: UIConstants.spacing32),
// ── How it works ─────────────────────────────────────
_HowItWorksCard(),
],
),
),
),
),
),
],
),
);
}
}
// =============================================================================
// Top bar
// =============================================================================
class _TopBar extends StatelessWidget {
const _TopBar({required this.onBack});
final VoidCallback onBack;
@override
Widget build(BuildContext context) {
return Container(
height: 52,
padding: const EdgeInsets.symmetric(horizontal: UIConstants.spacing16),
decoration: const BoxDecoration(
color: AppColors.surfaceContainer,
border: Border(bottom: BorderSide(color: AppColors.border)),
),
child: Row(
children: [
_IconBtn(icon: Icons.arrow_back_rounded, onTap: onBack),
const SizedBox(width: UIConstants.spacing12),
Text(
'Knowledge Base',
style: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
],
),
);
}
}
// =============================================================================
// Status card
// =============================================================================
class _StatusCard extends StatelessWidget {
const _StatusCard({required this.chunkCount});
final int chunkCount;
@override
Widget build(BuildContext context) {
final hasChunks = chunkCount > 0;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing16,
vertical: UIConstants.spacing12,
),
decoration: BoxDecoration(
color: hasChunks ? AppColors.successMuted : AppColors.surfaceContainer,
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
border: Border.all(
color: hasChunks
? AppColors.success.withValues(alpha: 0.3)
: AppColors.border,
),
),
child: Row(
children: [
Icon(
hasChunks
? Icons.check_circle_outline_rounded
: Icons.info_outline_rounded,
size: 16,
color: hasChunks ? AppColors.success : AppColors.textMuted,
),
const SizedBox(width: UIConstants.spacing12),
Expanded(
child: Text(
hasChunks
? '$chunkCount chunk${chunkCount == 1 ? '' : 's'} stored — '
'AI chat will use these as context.'
: 'No notes added yet. The AI chat will use only its base '
'training knowledge.',
style: GoogleFonts.inter(
fontSize: 13,
color: hasChunks ? AppColors.success : AppColors.textMuted,
height: 1.4,
),
),
),
],
),
);
}
}
// =============================================================================
// Loading indicator
// =============================================================================
class _LoadingIndicator extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(vertical: UIConstants.spacing16),
child: Row(
children: [
const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.accent,
),
),
const SizedBox(width: UIConstants.spacing12),
Text(
'Generating embeddings… this may take a moment.',
style: GoogleFonts.inter(
fontSize: 13,
color: AppColors.textSecondary,
),
),
],
),
);
}
}
// =============================================================================
// Save button
// =============================================================================
class _SaveButton extends StatefulWidget {
const _SaveButton({required this.onPressed});
final VoidCallback onPressed;
@override
State<_SaveButton> createState() => _SaveButtonState();
}
class _SaveButtonState extends State<_SaveButton> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: AnimatedContainer(
duration: UIConstants.animationDuration,
height: 42,
decoration: BoxDecoration(
color: _hovered
? AppColors.accent.withValues(alpha: 0.85)
: AppColors.accent,
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius:
BorderRadius.circular(UIConstants.smallBorderRadius),
onTap: widget.onPressed,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.save_outlined,
color: AppColors.zinc950,
size: 16,
),
const SizedBox(width: UIConstants.spacing8),
Text(
'Save to Knowledge Base',
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.zinc950,
),
),
],
),
),
),
),
);
}
}
// =============================================================================
// Clear button
// =============================================================================
class _ClearButton extends StatefulWidget {
const _ClearButton({required this.onPressed});
final VoidCallback onPressed;
@override
State<_ClearButton> createState() => _ClearButtonState();
}
class _ClearButtonState extends State<_ClearButton> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: AnimatedContainer(
duration: UIConstants.animationDuration,
height: 42,
decoration: BoxDecoration(
color: _hovered ? AppColors.destructiveMuted : Colors.transparent,
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
border: Border.all(
color: _hovered
? AppColors.destructive.withValues(alpha: 0.4)
: AppColors.border,
),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius:
BorderRadius.circular(UIConstants.smallBorderRadius),
onTap: widget.onPressed,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing16,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.delete_outline_rounded,
size: 15,
color:
_hovered ? AppColors.destructive : AppColors.textMuted,
),
const SizedBox(width: UIConstants.spacing8),
Text(
'Clear All',
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w500,
color: _hovered
? AppColors.destructive
: AppColors.textMuted,
),
),
],
),
),
),
),
),
);
}
}
// =============================================================================
// Error banner
// =============================================================================
class _ErrorBanner extends StatelessWidget {
const _ErrorBanner({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(UIConstants.spacing12),
decoration: BoxDecoration(
color: AppColors.destructiveMuted,
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
border: Border.all(
color: AppColors.destructive.withValues(alpha: 0.4),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.error_outline_rounded,
color: AppColors.destructive,
size: 15,
),
const SizedBox(width: UIConstants.spacing8),
Expanded(
child: Text(
message,
style: GoogleFonts.inter(
fontSize: 12,
color: AppColors.destructive,
height: 1.5,
),
),
),
],
),
);
}
}
// =============================================================================
// How it works info card
// =============================================================================
class _HowItWorksCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(UIConstants.cardPadding),
decoration: BoxDecoration(
color: AppColors.surfaceContainer,
borderRadius: BorderRadius.circular(UIConstants.borderRadius),
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(
Icons.info_outline_rounded,
size: 15,
color: AppColors.textMuted,
),
const SizedBox(width: UIConstants.spacing8),
Text(
'How it works',
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
],
),
const SizedBox(height: UIConstants.spacing12),
_Step(
n: '1',
text: 'Your text is split into ~500-character chunks at paragraph '
'and sentence boundaries.',
),
const SizedBox(height: UIConstants.spacing8),
_Step(
n: '2',
text:
'Each chunk is embedded by the local Nomic model into a 768-dim '
'vector and stored in the database.',
),
const SizedBox(height: UIConstants.spacing8),
_Step(
n: '3',
text:
'When you ask a question, the 3 most similar chunks are retrieved '
'and injected into the AI system prompt as context.',
),
const SizedBox(height: UIConstants.spacing8),
_Step(
n: '4',
text:
'Everything stays on your device — no data leaves the machine.',
),
],
),
);
}
}
class _Step extends StatelessWidget {
const _Step({required this.n, required this.text});
final String n;
final String text;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: AppColors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: Text(
n,
style: GoogleFonts.inter(
fontSize: 11,
fontWeight: FontWeight.w700,
color: AppColors.accent,
),
),
),
),
const SizedBox(width: UIConstants.spacing8),
Expanded(
child: Text(
text,
style: GoogleFonts.inter(
fontSize: 12,
color: AppColors.textMuted,
height: 1.5,
),
),
),
],
);
}
}
// =============================================================================
// Small reusable widgets
// =============================================================================
class _SectionLabel extends StatelessWidget {
const _SectionLabel(this.label);
final String label;
@override
Widget build(BuildContext context) {
return Text(
label,
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textMuted,
letterSpacing: 0.8,
),
);
}
}
class _IconBtn extends StatefulWidget {
const _IconBtn({required this.icon, required this.onTap});
final IconData icon;
final VoidCallback onTap;
@override
State<_IconBtn> createState() => _IconBtnState();
}
class _IconBtnState extends State<_IconBtn> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: UIConstants.animationDuration,
width: 32,
height: 32,
decoration: BoxDecoration(
color: _hovered ? AppColors.zinc800 : Colors.transparent,
borderRadius: BorderRadius.circular(6),
),
child: Icon(
widget.icon,
size: 18,
color: _hovered ? AppColors.textPrimary : AppColors.textSecondary,
),
),
),
);
}
}

View File

@@ -0,0 +1,13 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'knowledge_base_state.freezed.dart';
@freezed
class KnowledgeBaseState with _$KnowledgeBaseState {
const factory KnowledgeBaseState({
@Default(false) bool isLoading,
@Default(0) int chunkCount,
String? successMessage,
String? errorMessage,
}) = _KnowledgeBaseState;
}

View File

@@ -0,0 +1,235 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'knowledge_base_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
);
/// @nodoc
mixin _$KnowledgeBaseState {
bool get isLoading => throw _privateConstructorUsedError;
int get chunkCount => throw _privateConstructorUsedError;
String? get successMessage => throw _privateConstructorUsedError;
String? get errorMessage => throw _privateConstructorUsedError;
/// Create a copy of KnowledgeBaseState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$KnowledgeBaseStateCopyWith<KnowledgeBaseState> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $KnowledgeBaseStateCopyWith<$Res> {
factory $KnowledgeBaseStateCopyWith(
KnowledgeBaseState value,
$Res Function(KnowledgeBaseState) then,
) = _$KnowledgeBaseStateCopyWithImpl<$Res, KnowledgeBaseState>;
@useResult
$Res call({
bool isLoading,
int chunkCount,
String? successMessage,
String? errorMessage,
});
}
/// @nodoc
class _$KnowledgeBaseStateCopyWithImpl<$Res, $Val extends KnowledgeBaseState>
implements $KnowledgeBaseStateCopyWith<$Res> {
_$KnowledgeBaseStateCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of KnowledgeBaseState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? isLoading = null,
Object? chunkCount = null,
Object? successMessage = freezed,
Object? errorMessage = freezed,
}) {
return _then(
_value.copyWith(
isLoading: null == isLoading
? _value.isLoading
: isLoading // ignore: cast_nullable_to_non_nullable
as bool,
chunkCount: null == chunkCount
? _value.chunkCount
: chunkCount // ignore: cast_nullable_to_non_nullable
as int,
successMessage: freezed == successMessage
? _value.successMessage
: successMessage // ignore: cast_nullable_to_non_nullable
as String?,
errorMessage: freezed == errorMessage
? _value.errorMessage
: errorMessage // ignore: cast_nullable_to_non_nullable
as String?,
)
as $Val,
);
}
}
/// @nodoc
abstract class _$$KnowledgeBaseStateImplCopyWith<$Res>
implements $KnowledgeBaseStateCopyWith<$Res> {
factory _$$KnowledgeBaseStateImplCopyWith(
_$KnowledgeBaseStateImpl value,
$Res Function(_$KnowledgeBaseStateImpl) then,
) = __$$KnowledgeBaseStateImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({
bool isLoading,
int chunkCount,
String? successMessage,
String? errorMessage,
});
}
/// @nodoc
class __$$KnowledgeBaseStateImplCopyWithImpl<$Res>
extends _$KnowledgeBaseStateCopyWithImpl<$Res, _$KnowledgeBaseStateImpl>
implements _$$KnowledgeBaseStateImplCopyWith<$Res> {
__$$KnowledgeBaseStateImplCopyWithImpl(
_$KnowledgeBaseStateImpl _value,
$Res Function(_$KnowledgeBaseStateImpl) _then,
) : super(_value, _then);
/// Create a copy of KnowledgeBaseState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? isLoading = null,
Object? chunkCount = null,
Object? successMessage = freezed,
Object? errorMessage = freezed,
}) {
return _then(
_$KnowledgeBaseStateImpl(
isLoading: null == isLoading
? _value.isLoading
: isLoading // ignore: cast_nullable_to_non_nullable
as bool,
chunkCount: null == chunkCount
? _value.chunkCount
: chunkCount // ignore: cast_nullable_to_non_nullable
as int,
successMessage: freezed == successMessage
? _value.successMessage
: successMessage // ignore: cast_nullable_to_non_nullable
as String?,
errorMessage: freezed == errorMessage
? _value.errorMessage
: errorMessage // ignore: cast_nullable_to_non_nullable
as String?,
),
);
}
}
/// @nodoc
class _$KnowledgeBaseStateImpl implements _KnowledgeBaseState {
const _$KnowledgeBaseStateImpl({
this.isLoading = false,
this.chunkCount = 0,
this.successMessage,
this.errorMessage,
});
@override
@JsonKey()
final bool isLoading;
@override
@JsonKey()
final int chunkCount;
@override
final String? successMessage;
@override
final String? errorMessage;
@override
String toString() {
return 'KnowledgeBaseState(isLoading: $isLoading, chunkCount: $chunkCount, successMessage: $successMessage, errorMessage: $errorMessage)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$KnowledgeBaseStateImpl &&
(identical(other.isLoading, isLoading) ||
other.isLoading == isLoading) &&
(identical(other.chunkCount, chunkCount) ||
other.chunkCount == chunkCount) &&
(identical(other.successMessage, successMessage) ||
other.successMessage == successMessage) &&
(identical(other.errorMessage, errorMessage) ||
other.errorMessage == errorMessage));
}
@override
int get hashCode => Object.hash(
runtimeType,
isLoading,
chunkCount,
successMessage,
errorMessage,
);
/// Create a copy of KnowledgeBaseState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$KnowledgeBaseStateImplCopyWith<_$KnowledgeBaseStateImpl> get copyWith =>
__$$KnowledgeBaseStateImplCopyWithImpl<_$KnowledgeBaseStateImpl>(
this,
_$identity,
);
}
abstract class _KnowledgeBaseState implements KnowledgeBaseState {
const factory _KnowledgeBaseState({
final bool isLoading,
final int chunkCount,
final String? successMessage,
final String? errorMessage,
}) = _$KnowledgeBaseStateImpl;
@override
bool get isLoading;
@override
int get chunkCount;
@override
String? get successMessage;
@override
String? get errorMessage;
/// Create a copy of KnowledgeBaseState
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$KnowledgeBaseStateImplCopyWith<_$KnowledgeBaseStateImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@@ -0,0 +1,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<Color>(AppColors.accent),
),
),
if (modelState.errorMessage != null) ...[
const SizedBox(height: UIConstants.spacing12),
_ErrorRow(message: modelState.errorMessage!),
],
],
);
}
}
class _ErrorRow extends StatelessWidget {
const _ErrorRow({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.error_outline_rounded,
color: AppColors.destructive,
size: 14,
),
const SizedBox(width: UIConstants.spacing8),
Expanded(
child: Text(
message,
style: GoogleFonts.inter(
fontSize: 12,
color: AppColors.destructive,
height: 1.4,
),
),
),
],
);
}
}
class _ActionButton extends StatefulWidget {
const _ActionButton({
required this.label,
required this.icon,
required this.color,
required this.textColor,
required this.onPressed,
this.borderColor,
});
final String label;
final IconData icon;
final Color color;
final Color textColor;
final Color? borderColor;
final VoidCallback onPressed;
@override
State<_ActionButton> createState() => _ActionButtonState();
}
class _ActionButtonState extends State<_ActionButton> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final hasBorder = widget.borderColor != null;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: AnimatedContainer(
duration: UIConstants.animationDuration,
height: 40,
decoration: BoxDecoration(
color: hasBorder
? (_hovered ? AppColors.zinc800 : Colors.transparent)
: (_hovered
? widget.color.withValues(alpha: 0.85)
: widget.color),
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
border: hasBorder
? Border.all(color: widget.borderColor!)
: null,
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius:
BorderRadius.circular(UIConstants.smallBorderRadius),
onTap: widget.onPressed,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing16,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(widget.icon, size: 16, color: widget.textColor),
const SizedBox(width: UIConstants.spacing8),
Text(
widget.label,
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w600,
color: widget.textColor,
),
),
],
),
),
),
),
),
);
}
}
// =============================================================================
// Generic icon button
// =============================================================================
class _IconBtn extends StatefulWidget {
const _IconBtn({
required this.icon,
required this.onTap,
this.tooltip = '',
});
final IconData icon;
final VoidCallback onTap;
final String tooltip;
@override
State<_IconBtn> createState() => _IconBtnState();
}
class _IconBtnState extends State<_IconBtn> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
return Tooltip(
message: widget.tooltip,
child: MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: UIConstants.animationDuration,
width: 32,
height: 32,
decoration: BoxDecoration(
color: _hovered ? AppColors.zinc800 : Colors.transparent,
borderRadius: BorderRadius.circular(6),
),
child: Icon(
widget.icon,
size: 18,
color:
_hovered ? AppColors.textPrimary : AppColors.textSecondary,
),
),
),
),
);
}
}
// =============================================================================
// Knowledge Base navigation section
// =============================================================================
class _KnowledgeBaseSection extends StatelessWidget {
const _KnowledgeBaseSection({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Knowledge Base',
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textMuted,
letterSpacing: 0.8,
),
),
const SizedBox(height: UIConstants.spacing12),
_KnowledgeBaseCard(onTap: onTap),
],
);
}
}
class _KnowledgeBaseCard extends StatefulWidget {
const _KnowledgeBaseCard({required this.onTap});
final VoidCallback onTap;
@override
State<_KnowledgeBaseCard> createState() => _KnowledgeBaseCardState();
}
class _KnowledgeBaseCardState extends State<_KnowledgeBaseCard> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: UIConstants.animationDuration,
decoration: BoxDecoration(
color: _hovered
? AppColors.surfaceContainerHigh
: AppColors.surfaceContainer,
borderRadius: BorderRadius.circular(UIConstants.borderRadius),
border: Border.all(
color: _hovered
? AppColors.accent.withValues(alpha: 0.3)
: AppColors.border,
),
),
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing16,
vertical: UIConstants.spacing16,
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColors.accentMuted,
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.hub_outlined,
color: AppColors.accent,
size: 20,
),
),
const SizedBox(width: UIConstants.spacing16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Manage Knowledge Base',
style: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 3),
Text(
'Add trainer notes to give the AI context-aware answers.',
style: GoogleFonts.inter(
fontSize: 12,
color: AppColors.textMuted,
),
),
],
),
),
Icon(
Icons.chevron_right_rounded,
color:
_hovered ? AppColors.accent : AppColors.textMuted,
size: 20,
),
],
),
),
),
);
}
}

View File

@@ -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<int> onDestinationSelected;
const _Sidebar({
required this.activeIndex,
required this.onDestinationSelected,
});
static const _items = [
_NavItemData(
icon: Icons.dashboard_outlined,
activeIcon: Icons.dashboard_rounded,
label: 'Home',
),
_NavItemData(
icon: Icons.fitness_center_outlined,
activeIcon: Icons.fitness_center,
label: 'Trainings',
),
_NavItemData(
icon: Icons.video_library_outlined,
activeIcon: Icons.video_library,
label: 'Analysis',
),
_NavItemData(
icon: Icons.calendar_today_outlined,
activeIcon: Icons.calendar_today,
label: 'Calendar',
),
_NavItemData(
icon: Icons.chat_bubble_outline,
activeIcon: Icons.chat_bubble,
label: 'AI Chat',
),
];
@override
Widget build(BuildContext context) {
return Container(
width: 200,
decoration: const BoxDecoration(
color: AppColors.surfaceContainer,
border: Border(right: BorderSide(color: AppColors.border)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// --- Logo ---
Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 20),
child: Row(
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: AppColors.accentMuted,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.fitness_center,
color: AppColors.accent,
size: 18,
),
),
const SizedBox(width: 10),
Text(
'TrainHub',
style: GoogleFonts.inter(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
letterSpacing: -0.3,
),
),
],
),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: Divider(height: 1),
),
const SizedBox(height: UIConstants.spacing8),
// --- Nav items ---
for (int i = 0; i < _items.length; i++)
_NavItem(
data: _items[i],
isActive: activeIndex == i,
onTap: () => onDestinationSelected(i),
),
const Spacer(),
// --- Footer ---
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
child: Row(
children: [
Text(
'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,
),
),
),
),
);
}
}

View File

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

View File

@@ -1,11 +1,15 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'dart:async';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/router/app_router.dart';
import 'package:trainhub_flutter/domain/entities/training_plan.dart';
import 'package:trainhub_flutter/domain/entities/exercise.dart';
import 'package:trainhub_flutter/domain/entities/training_plan.dart';
import 'package:trainhub_flutter/presentation/trainings/trainings_controller.dart';
import 'package:trainhub_flutter/presentation/common/widgets/app_empty_state.dart';
import 'package:trainhub_flutter/presentation/common/dialogs/text_input_dialog.dart';
@@ -260,24 +264,22 @@ class _ExercisesTab extends StatelessWidget {
title: 'No exercises yet',
subtitle: 'Add exercises to use in your training plans',
)
: GridView.builder(
padding: const EdgeInsets.all(UIConstants.spacing16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 3.0,
crossAxisSpacing: UIConstants.spacing12,
mainAxisSpacing: UIConstants.spacing12,
: ListView.builder(
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing16,
vertical: UIConstants.spacing8,
),
itemCount: exercises.length,
itemBuilder: (context, index) {
final exercise = exercises[index];
return _ExerciseCard(
return _ExerciseListItem(
exercise: exercise,
onEdit: () =>
_showExerciseDialog(context, exercise: exercise),
onDelete: () => ref
.read(trainingsControllerProvider.notifier)
.deleteExercise(exercise.id),
onPreview: () => _showExercisePreview(context, exercise),
);
},
),
@@ -311,7 +313,7 @@ class _ExercisesTab extends StatelessWidget {
TextField(
controller: instructionsCtrl,
decoration: const InputDecoration(labelText: 'Instructions'),
maxLines: 2,
maxLines: 3,
),
const SizedBox(height: UIConstants.spacing12),
TextField(
@@ -323,7 +325,9 @@ class _ExercisesTab extends StatelessWidget {
const SizedBox(height: UIConstants.spacing12),
TextField(
controller: videoUrlCtrl,
decoration: const InputDecoration(labelText: 'Video URL'),
decoration: const InputDecoration(
labelText: 'Video path or URL',
),
),
],
),
@@ -335,10 +339,10 @@ class _ExercisesTab extends StatelessWidget {
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
onPressed: () async {
if (nameCtrl.text.isEmpty) return;
if (exercise == null) {
ref
await ref
.read(trainingsControllerProvider.notifier)
.addExercise(
name: nameCtrl.text,
@@ -347,7 +351,7 @@ class _ExercisesTab extends StatelessWidget {
videoUrl: videoUrlCtrl.text,
);
} else {
ref
await ref
.read(trainingsControllerProvider.notifier)
.updateExercise(
exercise.copyWith(
@@ -358,7 +362,7 @@ class _ExercisesTab extends StatelessWidget {
),
);
}
Navigator.pop(context);
if (context.mounted) Navigator.pop(context);
},
child: const Text('Save'),
),
@@ -366,43 +370,74 @@ class _ExercisesTab extends StatelessWidget {
),
);
}
void _showExercisePreview(BuildContext context, ExerciseEntity exercise) {
showDialog(
context: context,
builder: (context) => _ExercisePreviewDialog(exercise: exercise),
);
}
}
class _ExerciseCard extends StatefulWidget {
class _ExerciseListItem extends StatefulWidget {
final ExerciseEntity exercise;
final VoidCallback onEdit;
final VoidCallback onDelete;
final VoidCallback onPreview;
const _ExerciseCard({
const _ExerciseListItem({
required this.exercise,
required this.onEdit,
required this.onDelete,
required this.onPreview,
});
@override
State<_ExerciseCard> createState() => _ExerciseCardState();
State<_ExerciseListItem> createState() => _ExerciseListItemState();
}
class _ExerciseCardState extends State<_ExerciseCard> {
class _ExerciseListItemState extends State<_ExerciseListItem> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
final hasVideo = widget.exercise.videoUrl != null &&
widget.exercise.videoUrl!.isNotEmpty;
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: Card(
margin: const EdgeInsets.only(bottom: UIConstants.spacing8),
child: InkWell(
onTap: widget.onEdit,
onTap: widget.onPreview,
borderRadius: UIConstants.cardBorderRadius,
child: Padding(
padding: const EdgeInsets.all(UIConstants.spacing12),
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing16,
vertical: UIConstants.spacing12,
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: hasVideo
? AppColors.info.withValues(alpha: 0.15)
: AppColors.zinc800,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
hasVideo ? Icons.videocam : Icons.fitness_center,
color: hasVideo ? AppColors.info : AppColors.textMuted,
size: 20,
),
),
const SizedBox(width: UIConstants.spacing12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
widget.exercise.name,
@@ -410,12 +445,10 @@ class _ExerciseCardState extends State<_ExerciseCard> {
fontWeight: FontWeight.w600,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (widget.exercise.instructions != null &&
widget.exercise.instructions!.isNotEmpty) ...[
const SizedBox(height: 4),
const SizedBox(height: 2),
Text(
widget.exercise.instructions!,
style: const TextStyle(
@@ -433,7 +466,7 @@ class _ExerciseCardState extends State<_ExerciseCard> {
spacing: 4,
children: widget.exercise.tags!
.split(',')
.take(3)
.take(4)
.map(
(tag) => Container(
padding: const EdgeInsets.symmetric(
@@ -459,20 +492,20 @@ class _ExerciseCardState extends State<_ExerciseCard> {
],
),
),
if (widget.exercise.videoUrl != null &&
widget.exercise.videoUrl!.isNotEmpty)
const Padding(
padding: EdgeInsets.only(right: 8),
child: Icon(
Icons.videocam,
size: 16,
color: AppColors.info,
),
),
if (_isHovered) ...[
IconButton(
icon: const Icon(
Icons.open_in_new,
size: 16,
color: AppColors.textMuted,
),
onPressed: widget.onPreview,
tooltip: 'Preview',
),
IconButton(
icon: const Icon(Icons.edit, size: 16),
onPressed: widget.onEdit,
tooltip: 'Edit',
),
IconButton(
icon: const Icon(
@@ -481,6 +514,7 @@ class _ExerciseCardState extends State<_ExerciseCard> {
color: AppColors.destructive,
),
onPressed: widget.onDelete,
tooltip: 'Delete',
),
],
],
@@ -491,3 +525,384 @@ class _ExerciseCardState extends State<_ExerciseCard> {
);
}
}
class _ExercisePreviewDialog extends StatelessWidget {
final ExerciseEntity exercise;
const _ExercisePreviewDialog({required this.exercise});
@override
Widget build(BuildContext context) {
final hasVideo = exercise.videoUrl != null && exercise.videoUrl!.isNotEmpty;
return Dialog(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600, maxHeight: 600),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (hasVideo)
_ExerciseVideoPreview(videoPath: exercise.videoUrl!),
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.all(UIConstants.spacing24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
exercise.name,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
),
),
if (exercise.muscleGroup != null &&
exercise.muscleGroup!.isNotEmpty) ...[
const SizedBox(height: UIConstants.spacing8),
Row(
children: [
const Icon(
Icons.accessibility_new,
size: 14,
color: AppColors.textMuted,
),
const SizedBox(width: 4),
Text(
exercise.muscleGroup!,
style: const TextStyle(
color: AppColors.textMuted,
fontSize: 13,
),
),
],
),
],
if (exercise.tags != null &&
exercise.tags!.isNotEmpty) ...[
const SizedBox(height: UIConstants.spacing12),
Wrap(
spacing: 6,
runSpacing: 6,
children: exercise.tags!
.split(',')
.map(
(tag) => Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: AppColors.accentMuted,
borderRadius: BorderRadius.circular(12),
),
child: Text(
tag.trim(),
style: const TextStyle(
fontSize: 11,
color: AppColors.accent,
),
),
),
)
.toList(),
),
],
if (exercise.instructions != null &&
exercise.instructions!.isNotEmpty) ...[
const SizedBox(height: UIConstants.spacing16),
const Text(
'Instructions',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
const SizedBox(height: UIConstants.spacing8),
Text(
exercise.instructions!,
style: const TextStyle(
fontSize: 14,
color: AppColors.textPrimary,
height: 1.5,
),
),
],
if (exercise.enrichment != null &&
exercise.enrichment!.isNotEmpty) ...[
const SizedBox(height: UIConstants.spacing16),
const Text(
'Notes',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
const SizedBox(height: UIConstants.spacing8),
Text(
exercise.enrichment!,
style: const TextStyle(
fontSize: 14,
color: AppColors.textPrimary,
height: 1.5,
),
),
],
],
),
),
),
Padding(
padding: const EdgeInsets.all(UIConstants.spacing16),
child: Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
),
),
],
),
),
);
}
}
class _ExerciseVideoPreview extends StatefulWidget {
final String videoPath;
const _ExerciseVideoPreview({required this.videoPath});
@override
State<_ExerciseVideoPreview> createState() => _ExerciseVideoPreviewState();
}
class _ExerciseVideoPreviewState extends State<_ExerciseVideoPreview> {
late final Player _player;
late final VideoController _videoController;
bool _isInitialized = false;
String? _error;
bool _isPlaying = false;
// Clip boundaries parsed from the '#t=start,end' fragment.
double _clipStart = 0.0;
double _clipEnd = double.infinity; // infinity means play to end of file
double _position = 0.0;
StreamSubscription<Duration>? _positionSub;
StreamSubscription<Duration>? _durationSub;
StreamSubscription<bool>? _playingSub;
StreamSubscription<String>? _errorSub;
bool _initialSeekDone = false;
@override
void initState() {
super.initState();
_player = Player();
_videoController = VideoController(
_player,
configuration: const VideoControllerConfiguration(
enableHardwareAcceleration: false,
),
);
_parseClipTimes();
_setupListeners();
_initialize();
}
void _parseClipTimes() {
final parts = widget.videoPath.split('#');
if (parts.length > 1 && parts[1].startsWith('t=')) {
final times = parts[1].substring(2).split(',');
_clipStart = double.tryParse(times[0]) ?? 0.0;
if (times.length > 1) {
_clipEnd = double.tryParse(times[1]) ?? double.infinity;
}
}
}
void _setupListeners() {
_errorSub = _player.stream.error.listen((error) {
if (mounted) setState(() => _error = error);
});
// Wait for the file to load (duration > 0), seek to clip start, then
// mark as initialized. Doing it in one chain prevents the Video widget
// from rendering frame 0 before the seek completes.
_durationSub = _player.stream.duration.listen((duration) {
if (!_initialSeekDone && duration > Duration.zero) {
_initialSeekDone = true;
if (_clipStart > 0) {
_player
.seek(Duration(milliseconds: (_clipStart * 1000).round()))
.then((_) {
if (mounted) setState(() => _isInitialized = true);
});
} else {
if (mounted) setState(() => _isInitialized = true);
}
}
});
_positionSub = _player.stream.position.listen((pos) {
final secs = pos.inMilliseconds / 1000.0;
if (_clipEnd != double.infinity && secs >= _clipEnd) {
// Loop: seek back to clip start without pausing.
_player.seek(Duration(milliseconds: (_clipStart * 1000).round()));
} else if (mounted) {
setState(() => _position = secs);
}
});
_playingSub = _player.stream.playing.listen((playing) {
if (mounted) setState(() => _isPlaying = playing);
});
}
Future<void> _initialize() async {
try {
final rawPath = widget.videoPath.split('#').first;
await _player.open(Media(rawPath), play: false);
// _isInitialized is set in _durationSub after the seek to _clipStart
// completes, so the Video widget never renders frame 0.
} catch (e) {
if (mounted) setState(() => _error = e.toString());
}
}
@override
void dispose() {
_positionSub?.cancel();
_durationSub?.cancel();
_playingSub?.cancel();
_errorSub?.cancel();
_player.dispose();
super.dispose();
}
void _togglePlay() {
if (_isPlaying) {
_player.pause();
} else {
if (_clipEnd != double.infinity && _position >= _clipEnd - 0.1) {
_player.seek(Duration(milliseconds: (_clipStart * 1000).round()));
}
_player.play();
}
}
@override
Widget build(BuildContext context) {
if (_error != null) {
return Container(
height: 180,
color: Colors.black,
child: const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.videocam_off, color: Colors.grey, size: 32),
SizedBox(height: 8),
Text(
'Unable to load video',
style: TextStyle(color: Colors.grey, fontSize: 12),
),
],
),
),
);
}
if (!_isInitialized) {
return const SizedBox(
height: 180,
child: Center(child: CircularProgressIndicator()),
);
}
final hasClip = _clipEnd != double.infinity;
final clipDuration = hasClip ? (_clipEnd - _clipStart) : 0.0;
final clipPosition = (_position - _clipStart).clamp(0.0, clipDuration);
final progress = (hasClip && clipDuration > 0) ? clipPosition / clipDuration : 0.0;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 220,
child: Stack(
children: [
Video(
controller: _videoController,
controls: NoVideoControls,
fit: BoxFit.contain,
),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
color: Colors.black54,
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child: Row(
children: [
GestureDetector(
onTap: _togglePlay,
child: Icon(
_isPlaying ? Icons.pause : Icons.play_arrow,
color: Colors.white,
size: 20,
),
),
const SizedBox(width: 8),
Expanded(
child: LinearProgressIndicator(
value: progress.clamp(0.0, 1.0),
backgroundColor: Colors.white24,
valueColor: const AlwaysStoppedAnimation(
Colors.white,
),
),
),
if (hasClip) ...[
const SizedBox(width: 8),
Text(
'${_fmt(clipPosition)} / ${_fmt(clipDuration)}',
style: const TextStyle(
color: Colors.white,
fontSize: 11,
),
),
],
],
),
),
),
],
),
),
if (hasClip)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'Clip ${_fmt(_clipStart)}${_fmt(_clipEnd)}',
style: const TextStyle(color: Colors.grey, fontSize: 11),
),
),
],
);
}
String _fmt(double seconds) {
final m = seconds ~/ 60;
final s = (seconds % 60).toInt();
return '$m:${s.toString().padLeft(2, '0')}';
}
}

View File

@@ -0,0 +1,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<WelcomeScreen> createState() => _WelcomeScreenState();
}
class _WelcomeScreenState extends ConsumerState<WelcomeScreen> {
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<AiModelSettingsState>(aiModelSettingsControllerProvider,
(prev, next) {
if (!_hasNavigated &&
next.areModelsValidated &&
!next.isDownloading) {
_navigateToApp();
}
});
return Scaffold(
backgroundColor: AppColors.surface,
body: Center(
child: SizedBox(
width: 560,
child: AnimatedSwitcher(
duration: UIConstants.animationDuration,
child: modelState.isDownloading
? _DownloadProgress(modelState: modelState)
: _InitialPrompt(
onDownload: _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<Color>(AppColors.accent),
),
),
const SizedBox(height: UIConstants.spacing12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
modelState.currentTask,
style: GoogleFonts.inter(
fontSize: 13,
color: AppColors.textSecondary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Text(
'$pct %',
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.accent,
),
),
],
),
if (modelState.errorMessage != null) ...[
const SizedBox(height: UIConstants.spacing16),
_ErrorBanner(message: modelState.errorMessage!),
],
],
);
}
}
// =============================================================================
// Small reusable widgets
// =============================================================================
class _FeatureRow extends StatelessWidget {
const _FeatureRow({required this.icon, required this.label});
final IconData icon;
final String label;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: AppColors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(6),
),
child: Icon(icon, size: 14, color: AppColors.accent),
),
const SizedBox(width: UIConstants.spacing12),
Expanded(
child: Text(
label,
style: GoogleFonts.inter(
fontSize: 13,
color: AppColors.textSecondary,
height: 1.5,
),
),
),
],
);
}
}
class _PrimaryButton extends StatefulWidget {
const _PrimaryButton({
required this.label,
required this.icon,
required this.onPressed,
});
final String label;
final IconData icon;
final VoidCallback onPressed;
@override
State<_PrimaryButton> createState() => _PrimaryButtonState();
}
class _PrimaryButtonState extends State<_PrimaryButton> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: AnimatedContainer(
duration: UIConstants.animationDuration,
height: 44,
decoration: BoxDecoration(
color: _hovered
? AppColors.accent.withValues(alpha: 0.85)
: AppColors.accent,
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius:
BorderRadius.circular(UIConstants.smallBorderRadius),
onTap: widget.onPressed,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(widget.icon, color: AppColors.zinc950, size: 16),
const SizedBox(width: UIConstants.spacing8),
Text(
widget.label,
style: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.zinc950,
),
),
],
),
),
),
),
);
}
}
class _SecondaryButton extends StatefulWidget {
const _SecondaryButton({required this.label, required this.onPressed});
final String label;
final VoidCallback onPressed;
@override
State<_SecondaryButton> createState() => _SecondaryButtonState();
}
class _SecondaryButtonState extends State<_SecondaryButton> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: AnimatedContainer(
duration: UIConstants.animationDuration,
height: 44,
decoration: BoxDecoration(
color: _hovered ? AppColors.zinc800 : Colors.transparent,
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
border: Border.all(color: AppColors.border),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius:
BorderRadius.circular(UIConstants.smallBorderRadius),
onTap: widget.onPressed,
child: Center(
child: Text(
widget.label,
style: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
),
),
),
),
),
),
);
}
}
class _ErrorBanner extends StatelessWidget {
const _ErrorBanner({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(UIConstants.spacing12),
decoration: BoxDecoration(
color: AppColors.destructiveMuted,
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
border: Border.all(
color: AppColors.destructive.withValues(alpha: 0.4),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.error_outline_rounded,
color: AppColors.destructive,
size: 16,
),
const SizedBox(width: UIConstants.spacing8),
Expanded(
child: Text(
message,
style: GoogleFonts.inter(
fontSize: 12,
color: AppColors.destructive,
height: 1.5,
),
),
),
],
),
);
}
}

View File

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

View File

@@ -20,12 +20,20 @@ class WorkoutSessionController extends _$WorkoutSessionController {
final activities = _buildSequence(plan);
ref.onDispose(() => _timer?.cancel());
final initialState = WorkoutSessionState(activities: activities);
if (activities.isNotEmpty) {
final first = activities.first;
return initialState.copyWith(timeRemaining: first.duration);
if (activities.isEmpty) {
return WorkoutSessionState(activities: activities);
}
final first = activities.first;
final initialState = WorkoutSessionState(
activities: activities,
timeRemaining: first.duration,
);
if (first.isTimeBased) {
Future.microtask(startTimer);
}
return initialState;
}
@@ -85,6 +93,19 @@ class WorkoutSessionController extends _$WorkoutSessionController {
}
}
void rewindSeconds(int amount) {
final currentState = state.value;
if (currentState == null) return;
final maxDuration = currentState.currentActivity?.duration ?? 0;
final newRemaining = (currentState.timeRemaining + amount).clamp(
0,
maxDuration,
);
state = AsyncValue.data(
currentState.copyWith(timeRemaining: newRemaining),
);
}
void _tick(Timer timer) {
if (state.value?.isFinished ?? true) return;
var currentState = state.value!;
@@ -98,7 +119,7 @@ class WorkoutSessionController extends _$WorkoutSessionController {
if (newState.timeRemaining > 0) {
newState = newState.copyWith(timeRemaining: newState.timeRemaining - 1);
} else {
state = AsyncValue.data(newState); // update interim state before next
state = AsyncValue.data(newState);
_goNext(newState);
return;
}
@@ -123,7 +144,7 @@ class WorkoutSessionController extends _$WorkoutSessionController {
state = AsyncValue.data(newState);
if (nextActivity.isRest) {
if (nextActivity.isTimeBased) {
startTimer();
} else {
pauseTimer();
@@ -152,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();
}
}

View File

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

View File

@@ -1,11 +1,10 @@
import 'dart:math';
import 'dart:ui';
import 'dart:math' as math;
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
import 'package:trainhub_flutter/domain/entities/workout_activity.dart';
import 'package:trainhub_flutter/presentation/workout_session/workout_session_controller.dart';
import 'package:trainhub_flutter/presentation/workout_session/workout_session_state.dart';
import 'package:trainhub_flutter/presentation/workout_session/widgets/activity_card.dart';
@@ -55,12 +54,14 @@ class WorkoutSessionPage extends ConsumerWidget {
const SizedBox(height: UIConstants.spacing8),
Text(
'$err',
style: TextStyle(
color: AppColors.textMuted,
fontSize: 14,
),
style: TextStyle(color: AppColors.textMuted, fontSize: 14),
textAlign: TextAlign.center,
),
const SizedBox(height: UIConstants.spacing16),
OutlinedButton(
onPressed: () => context.router.maybePop(),
child: const Text('Go Back'),
),
],
),
),
@@ -75,11 +76,8 @@ class WorkoutSessionPage extends ConsumerWidget {
);
}
final isRest = state.currentActivity?.isRest ?? false;
return _ActiveSessionView(
state: state,
isRest: isRest,
controller: controller,
);
},
@@ -88,27 +86,32 @@ class WorkoutSessionPage extends ConsumerWidget {
}
}
// ---------------------------------------------------------------------------
// Active session view (gradient background + timer + controls)
// ---------------------------------------------------------------------------
class _ActiveSessionView extends StatelessWidget {
class _ActiveSessionView extends StatefulWidget {
final WorkoutSessionState state;
final bool isRest;
final WorkoutSessionController controller;
const _ActiveSessionView({
required this.state,
required this.isRest,
required this.controller,
});
@override
State<_ActiveSessionView> createState() => _ActiveSessionViewState();
}
class _ActiveSessionViewState extends State<_ActiveSessionView> {
bool _showActivitiesList = false;
@override
Widget build(BuildContext context) {
// Compute the time progress for the circular ring.
final activity = state.currentActivity;
final activity = widget.state.currentActivity;
final isRest = activity?.isRest ?? false;
final isTimeBased = activity?.isTimeBased ?? false;
final double timeProgress;
if (activity != null && activity.duration > 0) {
timeProgress = 1.0 - (state.timeRemaining / activity.duration);
timeProgress =
1.0 - (widget.state.timeRemaining / activity.duration);
} else {
timeProgress = 0.0;
}
@@ -118,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<void> _confirmExit(BuildContext context) async {
final shouldExit = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Exit Workout?'),
content: const Text(
'Your progress will not be saved. Are you sure you want to exit?',
),
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<double>(
tween: Tween<double>(begin: progress, end: progress),
duration: const Duration(milliseconds: 400),
curve: Curves.easeOutQuart,
builder: (context, value, child) {
return CustomPaint(
size: const Size(size, size),
painter: TimerRingPainter(
progress: value,
ringColor: ringColor,
),
);
},
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -321,39 +464,49 @@ class _CircularTimerDisplay extends StatelessWidget {
_formatTime(timeRemaining),
style: TextStyle(
color: AppColors.textPrimary,
fontSize: 52,
fontWeight: FontWeight.w300,
letterSpacing: 2,
fontSize: 64,
fontWeight: FontWeight.w200,
letterSpacing: -1,
fontFeatures: const [FontFeature.tabularFigures()],
fontFamily: 'monospace',
shadows: [
Shadow(
color: ringColor.withValues(alpha: 0.3),
blurRadius: 20,
offset: const Offset(0, 4),
),
],
),
),
if (!isTimeBased)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'UNTIMED',
style: TextStyle(
color: AppColors.textMuted,
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 2,
),
),
),
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<WorkoutActivityEntity> activities;
final int currentIndex;
final Function(int) onJumpTo;
final VoidCallback onClose;
const _ActivitiesListPanel({
required this.activities,
required this.currentIndex,
required this.onJumpTo,
required this.onClose,
});
@override
Widget build(BuildContext context) {
return Positioned.fill(
child: GestureDetector(
onTap: onClose,
child: ColoredBox(
color: Colors.black.withValues(alpha: 0.5),
child: Align(
alignment: Alignment.centerRight,
child: GestureDetector(
onTap: () {},
child: Container(
width: 320,
decoration: const BoxDecoration(
color: AppColors.surfaceContainer,
border: Border(
left: BorderSide(color: AppColors.border),
),
),
child: Column(
children: [
Container(
padding: const EdgeInsets.all(UIConstants.spacing16),
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: AppColors.border),
),
),
child: Row(
children: [
const Text(
'All Exercises',
style: TextStyle(
color: AppColors.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
IconButton(
onPressed: onClose,
icon: const Icon(
Icons.close,
color: AppColors.textSecondary,
),
),
],
),
),
Expanded(
child: ListView.builder(
itemCount: activities.length,
itemBuilder: (context, index) {
final activity = activities[index];
final isCurrent = index == currentIndex;
final isRest = activity.isRest;
return InkWell(
onTap: () => onJumpTo(index),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing16,
vertical: UIConstants.spacing12,
),
decoration: BoxDecoration(
color: isCurrent
? AppColors.accent.withValues(alpha: 0.12)
: null,
border: const Border(
bottom: BorderSide(
color: AppColors.border,
width: 0.5,
),
),
),
child: Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isCurrent
? AppColors.accent
: isRest
? AppColors.info
: AppColors.zinc600,
),
),
const SizedBox(width: UIConstants.spacing12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
activity.name,
style: TextStyle(
color: isCurrent
? AppColors.textPrimary
: AppColors.textSecondary,
fontSize: 13,
fontWeight: isCurrent
? FontWeight.w600
: FontWeight.normal,
),
),
if (!isRest && activity.setIndex != null)
Text(
'Set ${activity.setIndex}/${activity.totalSets} · ${activity.sectionName ?? ''}',
style: const TextStyle(
color: AppColors.textMuted,
fontSize: 11,
),
),
],
),
),
if (activity.isTimeBased)
Text(
_formatDuration(activity.duration),
style: const TextStyle(
color: AppColors.textMuted,
fontSize: 12,
fontFeatures: [
FontFeature.tabularFigures(),
],
),
)
else if (!isRest)
Text(
'${activity.originalExercise?.value ?? 0} reps',
style: const TextStyle(
color: AppColors.textMuted,
fontSize: 12,
),
),
],
),
),
);
},
),
),
],
),
),
),
),
),
),
);
}
String _formatDuration(int seconds) {
final m = seconds ~/ 60;
final s = seconds % 60;
return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
}
}
class _CompletionScreen extends StatelessWidget {
final int totalTimeElapsed;
@@ -466,7 +863,6 @@ class _CompletionScreen extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Checkmark circle
Container(
width: 96,
height: 96,
@@ -491,9 +887,7 @@ class _CompletionScreen extends StatelessWidget {
size: 48,
),
),
const SizedBox(height: UIConstants.spacing24),
const Text(
'Workout Complete',
style: TextStyle(
@@ -503,9 +897,7 @@ class _CompletionScreen extends StatelessWidget {
letterSpacing: -0.5,
),
),
const SizedBox(height: UIConstants.spacing8),
Text(
'Great job! You crushed it.',
style: TextStyle(
@@ -513,10 +905,7 @@ class _CompletionScreen extends StatelessWidget {
fontSize: 15,
),
),
const SizedBox(height: UIConstants.spacing32),
// Total time card
Container(
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing24,
@@ -554,10 +943,7 @@ class _CompletionScreen extends StatelessWidget {
],
),
),
const SizedBox(height: UIConstants.spacing32),
// Finish button
SizedBox(
width: 200,
height: 48,

View File

@@ -6,17 +6,29 @@
#include "generated_plugin_registrant.h"
#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
#include <media_kit_video/media_kit_video_plugin.h>
#include <screen_retriever/screen_retriever_plugin.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
#include <volume_controller/volume_controller_plugin.h>
#include <window_manager/window_manager_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin");
media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar);
g_autoptr(FlPluginRegistrar) media_kit_video_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitVideoPlugin");
media_kit_video_plugin_register_with_registrar(media_kit_video_registrar);
g_autoptr(FlPluginRegistrar) screen_retriever_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverPlugin");
screen_retriever_plugin_register_with_registrar(screen_retriever_registrar);
g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin");
sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar);
g_autoptr(FlPluginRegistrar) volume_controller_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "VolumeControllerPlugin");
volume_controller_plugin_register_with_registrar(volume_controller_registrar);
g_autoptr(FlPluginRegistrar) window_manager_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin");
window_manager_plugin_register_with_registrar(window_manager_registrar);

View File

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

View File

@@ -5,14 +5,22 @@
import FlutterMacOS
import Foundation
import media_kit_libs_macos_video
import media_kit_video
import package_info_plus
import screen_retriever
import sqlite3_flutter_libs
import video_player_avfoundation
import volume_controller
import wakelock_plus
import window_manager
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin"))
MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
ScreenRetrieverPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverPlugin"))
Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin"))
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
VolumeControllerPlugin.register(with: registry.registrar(forPlugin: "VolumeControllerPlugin"))
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
}

View File

@@ -33,6 +33,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.3.9"
archive:
dependency: "direct main"
description:
name: archive
sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d
url: "https://pub.dev"
source: hosted
version: "3.6.1"
args:
dependency: transitive
description:
@@ -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:

View File

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

View File

@@ -6,18 +6,24 @@
#include "generated_plugin_registrant.h"
#include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h>
#include <media_kit_video/media_kit_video_plugin_c_api.h>
#include <screen_retriever/screen_retriever_plugin.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
#include <video_player_win/video_player_win_plugin_c_api.h>
#include <volume_controller/volume_controller_plugin_c_api.h>
#include <window_manager/window_manager_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi"));
MediaKitVideoPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi"));
ScreenRetrieverPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ScreenRetrieverPlugin"));
Sqlite3FlutterLibsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin"));
VideoPlayerWinPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("VideoPlayerWinPluginCApi"));
VolumeControllerPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("VolumeControllerPluginCApi"));
WindowManagerPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("WindowManagerPlugin"));
}

View File

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