This commit is contained in:
@@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
35
lib/data/database/daos/knowledge_chunk_dao.dart
Normal file
35
lib/data/database/daos/knowledge_chunk_dao.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:trainhub_flutter/data/database/app_database.dart';
|
||||
|
||||
part 'knowledge_chunk_dao.g.dart';
|
||||
|
||||
@DriftAccessor(tables: [KnowledgeChunks])
|
||||
class KnowledgeChunkDao extends DatabaseAccessor<AppDatabase>
|
||||
with _$KnowledgeChunkDaoMixin {
|
||||
KnowledgeChunkDao(super.db);
|
||||
|
||||
Future<void> insertChunk(KnowledgeChunksCompanion entry) =>
|
||||
into(knowledgeChunks).insert(entry);
|
||||
|
||||
/// Returns every stored chunk, including its JSON-encoded embedding.
|
||||
/// Loaded into memory for in-process cosine similarity scoring.
|
||||
Future<List<KnowledgeChunk>> getAllChunks() =>
|
||||
(select(knowledgeChunks)
|
||||
..orderBy([
|
||||
(t) =>
|
||||
OrderingTerm(expression: t.createdAt, mode: OrderingMode.asc)
|
||||
]))
|
||||
.get();
|
||||
|
||||
Future<int> getCount() async {
|
||||
final rows = await select(knowledgeChunks).get();
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
Future<void> deleteAll() => delete(knowledgeChunks).go();
|
||||
|
||||
Future<void> deleteBySourceId(String sourceId) =>
|
||||
(delete(knowledgeChunks)
|
||||
..where((t) => t.sourceId.equals(sourceId)))
|
||||
.go();
|
||||
}
|
||||
8
lib/data/database/daos/knowledge_chunk_dao.g.dart
Normal file
8
lib/data/database/daos/knowledge_chunk_dao.g.dart
Normal file
@@ -0,0 +1,8 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'knowledge_chunk_dao.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
mixin _$KnowledgeChunkDaoMixin on DatabaseAccessor<AppDatabase> {
|
||||
$KnowledgeChunksTable get knowledgeChunks => attachedDatabase.knowledgeChunks;
|
||||
}
|
||||
@@ -83,6 +83,21 @@ class AnalysisRepositoryImpl implements AnalysisRepository {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateAnnotation({
|
||||
required String id,
|
||||
required String name,
|
||||
required String description,
|
||||
required String color,
|
||||
}) async {
|
||||
await _dao.updateAnnotation(
|
||||
id: id,
|
||||
name: name,
|
||||
description: description,
|
||||
color: color,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteAnnotation(String id) async {
|
||||
await _dao.deleteAnnotation(id);
|
||||
|
||||
142
lib/data/repositories/note_repository_impl.dart
Normal file
142
lib/data/repositories/note_repository_impl.dart
Normal file
@@ -0,0 +1,142 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math' show sqrt;
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:trainhub_flutter/data/database/app_database.dart';
|
||||
import 'package:trainhub_flutter/data/database/daos/knowledge_chunk_dao.dart';
|
||||
import 'package:trainhub_flutter/domain/repositories/note_repository.dart';
|
||||
import 'package:trainhub_flutter/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;
|
||||
}
|
||||
16
lib/domain/entities/note_chunk.dart
Normal file
16
lib/domain/entities/note_chunk.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'note_chunk.freezed.dart';
|
||||
|
||||
/// A single text chunk produced by splitting a trainer's note.
|
||||
/// [sourceId] groups all chunks that came from the same original note
|
||||
/// (useful for bulk deletion later).
|
||||
@freezed
|
||||
class NoteChunkEntity with _$NoteChunkEntity {
|
||||
const factory NoteChunkEntity({
|
||||
required String id,
|
||||
required String text,
|
||||
required String sourceId,
|
||||
required String createdAt,
|
||||
}) = _NoteChunkEntity;
|
||||
}
|
||||
215
lib/domain/entities/note_chunk.freezed.dart
Normal file
215
lib/domain/entities/note_chunk.freezed.dart
Normal file
@@ -0,0 +1,215 @@
|
||||
// coverage:ignore-file
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'note_chunk.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
final _privateConstructorUsedError = UnsupportedError(
|
||||
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
|
||||
);
|
||||
|
||||
/// @nodoc
|
||||
mixin _$NoteChunkEntity {
|
||||
String get id => throw _privateConstructorUsedError;
|
||||
String get text => throw _privateConstructorUsedError;
|
||||
String get sourceId => throw _privateConstructorUsedError;
|
||||
String get createdAt => throw _privateConstructorUsedError;
|
||||
|
||||
/// Create a copy of NoteChunkEntity
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
$NoteChunkEntityCopyWith<NoteChunkEntity> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $NoteChunkEntityCopyWith<$Res> {
|
||||
factory $NoteChunkEntityCopyWith(
|
||||
NoteChunkEntity value,
|
||||
$Res Function(NoteChunkEntity) then,
|
||||
) = _$NoteChunkEntityCopyWithImpl<$Res, NoteChunkEntity>;
|
||||
@useResult
|
||||
$Res call({String id, String text, String sourceId, String createdAt});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$NoteChunkEntityCopyWithImpl<$Res, $Val extends NoteChunkEntity>
|
||||
implements $NoteChunkEntityCopyWith<$Res> {
|
||||
_$NoteChunkEntityCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
/// Create a copy of NoteChunkEntity
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? text = null,
|
||||
Object? sourceId = null,
|
||||
Object? createdAt = null,
|
||||
}) {
|
||||
return _then(
|
||||
_value.copyWith(
|
||||
id: null == id
|
||||
? _value.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
text: null == text
|
||||
? _value.text
|
||||
: text // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
sourceId: null == sourceId
|
||||
? _value.sourceId
|
||||
: sourceId // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
createdAt: null == createdAt
|
||||
? _value.createdAt
|
||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
)
|
||||
as $Val,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$NoteChunkEntityImplCopyWith<$Res>
|
||||
implements $NoteChunkEntityCopyWith<$Res> {
|
||||
factory _$$NoteChunkEntityImplCopyWith(
|
||||
_$NoteChunkEntityImpl value,
|
||||
$Res Function(_$NoteChunkEntityImpl) then,
|
||||
) = __$$NoteChunkEntityImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call({String id, String text, String sourceId, String createdAt});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$NoteChunkEntityImplCopyWithImpl<$Res>
|
||||
extends _$NoteChunkEntityCopyWithImpl<$Res, _$NoteChunkEntityImpl>
|
||||
implements _$$NoteChunkEntityImplCopyWith<$Res> {
|
||||
__$$NoteChunkEntityImplCopyWithImpl(
|
||||
_$NoteChunkEntityImpl _value,
|
||||
$Res Function(_$NoteChunkEntityImpl) _then,
|
||||
) : super(_value, _then);
|
||||
|
||||
/// Create a copy of NoteChunkEntity
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? text = null,
|
||||
Object? sourceId = null,
|
||||
Object? createdAt = null,
|
||||
}) {
|
||||
return _then(
|
||||
_$NoteChunkEntityImpl(
|
||||
id: null == id
|
||||
? _value.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
text: null == text
|
||||
? _value.text
|
||||
: text // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
sourceId: null == sourceId
|
||||
? _value.sourceId
|
||||
: sourceId // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
createdAt: null == createdAt
|
||||
? _value.createdAt
|
||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
class _$NoteChunkEntityImpl implements _NoteChunkEntity {
|
||||
const _$NoteChunkEntityImpl({
|
||||
required this.id,
|
||||
required this.text,
|
||||
required this.sourceId,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
@override
|
||||
final String id;
|
||||
@override
|
||||
final String text;
|
||||
@override
|
||||
final String sourceId;
|
||||
@override
|
||||
final String createdAt;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'NoteChunkEntity(id: $id, text: $text, sourceId: $sourceId, createdAt: $createdAt)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$NoteChunkEntityImpl &&
|
||||
(identical(other.id, id) || other.id == id) &&
|
||||
(identical(other.text, text) || other.text == text) &&
|
||||
(identical(other.sourceId, sourceId) ||
|
||||
other.sourceId == sourceId) &&
|
||||
(identical(other.createdAt, createdAt) ||
|
||||
other.createdAt == createdAt));
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType, id, text, sourceId, createdAt);
|
||||
|
||||
/// Create a copy of NoteChunkEntity
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$NoteChunkEntityImplCopyWith<_$NoteChunkEntityImpl> get copyWith =>
|
||||
__$$NoteChunkEntityImplCopyWithImpl<_$NoteChunkEntityImpl>(
|
||||
this,
|
||||
_$identity,
|
||||
);
|
||||
}
|
||||
|
||||
abstract class _NoteChunkEntity implements NoteChunkEntity {
|
||||
const factory _NoteChunkEntity({
|
||||
required final String id,
|
||||
required final String text,
|
||||
required final String sourceId,
|
||||
required final String createdAt,
|
||||
}) = _$NoteChunkEntityImpl;
|
||||
|
||||
@override
|
||||
String get id;
|
||||
@override
|
||||
String get text;
|
||||
@override
|
||||
String get sourceId;
|
||||
@override
|
||||
String get createdAt;
|
||||
|
||||
/// Create a copy of NoteChunkEntity
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
_$$NoteChunkEntityImplCopyWith<_$NoteChunkEntityImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
@@ -15,5 +15,11 @@ abstract class AnalysisRepository {
|
||||
required double endTime,
|
||||
required String color,
|
||||
});
|
||||
Future<void> updateAnnotation({
|
||||
required String id,
|
||||
required String name,
|
||||
required String description,
|
||||
required String color,
|
||||
});
|
||||
Future<void> deleteAnnotation(String id);
|
||||
}
|
||||
|
||||
21
lib/domain/repositories/note_repository.dart
Normal file
21
lib/domain/repositories/note_repository.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
/// Persistence interface for the trainer's knowledge base.
|
||||
///
|
||||
/// The implementation splits raw text into chunks, generates embeddings
|
||||
/// via the Nomic server, stores them in the local database, and provides
|
||||
/// semantic search for RAG context injection.
|
||||
abstract class NoteRepository {
|
||||
/// Splits [text] into overlapping chunks, generates an embedding for each,
|
||||
/// and persists them under a shared [sourceId].
|
||||
Future<void> addNote(String text);
|
||||
|
||||
/// Returns the [topK] most semantically similar chunk texts for [query].
|
||||
/// Returns an empty list if no chunks are stored or the embedding server
|
||||
/// is unavailable.
|
||||
Future<List<String>> searchSimilar(String query, {int topK = 3});
|
||||
|
||||
/// Returns the total number of stored chunks.
|
||||
Future<int> getChunkCount();
|
||||
|
||||
/// Deletes every stored chunk (full knowledge-base reset).
|
||||
Future<void> clearAll();
|
||||
}
|
||||
88
lib/domain/services/ai_process_manager.dart
Normal file
88
lib/domain/services/ai_process_manager.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
31
lib/domain/services/embedding_service.dart
Normal file
31
lib/domain/services/embedding_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -7,7 +7,7 @@ part of 'calendar_controller.dart';
|
||||
// **************************************************************************
|
||||
|
||||
String _$calendarControllerHash() =>
|
||||
r'747a59ba47bf4d1b6a66e3bcc82276e4ad81eb1a';
|
||||
r'd26afbe4d0a107aa6d0067e9b6f44e5ba079d37c';
|
||||
|
||||
/// See also [CalendarController].
|
||||
@ProviderFor(CalendarController)
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ part of 'chat_controller.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$chatControllerHash() => r'44a3d0e906eaad16f7a9c292fe847b8bd144c835';
|
||||
String _$chatControllerHash() => r'06ffc6b53c1d878ffc0a758da4f7ee1261ae1340';
|
||||
|
||||
/// See also [ChatController].
|
||||
@ProviderFor(ChatController)
|
||||
|
||||
@@ -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(
|
||||
|
||||
13
lib/presentation/plan_editor/models/exercise_drag_data.dart
Normal file
13
lib/presentation/plan_editor/models/exercise_drag_data.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
import 'package:trainhub_flutter/domain/entities/training_exercise.dart';
|
||||
|
||||
class ExerciseDragData {
|
||||
const ExerciseDragData({
|
||||
required this.fromSectionIndex,
|
||||
required this.exerciseIndex,
|
||||
required this.exercise,
|
||||
});
|
||||
|
||||
final int fromSectionIndex;
|
||||
final int exerciseIndex;
|
||||
final TrainingExerciseEntity exercise;
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import 'package:trainhub_flutter/core/utils/id_generator.dart';
|
||||
import 'package:trainhub_flutter/injection.dart';
|
||||
import 'package:trainhub_flutter/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;
|
||||
|
||||
@@ -7,7 +7,7 @@ part of 'plan_editor_controller.dart';
|
||||
// **************************************************************************
|
||||
|
||||
String _$planEditorControllerHash() =>
|
||||
r'4045493829126f28b3a58695b68ade53519c1412';
|
||||
r'6c6c2f74725e250bd41401cab12c1a62306d10ea';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
|
||||
@@ -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()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
263
lib/presentation/settings/ai_model_settings_controller.dart
Normal file
263
lib/presentation/settings/ai_model_settings_controller.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
14
lib/presentation/settings/ai_model_settings_state.dart
Normal file
14
lib/presentation/settings/ai_model_settings_state.dart
Normal file
@@ -0,0 +1,14 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'ai_model_settings_state.freezed.dart';
|
||||
|
||||
@freezed
|
||||
class AiModelSettingsState with _$AiModelSettingsState {
|
||||
const factory AiModelSettingsState({
|
||||
@Default(false) bool isDownloading,
|
||||
@Default(0.0) double progress,
|
||||
@Default('') String currentTask,
|
||||
@Default(false) bool areModelsValidated,
|
||||
String? errorMessage,
|
||||
}) = _AiModelSettingsState;
|
||||
}
|
||||
263
lib/presentation/settings/ai_model_settings_state.freezed.dart
Normal file
263
lib/presentation/settings/ai_model_settings_state.freezed.dart
Normal file
@@ -0,0 +1,263 @@
|
||||
// coverage:ignore-file
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'ai_model_settings_state.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
final _privateConstructorUsedError = UnsupportedError(
|
||||
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
|
||||
);
|
||||
|
||||
/// @nodoc
|
||||
mixin _$AiModelSettingsState {
|
||||
bool get isDownloading => throw _privateConstructorUsedError;
|
||||
double get progress => throw _privateConstructorUsedError;
|
||||
String get currentTask => throw _privateConstructorUsedError;
|
||||
bool get areModelsValidated => throw _privateConstructorUsedError;
|
||||
String? get errorMessage => throw _privateConstructorUsedError;
|
||||
|
||||
/// Create a copy of AiModelSettingsState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
$AiModelSettingsStateCopyWith<AiModelSettingsState> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $AiModelSettingsStateCopyWith<$Res> {
|
||||
factory $AiModelSettingsStateCopyWith(
|
||||
AiModelSettingsState value,
|
||||
$Res Function(AiModelSettingsState) then,
|
||||
) = _$AiModelSettingsStateCopyWithImpl<$Res, AiModelSettingsState>;
|
||||
@useResult
|
||||
$Res call({
|
||||
bool isDownloading,
|
||||
double progress,
|
||||
String currentTask,
|
||||
bool areModelsValidated,
|
||||
String? errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$AiModelSettingsStateCopyWithImpl<
|
||||
$Res,
|
||||
$Val extends AiModelSettingsState
|
||||
>
|
||||
implements $AiModelSettingsStateCopyWith<$Res> {
|
||||
_$AiModelSettingsStateCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
/// Create a copy of AiModelSettingsState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? isDownloading = null,
|
||||
Object? progress = null,
|
||||
Object? currentTask = null,
|
||||
Object? areModelsValidated = null,
|
||||
Object? errorMessage = freezed,
|
||||
}) {
|
||||
return _then(
|
||||
_value.copyWith(
|
||||
isDownloading: null == isDownloading
|
||||
? _value.isDownloading
|
||||
: isDownloading // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
progress: null == progress
|
||||
? _value.progress
|
||||
: progress // ignore: cast_nullable_to_non_nullable
|
||||
as double,
|
||||
currentTask: null == currentTask
|
||||
? _value.currentTask
|
||||
: currentTask // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
areModelsValidated: null == areModelsValidated
|
||||
? _value.areModelsValidated
|
||||
: areModelsValidated // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
errorMessage: freezed == errorMessage
|
||||
? _value.errorMessage
|
||||
: errorMessage // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
)
|
||||
as $Val,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$AiModelSettingsStateImplCopyWith<$Res>
|
||||
implements $AiModelSettingsStateCopyWith<$Res> {
|
||||
factory _$$AiModelSettingsStateImplCopyWith(
|
||||
_$AiModelSettingsStateImpl value,
|
||||
$Res Function(_$AiModelSettingsStateImpl) then,
|
||||
) = __$$AiModelSettingsStateImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call({
|
||||
bool isDownloading,
|
||||
double progress,
|
||||
String currentTask,
|
||||
bool areModelsValidated,
|
||||
String? errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$AiModelSettingsStateImplCopyWithImpl<$Res>
|
||||
extends _$AiModelSettingsStateCopyWithImpl<$Res, _$AiModelSettingsStateImpl>
|
||||
implements _$$AiModelSettingsStateImplCopyWith<$Res> {
|
||||
__$$AiModelSettingsStateImplCopyWithImpl(
|
||||
_$AiModelSettingsStateImpl _value,
|
||||
$Res Function(_$AiModelSettingsStateImpl) _then,
|
||||
) : super(_value, _then);
|
||||
|
||||
/// Create a copy of AiModelSettingsState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? isDownloading = null,
|
||||
Object? progress = null,
|
||||
Object? currentTask = null,
|
||||
Object? areModelsValidated = null,
|
||||
Object? errorMessage = freezed,
|
||||
}) {
|
||||
return _then(
|
||||
_$AiModelSettingsStateImpl(
|
||||
isDownloading: null == isDownloading
|
||||
? _value.isDownloading
|
||||
: isDownloading // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
progress: null == progress
|
||||
? _value.progress
|
||||
: progress // ignore: cast_nullable_to_non_nullable
|
||||
as double,
|
||||
currentTask: null == currentTask
|
||||
? _value.currentTask
|
||||
: currentTask // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
areModelsValidated: null == areModelsValidated
|
||||
? _value.areModelsValidated
|
||||
: areModelsValidated // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
errorMessage: freezed == errorMessage
|
||||
? _value.errorMessage
|
||||
: errorMessage // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
class _$AiModelSettingsStateImpl implements _AiModelSettingsState {
|
||||
const _$AiModelSettingsStateImpl({
|
||||
this.isDownloading = false,
|
||||
this.progress = 0.0,
|
||||
this.currentTask = '',
|
||||
this.areModelsValidated = false,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
@override
|
||||
@JsonKey()
|
||||
final bool isDownloading;
|
||||
@override
|
||||
@JsonKey()
|
||||
final double progress;
|
||||
@override
|
||||
@JsonKey()
|
||||
final String currentTask;
|
||||
@override
|
||||
@JsonKey()
|
||||
final bool areModelsValidated;
|
||||
@override
|
||||
final String? errorMessage;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AiModelSettingsState(isDownloading: $isDownloading, progress: $progress, currentTask: $currentTask, areModelsValidated: $areModelsValidated, errorMessage: $errorMessage)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$AiModelSettingsStateImpl &&
|
||||
(identical(other.isDownloading, isDownloading) ||
|
||||
other.isDownloading == isDownloading) &&
|
||||
(identical(other.progress, progress) ||
|
||||
other.progress == progress) &&
|
||||
(identical(other.currentTask, currentTask) ||
|
||||
other.currentTask == currentTask) &&
|
||||
(identical(other.areModelsValidated, areModelsValidated) ||
|
||||
other.areModelsValidated == areModelsValidated) &&
|
||||
(identical(other.errorMessage, errorMessage) ||
|
||||
other.errorMessage == errorMessage));
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType,
|
||||
isDownloading,
|
||||
progress,
|
||||
currentTask,
|
||||
areModelsValidated,
|
||||
errorMessage,
|
||||
);
|
||||
|
||||
/// Create a copy of AiModelSettingsState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$AiModelSettingsStateImplCopyWith<_$AiModelSettingsStateImpl>
|
||||
get copyWith =>
|
||||
__$$AiModelSettingsStateImplCopyWithImpl<_$AiModelSettingsStateImpl>(
|
||||
this,
|
||||
_$identity,
|
||||
);
|
||||
}
|
||||
|
||||
abstract class _AiModelSettingsState implements AiModelSettingsState {
|
||||
const factory _AiModelSettingsState({
|
||||
final bool isDownloading,
|
||||
final double progress,
|
||||
final String currentTask,
|
||||
final bool areModelsValidated,
|
||||
final String? errorMessage,
|
||||
}) = _$AiModelSettingsStateImpl;
|
||||
|
||||
@override
|
||||
bool get isDownloading;
|
||||
@override
|
||||
double get progress;
|
||||
@override
|
||||
String get currentTask;
|
||||
@override
|
||||
bool get areModelsValidated;
|
||||
@override
|
||||
String? get errorMessage;
|
||||
|
||||
/// Create a copy of AiModelSettingsState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
_$$AiModelSettingsStateImplCopyWith<_$AiModelSettingsStateImpl>
|
||||
get copyWith => throw _privateConstructorUsedError;
|
||||
}
|
||||
102
lib/presentation/settings/knowledge_base_controller.dart
Normal file
102
lib/presentation/settings/knowledge_base_controller.dart
Normal file
@@ -0,0 +1,102 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:trainhub_flutter/domain/repositories/note_repository.dart';
|
||||
import 'package:trainhub_flutter/injection.dart';
|
||||
import 'package:trainhub_flutter/presentation/settings/knowledge_base_state.dart';
|
||||
|
||||
part 'knowledge_base_controller.g.dart';
|
||||
|
||||
@riverpod
|
||||
class KnowledgeBaseController extends _$KnowledgeBaseController {
|
||||
late NoteRepository _repo;
|
||||
|
||||
@override
|
||||
KnowledgeBaseState build() {
|
||||
_repo = getIt<NoteRepository>();
|
||||
// Load the current chunk count asynchronously after first build.
|
||||
_loadCount();
|
||||
return const KnowledgeBaseState();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Load chunk count
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
Future<void> _loadCount() async {
|
||||
try {
|
||||
final count = await _repo.getChunkCount();
|
||||
state = state.copyWith(chunkCount: count);
|
||||
} catch (_) {
|
||||
// Non-fatal — UI stays at 0.
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Save note
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Chunks [text], generates embeddings via Nomic, and stores the result.
|
||||
Future<void> saveNote(String text) async {
|
||||
if (text.trim().isEmpty) return;
|
||||
|
||||
state = state.copyWith(
|
||||
isLoading: true,
|
||||
successMessage: null,
|
||||
errorMessage: null,
|
||||
);
|
||||
|
||||
try {
|
||||
await _repo.addNote(text.trim());
|
||||
final count = await _repo.getChunkCount();
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
chunkCount: count,
|
||||
successMessage: 'Saved! Knowledge base now has $count chunks.',
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: _friendlyError(e),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Clear knowledge base
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
Future<void> clearKnowledgeBase() async {
|
||||
state = state.copyWith(
|
||||
isLoading: true,
|
||||
successMessage: null,
|
||||
errorMessage: null,
|
||||
);
|
||||
try {
|
||||
await _repo.clearAll();
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
chunkCount: 0,
|
||||
successMessage: 'Knowledge base cleared.',
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: _friendlyError(e),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helper
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
String _friendlyError(Object e) {
|
||||
final msg = e.toString();
|
||||
if (msg.contains('Connection refused') ||
|
||||
msg.contains('SocketException')) {
|
||||
return 'Cannot reach the embedding server. '
|
||||
'Make sure AI models are downloaded and the app has had time to '
|
||||
'start the inference servers.';
|
||||
}
|
||||
return 'Error: $msg';
|
||||
}
|
||||
}
|
||||
30
lib/presentation/settings/knowledge_base_controller.g.dart
Normal file
30
lib/presentation/settings/knowledge_base_controller.g.dart
Normal file
@@ -0,0 +1,30 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'knowledge_base_controller.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$knowledgeBaseControllerHash() =>
|
||||
r'1b18418c3e7a66c6517dbbd7167e7406e16c8748';
|
||||
|
||||
/// See also [KnowledgeBaseController].
|
||||
@ProviderFor(KnowledgeBaseController)
|
||||
final knowledgeBaseControllerProvider =
|
||||
AutoDisposeNotifierProvider<
|
||||
KnowledgeBaseController,
|
||||
KnowledgeBaseState
|
||||
>.internal(
|
||||
KnowledgeBaseController.new,
|
||||
name: r'knowledgeBaseControllerProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$knowledgeBaseControllerHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$KnowledgeBaseController = AutoDisposeNotifier<KnowledgeBaseState>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||
726
lib/presentation/settings/knowledge_base_page.dart
Normal file
726
lib/presentation/settings/knowledge_base_page.dart
Normal file
@@ -0,0 +1,726 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
import 'package:trainhub_flutter/presentation/settings/knowledge_base_controller.dart';
|
||||
import 'package:trainhub_flutter/presentation/settings/knowledge_base_state.dart';
|
||||
|
||||
@RoutePage()
|
||||
class KnowledgeBasePage extends ConsumerStatefulWidget {
|
||||
const KnowledgeBasePage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<KnowledgeBasePage> createState() => _KnowledgeBasePageState();
|
||||
}
|
||||
|
||||
class _KnowledgeBasePageState extends ConsumerState<KnowledgeBasePage> {
|
||||
final _textController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_textController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _save(KnowledgeBaseController controller) async {
|
||||
await controller.saveNote(_textController.text);
|
||||
// Only clear the field if save succeeded (no error in state).
|
||||
if (!mounted) return;
|
||||
final s = ref.read(knowledgeBaseControllerProvider);
|
||||
if (s.successMessage != null) _textController.clear();
|
||||
}
|
||||
|
||||
Future<void> _clear(KnowledgeBaseController controller) async {
|
||||
final confirmed = await _showConfirmDialog();
|
||||
if (!confirmed) return;
|
||||
await controller.clearKnowledgeBase();
|
||||
}
|
||||
|
||||
Future<bool> _showConfirmDialog() async {
|
||||
return await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: AppColors.surfaceContainer,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(UIConstants.borderRadius),
|
||||
side: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
title: Text(
|
||||
'Clear knowledge base?',
|
||||
style: GoogleFonts.inter(
|
||||
color: AppColors.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
content: Text(
|
||||
'This will permanently delete all stored chunks and embeddings. '
|
||||
'This action cannot be undone.',
|
||||
style: GoogleFonts.inter(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 13,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: Text(
|
||||
'Cancel',
|
||||
style: GoogleFonts.inter(color: AppColors.textMuted),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: Text(
|
||||
'Clear',
|
||||
style: GoogleFonts.inter(color: AppColors.destructive),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kbState = ref.watch(knowledgeBaseControllerProvider);
|
||||
final controller = ref.read(knowledgeBaseControllerProvider.notifier);
|
||||
|
||||
// Show success SnackBar when a note is saved successfully.
|
||||
ref.listen<KnowledgeBaseState>(knowledgeBaseControllerProvider,
|
||||
(prev, next) {
|
||||
if (next.successMessage != null &&
|
||||
next.successMessage != prev?.successMessage) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
next.successMessage!,
|
||||
style: GoogleFonts.inter(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
backgroundColor: AppColors.surfaceContainerHigh,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
side: const BorderSide(
|
||||
color: AppColors.success,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.surface,
|
||||
body: Column(
|
||||
children: [
|
||||
// ── Top bar ──────────────────────────────────────────────────────
|
||||
_TopBar(onBack: () => context.router.maybePop()),
|
||||
|
||||
// ── Content ──────────────────────────────────────────────────────
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(UIConstants.pagePadding),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 680),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Heading
|
||||
Text(
|
||||
'Knowledge Base',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
letterSpacing: -0.3,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing8),
|
||||
Text(
|
||||
'Paste your fitness or university notes below. '
|
||||
'They will be split into chunks, embedded with the '
|
||||
'Nomic model, and used as context when you chat with '
|
||||
'the AI — no internet required.',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: UIConstants.spacing24),
|
||||
|
||||
// ── Status card ──────────────────────────────────────
|
||||
_StatusCard(chunkCount: kbState.chunkCount),
|
||||
|
||||
const SizedBox(height: UIConstants.spacing24),
|
||||
|
||||
// ── Text input ───────────────────────────────────────
|
||||
_SectionLabel('Paste Notes'),
|
||||
const SizedBox(height: UIConstants.spacing8),
|
||||
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(
|
||||
UIConstants.borderRadius,
|
||||
),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _textController,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.6,
|
||||
),
|
||||
maxLines: 14,
|
||||
minLines: 8,
|
||||
decoration: InputDecoration(
|
||||
hintText:
|
||||
'Paste lecture notes, programming guides, '
|
||||
'exercise descriptions, research summaries…',
|
||||
hintStyle: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
color: AppColors.textMuted,
|
||||
height: 1.6,
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(
|
||||
UIConstants.cardPadding,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
),
|
||||
enabled: !kbState.isLoading,
|
||||
),
|
||||
),
|
||||
|
||||
// ── Error message ────────────────────────────────────
|
||||
if (kbState.errorMessage != null) ...[
|
||||
const SizedBox(height: UIConstants.spacing12),
|
||||
_ErrorBanner(message: kbState.errorMessage!),
|
||||
],
|
||||
|
||||
const SizedBox(height: UIConstants.spacing16),
|
||||
|
||||
// ── Action buttons ───────────────────────────────────
|
||||
if (kbState.isLoading)
|
||||
_LoadingIndicator()
|
||||
else
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SaveButton(
|
||||
onPressed: () => _save(controller),
|
||||
),
|
||||
),
|
||||
if (kbState.chunkCount > 0) ...[
|
||||
const SizedBox(width: UIConstants.spacing12),
|
||||
_ClearButton(
|
||||
onPressed: () => _clear(controller),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: UIConstants.spacing32),
|
||||
|
||||
// ── How it works ─────────────────────────────────────
|
||||
_HowItWorksCard(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Top bar
|
||||
// =============================================================================
|
||||
class _TopBar extends StatelessWidget {
|
||||
const _TopBar({required this.onBack});
|
||||
|
||||
final VoidCallback onBack;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 52,
|
||||
padding: const EdgeInsets.symmetric(horizontal: UIConstants.spacing16),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
border: Border(bottom: BorderSide(color: AppColors.border)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_IconBtn(icon: Icons.arrow_back_rounded, onTap: onBack),
|
||||
const SizedBox(width: UIConstants.spacing12),
|
||||
Text(
|
||||
'Knowledge Base',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Status card
|
||||
// =============================================================================
|
||||
class _StatusCard extends StatelessWidget {
|
||||
const _StatusCard({required this.chunkCount});
|
||||
|
||||
final int chunkCount;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasChunks = chunkCount > 0;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing16,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: hasChunks ? AppColors.successMuted : AppColors.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
border: Border.all(
|
||||
color: hasChunks
|
||||
? AppColors.success.withValues(alpha: 0.3)
|
||||
: AppColors.border,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
hasChunks
|
||||
? Icons.check_circle_outline_rounded
|
||||
: Icons.info_outline_rounded,
|
||||
size: 16,
|
||||
color: hasChunks ? AppColors.success : AppColors.textMuted,
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
hasChunks
|
||||
? '$chunkCount chunk${chunkCount == 1 ? '' : 's'} stored — '
|
||||
'AI chat will use these as context.'
|
||||
: 'No notes added yet. The AI chat will use only its base '
|
||||
'training knowledge.',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
color: hasChunks ? AppColors.success : AppColors.textMuted,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Loading indicator
|
||||
// =============================================================================
|
||||
class _LoadingIndicator extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: UIConstants.spacing16),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing12),
|
||||
Text(
|
||||
'Generating embeddings… this may take a moment.',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Save button
|
||||
// =============================================================================
|
||||
class _SaveButton extends StatefulWidget {
|
||||
const _SaveButton({required this.onPressed});
|
||||
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
State<_SaveButton> createState() => _SaveButtonState();
|
||||
}
|
||||
|
||||
class _SaveButtonState extends State<_SaveButton> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: AnimatedContainer(
|
||||
duration: UIConstants.animationDuration,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered
|
||||
? AppColors.accent.withValues(alpha: 0.85)
|
||||
: AppColors.accent,
|
||||
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
onTap: widget.onPressed,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.save_outlined,
|
||||
color: AppColors.zinc950,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
Text(
|
||||
'Save to Knowledge Base',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.zinc950,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Clear button
|
||||
// =============================================================================
|
||||
class _ClearButton extends StatefulWidget {
|
||||
const _ClearButton({required this.onPressed});
|
||||
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
State<_ClearButton> createState() => _ClearButtonState();
|
||||
}
|
||||
|
||||
class _ClearButtonState extends State<_ClearButton> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: AnimatedContainer(
|
||||
duration: UIConstants.animationDuration,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered ? AppColors.destructiveMuted : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
border: Border.all(
|
||||
color: _hovered
|
||||
? AppColors.destructive.withValues(alpha: 0.4)
|
||||
: AppColors.border,
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
onTap: widget.onPressed,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing16,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.delete_outline_rounded,
|
||||
size: 15,
|
||||
color:
|
||||
_hovered ? AppColors.destructive : AppColors.textMuted,
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
Text(
|
||||
'Clear All',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: _hovered
|
||||
? AppColors.destructive
|
||||
: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Error banner
|
||||
// =============================================================================
|
||||
class _ErrorBanner extends StatelessWidget {
|
||||
const _ErrorBanner({required this.message});
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UIConstants.spacing12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.destructiveMuted,
|
||||
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
border: Border.all(
|
||||
color: AppColors.destructive.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline_rounded,
|
||||
color: AppColors.destructive,
|
||||
size: 15,
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
color: AppColors.destructive,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// How it works info card
|
||||
// =============================================================================
|
||||
class _HowItWorksCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UIConstants.cardPadding),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(UIConstants.borderRadius),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.info_outline_rounded,
|
||||
size: 15,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
Text(
|
||||
'How it works',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing12),
|
||||
_Step(
|
||||
n: '1',
|
||||
text: 'Your text is split into ~500-character chunks at paragraph '
|
||||
'and sentence boundaries.',
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing8),
|
||||
_Step(
|
||||
n: '2',
|
||||
text:
|
||||
'Each chunk is embedded by the local Nomic model into a 768-dim '
|
||||
'vector and stored in the database.',
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing8),
|
||||
_Step(
|
||||
n: '3',
|
||||
text:
|
||||
'When you ask a question, the 3 most similar chunks are retrieved '
|
||||
'and injected into the AI system prompt as context.',
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing8),
|
||||
_Step(
|
||||
n: '4',
|
||||
text:
|
||||
'Everything stays on your device — no data leaves the machine.',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Step extends StatelessWidget {
|
||||
const _Step({required this.n, required this.text});
|
||||
|
||||
final String n;
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
n,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
color: AppColors.textMuted,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Small reusable widgets
|
||||
// =============================================================================
|
||||
|
||||
class _SectionLabel extends StatelessWidget {
|
||||
const _SectionLabel(this.label);
|
||||
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
label,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textMuted,
|
||||
letterSpacing: 0.8,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IconBtn extends StatefulWidget {
|
||||
const _IconBtn({required this.icon, required this.onTap});
|
||||
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
State<_IconBtn> createState() => _IconBtnState();
|
||||
}
|
||||
|
||||
class _IconBtnState extends State<_IconBtn> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: UIConstants.animationDuration,
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered ? AppColors.zinc800 : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Icon(
|
||||
widget.icon,
|
||||
size: 18,
|
||||
color: _hovered ? AppColors.textPrimary : AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
13
lib/presentation/settings/knowledge_base_state.dart
Normal file
13
lib/presentation/settings/knowledge_base_state.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'knowledge_base_state.freezed.dart';
|
||||
|
||||
@freezed
|
||||
class KnowledgeBaseState with _$KnowledgeBaseState {
|
||||
const factory KnowledgeBaseState({
|
||||
@Default(false) bool isLoading,
|
||||
@Default(0) int chunkCount,
|
||||
String? successMessage,
|
||||
String? errorMessage,
|
||||
}) = _KnowledgeBaseState;
|
||||
}
|
||||
235
lib/presentation/settings/knowledge_base_state.freezed.dart
Normal file
235
lib/presentation/settings/knowledge_base_state.freezed.dart
Normal file
@@ -0,0 +1,235 @@
|
||||
// coverage:ignore-file
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'knowledge_base_state.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
final _privateConstructorUsedError = UnsupportedError(
|
||||
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
|
||||
);
|
||||
|
||||
/// @nodoc
|
||||
mixin _$KnowledgeBaseState {
|
||||
bool get isLoading => throw _privateConstructorUsedError;
|
||||
int get chunkCount => throw _privateConstructorUsedError;
|
||||
String? get successMessage => throw _privateConstructorUsedError;
|
||||
String? get errorMessage => throw _privateConstructorUsedError;
|
||||
|
||||
/// Create a copy of KnowledgeBaseState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
$KnowledgeBaseStateCopyWith<KnowledgeBaseState> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $KnowledgeBaseStateCopyWith<$Res> {
|
||||
factory $KnowledgeBaseStateCopyWith(
|
||||
KnowledgeBaseState value,
|
||||
$Res Function(KnowledgeBaseState) then,
|
||||
) = _$KnowledgeBaseStateCopyWithImpl<$Res, KnowledgeBaseState>;
|
||||
@useResult
|
||||
$Res call({
|
||||
bool isLoading,
|
||||
int chunkCount,
|
||||
String? successMessage,
|
||||
String? errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$KnowledgeBaseStateCopyWithImpl<$Res, $Val extends KnowledgeBaseState>
|
||||
implements $KnowledgeBaseStateCopyWith<$Res> {
|
||||
_$KnowledgeBaseStateCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
/// Create a copy of KnowledgeBaseState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? isLoading = null,
|
||||
Object? chunkCount = null,
|
||||
Object? successMessage = freezed,
|
||||
Object? errorMessage = freezed,
|
||||
}) {
|
||||
return _then(
|
||||
_value.copyWith(
|
||||
isLoading: null == isLoading
|
||||
? _value.isLoading
|
||||
: isLoading // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
chunkCount: null == chunkCount
|
||||
? _value.chunkCount
|
||||
: chunkCount // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
successMessage: freezed == successMessage
|
||||
? _value.successMessage
|
||||
: successMessage // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
errorMessage: freezed == errorMessage
|
||||
? _value.errorMessage
|
||||
: errorMessage // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
)
|
||||
as $Val,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$KnowledgeBaseStateImplCopyWith<$Res>
|
||||
implements $KnowledgeBaseStateCopyWith<$Res> {
|
||||
factory _$$KnowledgeBaseStateImplCopyWith(
|
||||
_$KnowledgeBaseStateImpl value,
|
||||
$Res Function(_$KnowledgeBaseStateImpl) then,
|
||||
) = __$$KnowledgeBaseStateImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call({
|
||||
bool isLoading,
|
||||
int chunkCount,
|
||||
String? successMessage,
|
||||
String? errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$KnowledgeBaseStateImplCopyWithImpl<$Res>
|
||||
extends _$KnowledgeBaseStateCopyWithImpl<$Res, _$KnowledgeBaseStateImpl>
|
||||
implements _$$KnowledgeBaseStateImplCopyWith<$Res> {
|
||||
__$$KnowledgeBaseStateImplCopyWithImpl(
|
||||
_$KnowledgeBaseStateImpl _value,
|
||||
$Res Function(_$KnowledgeBaseStateImpl) _then,
|
||||
) : super(_value, _then);
|
||||
|
||||
/// Create a copy of KnowledgeBaseState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? isLoading = null,
|
||||
Object? chunkCount = null,
|
||||
Object? successMessage = freezed,
|
||||
Object? errorMessage = freezed,
|
||||
}) {
|
||||
return _then(
|
||||
_$KnowledgeBaseStateImpl(
|
||||
isLoading: null == isLoading
|
||||
? _value.isLoading
|
||||
: isLoading // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
chunkCount: null == chunkCount
|
||||
? _value.chunkCount
|
||||
: chunkCount // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
successMessage: freezed == successMessage
|
||||
? _value.successMessage
|
||||
: successMessage // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
errorMessage: freezed == errorMessage
|
||||
? _value.errorMessage
|
||||
: errorMessage // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
class _$KnowledgeBaseStateImpl implements _KnowledgeBaseState {
|
||||
const _$KnowledgeBaseStateImpl({
|
||||
this.isLoading = false,
|
||||
this.chunkCount = 0,
|
||||
this.successMessage,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
@override
|
||||
@JsonKey()
|
||||
final bool isLoading;
|
||||
@override
|
||||
@JsonKey()
|
||||
final int chunkCount;
|
||||
@override
|
||||
final String? successMessage;
|
||||
@override
|
||||
final String? errorMessage;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'KnowledgeBaseState(isLoading: $isLoading, chunkCount: $chunkCount, successMessage: $successMessage, errorMessage: $errorMessage)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$KnowledgeBaseStateImpl &&
|
||||
(identical(other.isLoading, isLoading) ||
|
||||
other.isLoading == isLoading) &&
|
||||
(identical(other.chunkCount, chunkCount) ||
|
||||
other.chunkCount == chunkCount) &&
|
||||
(identical(other.successMessage, successMessage) ||
|
||||
other.successMessage == successMessage) &&
|
||||
(identical(other.errorMessage, errorMessage) ||
|
||||
other.errorMessage == errorMessage));
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType,
|
||||
isLoading,
|
||||
chunkCount,
|
||||
successMessage,
|
||||
errorMessage,
|
||||
);
|
||||
|
||||
/// Create a copy of KnowledgeBaseState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$KnowledgeBaseStateImplCopyWith<_$KnowledgeBaseStateImpl> get copyWith =>
|
||||
__$$KnowledgeBaseStateImplCopyWithImpl<_$KnowledgeBaseStateImpl>(
|
||||
this,
|
||||
_$identity,
|
||||
);
|
||||
}
|
||||
|
||||
abstract class _KnowledgeBaseState implements KnowledgeBaseState {
|
||||
const factory _KnowledgeBaseState({
|
||||
final bool isLoading,
|
||||
final int chunkCount,
|
||||
final String? successMessage,
|
||||
final String? errorMessage,
|
||||
}) = _$KnowledgeBaseStateImpl;
|
||||
|
||||
@override
|
||||
bool get isLoading;
|
||||
@override
|
||||
int get chunkCount;
|
||||
@override
|
||||
String? get successMessage;
|
||||
@override
|
||||
String? get errorMessage;
|
||||
|
||||
/// Create a copy of KnowledgeBaseState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
_$$KnowledgeBaseStateImplCopyWith<_$KnowledgeBaseStateImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
697
lib/presentation/settings/settings_page.dart
Normal file
697
lib/presentation/settings/settings_page.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ part of 'trainings_controller.dart';
|
||||
// **************************************************************************
|
||||
|
||||
String _$trainingsControllerHash() =>
|
||||
r'15c54eb8211e3b2549af6ef25a9cb451a7a9988a';
|
||||
r'2da51cdda3db5f186bc32980544a6aeeab268274';
|
||||
|
||||
/// See also [TrainingsController].
|
||||
@ProviderFor(TrainingsController)
|
||||
|
||||
@@ -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')}';
|
||||
}
|
||||
}
|
||||
|
||||
535
lib/presentation/welcome/welcome_screen.dart
Normal file
535
lib/presentation/welcome/welcome_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ part of 'workout_session_controller.dart';
|
||||
// **************************************************************************
|
||||
|
||||
String _$workoutSessionControllerHash() =>
|
||||
r'd3f53d72c80963634c6edaeb44aa5b04c9ffba6d';
|
||||
r'ba4c44e3bc2de98cced4eef96f8a337fd1e43665';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
272
pubspec.lock
272
pubspec.lock
@@ -33,6 +33,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.3.9"
|
||||
archive:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: archive
|
||||
sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.6.1"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -141,10 +149,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
version: "1.4.1"
|
||||
charcode:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -217,14 +225,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
csslib:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: csslib
|
||||
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
custom_lint_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -249,6 +249,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.1"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.12"
|
||||
dio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dio
|
||||
sha256: b9d46faecab38fc8cc286f80bc4d61a3bb5d4ac49e51ed877b4d6706efe57b25
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.9.1"
|
||||
dio_web_adapter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dio_web_adapter
|
||||
sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
drift:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -461,14 +485,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
html:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: html
|
||||
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.15.6"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -493,6 +509,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
image:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image
|
||||
sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.3.0"
|
||||
intl:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -593,18 +617,82 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.17"
|
||||
version: "0.12.18"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.1"
|
||||
version: "0.13.0"
|
||||
media_kit:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: media_kit
|
||||
sha256: ae9e79597500c7ad6083a3c7b7b7544ddabfceacce7ae5c9709b0ec16a5d6643
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.6"
|
||||
media_kit_libs_android_video:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: media_kit_libs_android_video
|
||||
sha256: "3f6274e5ab2de512c286a25c327288601ee445ed8ac319e0ef0b66148bd8f76c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.8"
|
||||
media_kit_libs_ios_video:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: media_kit_libs_ios_video
|
||||
sha256: b5382994eb37a4564c368386c154ad70ba0cc78dacdd3fb0cd9f30db6d837991
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.4"
|
||||
media_kit_libs_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: media_kit_libs_linux
|
||||
sha256: "2b473399a49ec94452c4d4ae51cfc0f6585074398d74216092bf3d54aac37ecf"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
media_kit_libs_macos_video:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: media_kit_libs_macos_video
|
||||
sha256: f26aa1452b665df288e360393758f84b911f70ffb3878032e1aabba23aa1032d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.4"
|
||||
media_kit_libs_video:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: media_kit_libs_video
|
||||
sha256: "2b235b5dac79c6020e01eef5022c6cc85fedc0df1738aadc6ea489daa12a92a9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.7"
|
||||
media_kit_libs_windows_video:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: media_kit_libs_windows_video
|
||||
sha256: dff76da2778729ab650229e6b4ec6ec111eb5151431002cbd7ea304ff1f112ab
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.11"
|
||||
media_kit_video:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: media_kit_video
|
||||
sha256: "813858c3fe84eb46679eb698695f60665e2bfbef757766fac4d2e683f926e15a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.1"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -645,6 +733,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
package_info_plus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus
|
||||
sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.0.0"
|
||||
package_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus_platform_interface
|
||||
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.1"
|
||||
path:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -797,6 +901,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.4"
|
||||
safe_local_storage:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: safe_local_storage
|
||||
sha256: "287ea1f667c0b93cdc127dccc707158e2d81ee59fba0459c31a0c7da4d09c755"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.3"
|
||||
screen_brightness_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: screen_brightness_android
|
||||
sha256: d34f5321abd03bc3474f4c381f53d189117eba0b039eac1916aa92cca5fd0a96
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
screen_brightness_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: screen_brightness_platform_interface
|
||||
sha256: "737bd47b57746bc4291cab1b8a5843ee881af499514881b0247ec77447ee769c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
screen_retriever:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -922,6 +1050,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: synchronized
|
||||
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.0"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -934,10 +1070,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7"
|
||||
version: "0.7.9"
|
||||
timing:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -962,6 +1098,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
universal_platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: universal_platform
|
||||
sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
uri_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: uri_parser
|
||||
sha256: "051c62e5f693de98ca9f130ee707f8916e2266945565926be3ff20659f7853ce"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
uuid:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1002,54 +1154,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
video_player:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: video_player
|
||||
sha256: "08bfba72e311d48219acad4e191b1f9c27ff8cf928f2c7234874592d9c9d7341"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.11.0"
|
||||
video_player_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: video_player_android
|
||||
sha256: e726b33894526cf96a3eefe61af054b0c3e7d254443b3695b3c142dc277291be
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.9.3"
|
||||
video_player_avfoundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: video_player_avfoundation
|
||||
sha256: f93b93a3baa12ca0ff7d00ca8bc60c1ecd96865568a01ff0c18a99853ee201a5
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.9.3"
|
||||
video_player_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: video_player_platform_interface
|
||||
sha256: "57c5d73173f76d801129d0531c2774052c5a7c11ccb962f1830630decd9f24ec"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.6.0"
|
||||
video_player_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: video_player_web
|
||||
sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
video_player_win:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: video_player_win
|
||||
sha256: a4caca55ead1eb469d10060592e7ecdcbcd4493c6e2b63e4e666ff5446c790f1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1058,6 +1162,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.0.2"
|
||||
volume_controller:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: volume_controller
|
||||
sha256: "5c1a13d2ea99d2f6753e7c660d0d3fab541f36da3999cafeb17b66fe49759ad7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.1"
|
||||
wakelock_plus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: wakelock_plus
|
||||
sha256: "9296d40c9adbedaba95d1e704f4e0b434be446e2792948d0e4aa977048104228"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
wakelock_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: wakelock_plus_platform_interface
|
||||
sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
watcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
11
pubspec.yaml
11
pubspec.yaml
@@ -36,9 +36,10 @@ dependencies:
|
||||
fl_chart: ^0.66.0
|
||||
animate_do: ^3.3.4
|
||||
|
||||
# Video
|
||||
video_player: ^2.8.2
|
||||
video_player_win: ^3.2.2
|
||||
# Video (cross-platform: Linux, Windows, macOS, Android, iOS)
|
||||
media_kit: ^1.1.10+1
|
||||
media_kit_video: ^1.2.4
|
||||
media_kit_libs_video: ^1.0.5
|
||||
|
||||
# Markdown
|
||||
flutter_markdown: ^0.6.18+3
|
||||
@@ -49,6 +50,10 @@ dependencies:
|
||||
window_manager: ^0.3.9
|
||||
uuid: ^4.5.1
|
||||
|
||||
# AI Model Download
|
||||
dio: ^5.7.0
|
||||
archive: ^3.6.0
|
||||
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user