This commit is contained in:
49
lib/core/constants/ai_constants.dart
Normal file
49
lib/core/constants/ai_constants.dart
Normal file
@@ -0,0 +1,49 @@
|
||||
import 'dart:io';
|
||||
|
||||
class AiConstants {
|
||||
AiConstants._();
|
||||
|
||||
// Server ports
|
||||
static const int chatServerPort = 8080;
|
||||
static const int embeddingServerPort = 8081;
|
||||
|
||||
// API endpoints
|
||||
static String get chatApiUrl =>
|
||||
'http://localhost:$chatServerPort/v1/chat/completions';
|
||||
static String get embeddingApiUrl =>
|
||||
'http://localhost:$embeddingServerPort/v1/embeddings';
|
||||
|
||||
// Model files
|
||||
static const String qwenModelFile = 'qwen2.5-7b-instruct-q4_k_m.gguf';
|
||||
static const String nomicModelFile = 'nomic-embed-text-v1.5.Q4_K_M.gguf';
|
||||
static const String nomicModelName = 'nomic-embed-text-v1.5.Q4_K_M';
|
||||
|
||||
// llama.cpp binary
|
||||
static const String llamaBuild = 'b8130';
|
||||
static String get serverBinaryName =>
|
||||
Platform.isWindows ? 'llama-server.exe' : 'llama-server';
|
||||
|
||||
// Model download URLs
|
||||
static const String nomicModelUrl =
|
||||
'https://huggingface.co/nomic-ai/nomic-embed-text-v1.5-GGUF/resolve/main/nomic-embed-text-v1.5.Q4_K_M.gguf';
|
||||
static const String qwenModelUrl =
|
||||
'https://huggingface.co/bartowski/Qwen2.5-7B-Instruct-GGUF/resolve/main/Qwen2.5-7B-Instruct-Q4_K_M.gguf';
|
||||
|
||||
// Server configuration
|
||||
static const int qwenContextSize = 4096;
|
||||
static const int nomicContextSize = 8192;
|
||||
static const int gpuLayerOffload = 99;
|
||||
static const double chatTemperature = 0.7;
|
||||
|
||||
// Timeouts
|
||||
static const Duration serverConnectTimeout = Duration(seconds: 30);
|
||||
static const Duration serverReceiveTimeout = Duration(minutes: 5);
|
||||
static const Duration embeddingConnectTimeout = Duration(seconds: 10);
|
||||
static const Duration embeddingReceiveTimeout = Duration(seconds: 60);
|
||||
|
||||
// System prompt
|
||||
static const String baseSystemPrompt =
|
||||
'You are a helpful AI fitness assistant for personal trainers. '
|
||||
'Help users design training plans, analyse exercise technique, '
|
||||
'and answer questions about sports science and nutrition.';
|
||||
}
|
||||
@@ -2,6 +2,7 @@ class AppConstants {
|
||||
AppConstants._();
|
||||
|
||||
static const String appName = 'TrainHub';
|
||||
static const String appVersion = '2.0.0';
|
||||
static const double windowWidth = 1280;
|
||||
static const double windowHeight = 800;
|
||||
static const double minWindowWidth = 800;
|
||||
|
||||
@@ -5,7 +5,7 @@ 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:trainhub_flutter/data/services/embedding_service.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
const _uuid = Uuid();
|
||||
|
||||
174
lib/data/services/ai_process_manager.dart
Normal file
174
lib/data/services/ai_process_manager.dart
Normal file
@@ -0,0 +1,174 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ai_constants.dart';
|
||||
|
||||
enum AiServerStatus { offline, starting, ready, error }
|
||||
|
||||
/// Manages the two llama.cpp server processes that provide AI features.
|
||||
///
|
||||
/// Both processes are kept alive for the lifetime of the app and must be
|
||||
/// killed on shutdown to prevent zombie processes from consuming RAM.
|
||||
///
|
||||
/// - Qwen 2.5 7B → port 8080 (chat / completions)
|
||||
/// - Nomic Embed → port 8081 (embeddings)
|
||||
class AiProcessManager extends ChangeNotifier {
|
||||
Process? _qwenProcess;
|
||||
Process? _nomicProcess;
|
||||
AiServerStatus _status = AiServerStatus.offline;
|
||||
String? _lastError;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public API
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
AiServerStatus get status => _status;
|
||||
bool get isRunning => _status == AiServerStatus.ready;
|
||||
String? get errorMessage => _lastError;
|
||||
|
||||
/// Starts both inference servers. No-ops if already running or starting.
|
||||
Future<void> startServers() async {
|
||||
if (_status == AiServerStatus.starting || _status == AiServerStatus.ready) {
|
||||
return;
|
||||
}
|
||||
|
||||
_updateStatus(AiServerStatus.starting);
|
||||
_lastError = null;
|
||||
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final base = dir.path;
|
||||
final serverBin = p.join(base, AiConstants.serverBinaryName);
|
||||
|
||||
if (!File(serverBin).existsSync()) {
|
||||
_lastError = 'llama-server executable not found.';
|
||||
_updateStatus(AiServerStatus.error);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
_qwenProcess = await Process.start(serverBin, [
|
||||
'-m', p.join(base, AiConstants.qwenModelFile),
|
||||
'--port', '${AiConstants.chatServerPort}',
|
||||
'--ctx-size', '${AiConstants.qwenContextSize}',
|
||||
'-ngl', '${AiConstants.gpuLayerOffload}',
|
||||
], runInShell: false);
|
||||
|
||||
_qwenProcess!.stdout.listen((event) {
|
||||
if (kDebugMode) print('[QWEN STDOUT] ${String.fromCharCodes(event)}');
|
||||
});
|
||||
_qwenProcess!.stderr.listen((event) {
|
||||
if (kDebugMode) print('[QWEN STDERR] ${String.fromCharCodes(event)}');
|
||||
});
|
||||
|
||||
// Monitor for unexpected crash
|
||||
_qwenProcess!.exitCode.then((code) {
|
||||
if (_status == AiServerStatus.ready ||
|
||||
_status == AiServerStatus.starting) {
|
||||
_lastError = 'Qwen Chat Server crashed with code $code';
|
||||
_updateStatus(AiServerStatus.error);
|
||||
}
|
||||
});
|
||||
|
||||
_nomicProcess = await Process.start(serverBin, [
|
||||
'-m', p.join(base, AiConstants.nomicModelFile),
|
||||
'--port', '${AiConstants.embeddingServerPort}',
|
||||
'--ctx-size', '${AiConstants.nomicContextSize}',
|
||||
'--embedding',
|
||||
], runInShell: false);
|
||||
|
||||
_nomicProcess!.stdout.listen((event) {
|
||||
if (kDebugMode) print('[NOMIC STDOUT] ${String.fromCharCodes(event)}');
|
||||
});
|
||||
_nomicProcess!.stderr.listen((event) {
|
||||
if (kDebugMode) print('[NOMIC STDERR] ${String.fromCharCodes(event)}');
|
||||
});
|
||||
|
||||
// Monitor for unexpected crash
|
||||
_nomicProcess!.exitCode.then((code) {
|
||||
if (_status == AiServerStatus.ready ||
|
||||
_status == AiServerStatus.starting) {
|
||||
_lastError = 'Nomic Embedding Server crashed with code $code';
|
||||
_updateStatus(AiServerStatus.error);
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for servers to bind to their ports and allocate memory.
|
||||
// This is crucial because loading models (especially 7B) takes several
|
||||
// seconds and significant RAM, which might cause the dart process to appear hung.
|
||||
int attempts = 0;
|
||||
bool qwenReady = false;
|
||||
bool nomicReady = false;
|
||||
|
||||
while (attempts < 20 && (!qwenReady || !nomicReady)) {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
if (!qwenReady) {
|
||||
qwenReady = await _isPortReady(AiConstants.chatServerPort);
|
||||
}
|
||||
if (!nomicReady) {
|
||||
nomicReady = await _isPortReady(AiConstants.embeddingServerPort);
|
||||
}
|
||||
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (!qwenReady || !nomicReady) {
|
||||
throw Exception('Servers failed to start within 10 seconds.');
|
||||
}
|
||||
|
||||
_updateStatus(AiServerStatus.ready);
|
||||
} catch (e) {
|
||||
// Clean up any partially-started processes before returning error.
|
||||
_qwenProcess?.kill();
|
||||
_nomicProcess?.kill();
|
||||
_qwenProcess = null;
|
||||
_nomicProcess = null;
|
||||
_lastError = e.toString();
|
||||
_updateStatus(AiServerStatus.error);
|
||||
}
|
||||
}
|
||||
|
||||
/// Kills both processes and resets the running flag.
|
||||
/// Safe to call even if servers were never started.
|
||||
Future<void> stopServers() async {
|
||||
_qwenProcess?.kill();
|
||||
_nomicProcess?.kill();
|
||||
|
||||
if (Platform.isWindows) {
|
||||
try {
|
||||
await Process.run('taskkill', ['/F', '/IM', AiConstants.serverBinaryName]);
|
||||
} catch (_) {}
|
||||
} else if (Platform.isMacOS || Platform.isLinux) {
|
||||
try {
|
||||
await Process.run('pkill', ['-f', 'llama-server']);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
_qwenProcess = null;
|
||||
_nomicProcess = null;
|
||||
_updateStatus(AiServerStatus.offline);
|
||||
}
|
||||
|
||||
Future<bool> _isPortReady(int port) async {
|
||||
try {
|
||||
final socket = await Socket.connect(
|
||||
'127.0.0.1',
|
||||
port,
|
||||
timeout: const Duration(seconds: 1),
|
||||
);
|
||||
socket.destroy();
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void _updateStatus(AiServerStatus newStatus) {
|
||||
if (_status != newStatus) {
|
||||
_status = newStatus;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,24 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ai_constants.dart';
|
||||
|
||||
/// Wraps the Nomic embedding server (llama.cpp, port 8081).
|
||||
/// Wraps the Nomic embedding server (llama.cpp).
|
||||
/// 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),
|
||||
connectTimeout: AiConstants.embeddingConnectTimeout,
|
||||
receiveTimeout: AiConstants.embeddingReceiveTimeout,
|
||||
),
|
||||
);
|
||||
|
||||
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,
|
||||
AiConstants.embeddingApiUrl,
|
||||
data: {
|
||||
'input': text,
|
||||
'model': 'nomic-embed-text-v1.5.Q4_K_M',
|
||||
'model': AiConstants.nomicModelName,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,8 @@ 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/services/ai_process_manager.dart';
|
||||
import 'package:trainhub_flutter/data/services/embedding_service.dart';
|
||||
import 'package:trainhub_flutter/data/database/daos/knowledge_chunk_dao.dart';
|
||||
|
||||
final GetIt getIt = GetIt.instance;
|
||||
|
||||
@@ -4,10 +4,8 @@ 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/data/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();
|
||||
@@ -44,14 +42,10 @@ class TrainHubApp extends ConsumerStatefulWidget {
|
||||
ConsumerState<TrainHubApp> createState() => _TrainHubAppState();
|
||||
}
|
||||
|
||||
class _TrainHubAppState extends ConsumerState<TrainHubApp>
|
||||
with WindowListener {
|
||||
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
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -87,18 +81,6 @@ class _TrainHubAppState extends ConsumerState<TrainHubApp>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 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,
|
||||
|
||||
@@ -1,30 +1,32 @@
|
||||
import 'dart:convert';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ai_constants.dart';
|
||||
import 'package:trainhub_flutter/domain/repositories/chat_repository.dart';
|
||||
import 'package:trainhub_flutter/domain/repositories/note_repository.dart';
|
||||
import 'package:trainhub_flutter/data/services/ai_process_manager.dart';
|
||||
import 'package:trainhub_flutter/injection.dart';
|
||||
import 'package:trainhub_flutter/presentation/chat/chat_state.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
part 'chat_controller.g.dart';
|
||||
|
||||
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
|
||||
AiProcessManager aiProcessManager(AiProcessManagerRef ref) {
|
||||
final manager = getIt<AiProcessManager>();
|
||||
manager.addListener(() => ref.notifyListeners());
|
||||
return manager;
|
||||
}
|
||||
|
||||
@riverpod
|
||||
class ChatController extends _$ChatController {
|
||||
late ChatRepository _repo;
|
||||
late NoteRepository _noteRepo;
|
||||
|
||||
// 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),
|
||||
connectTimeout: AiConstants.serverConnectTimeout,
|
||||
receiveTimeout: AiConstants.serverReceiveTimeout,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -32,14 +34,14 @@ class ChatController extends _$ChatController {
|
||||
Future<ChatState> build() async {
|
||||
_repo = getIt<ChatRepository>();
|
||||
_noteRepo = getIt<NoteRepository>();
|
||||
final aiManager = ref.read(aiProcessManagerProvider);
|
||||
if (aiManager.status == AiServerStatus.offline) {
|
||||
aiManager.startServers();
|
||||
}
|
||||
final sessions = await _repo.getAllSessions();
|
||||
return ChatState(sessions: sessions);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Session management (unchanged)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
Future<void> createSession() async {
|
||||
final session = await _repo.createSession();
|
||||
final sessions = await _repo.getAllSessions();
|
||||
@@ -72,28 +74,29 @@ class ChatController extends _$ChatController {
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Send message (RAG + Step D)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
Future<void> sendMessage(String content) async {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final sessionId = await _resolveSession(current, content);
|
||||
await _persistUserMessage(sessionId, content);
|
||||
final contextChunks = await _searchKnowledgeBase(content);
|
||||
final systemPrompt = _buildSystemPrompt(contextChunks);
|
||||
final history = _buildHistory();
|
||||
final fullAiResponse = await _streamResponse(systemPrompt, history);
|
||||
await _persistAssistantResponse(sessionId, content, fullAiResponse);
|
||||
}
|
||||
|
||||
// ── 1. Resolve / create a session ─────────────────────────────────────
|
||||
String sessionId;
|
||||
if (current.activeSession == null) {
|
||||
final session = await _repo.createSession();
|
||||
sessionId = session.id;
|
||||
final sessions = await _repo.getAllSessions();
|
||||
state = AsyncValue.data(
|
||||
current.copyWith(sessions: sessions, activeSession: session),
|
||||
);
|
||||
} else {
|
||||
sessionId = current.activeSession!.id;
|
||||
}
|
||||
Future<String> _resolveSession(ChatState current, String content) async {
|
||||
if (current.activeSession != null) return current.activeSession!.id;
|
||||
final session = await _repo.createSession();
|
||||
final sessions = await _repo.getAllSessions();
|
||||
state = AsyncValue.data(
|
||||
current.copyWith(sessions: sessions, activeSession: session),
|
||||
);
|
||||
return session.id;
|
||||
}
|
||||
|
||||
// ── 2. Persist user message & show typing indicator ───────────────────
|
||||
Future<void> _persistUserMessage(String sessionId, String content) async {
|
||||
await _repo.addMessage(
|
||||
sessionId: sessionId,
|
||||
role: 'user',
|
||||
@@ -104,95 +107,196 @@ class ChatController extends _$ChatController {
|
||||
state.valueOrNull!.copyWith(
|
||||
messages: messagesAfterUser,
|
||||
isTyping: true,
|
||||
thinkingSteps: [],
|
||||
streamingContent: '',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
Future<List<String>> _searchKnowledgeBase(String query) async {
|
||||
final searchStep = _createStep('Searching knowledge base...');
|
||||
List<String> contextChunks = [];
|
||||
try {
|
||||
contextChunks = await _noteRepo.searchSimilar(content, topK: 3);
|
||||
} catch (_) {
|
||||
// Nomic server not running or no chunks stored — continue without RAG.
|
||||
contextChunks = await _noteRepo.searchSimilar(query, topK: 3);
|
||||
if (contextChunks.isNotEmpty) {
|
||||
_updateStep(
|
||||
searchStep.id,
|
||||
status: ThinkingStepStatus.completed,
|
||||
title: 'Found ${contextChunks.length} documents',
|
||||
details: 'Context added for assistant.',
|
||||
);
|
||||
} else {
|
||||
_updateStep(
|
||||
searchStep.id,
|
||||
status: ThinkingStepStatus.completed,
|
||||
title: 'No matching documents in knowledge base',
|
||||
details: 'Responding based on general knowledge.',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_updateStep(
|
||||
searchStep.id,
|
||||
status: ThinkingStepStatus.error,
|
||||
title: 'Knowledge base search error',
|
||||
details: e.toString(),
|
||||
);
|
||||
}
|
||||
return contextChunks;
|
||||
}
|
||||
|
||||
// ── 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,
|
||||
},
|
||||
)
|
||||
List<Map<String, String>> _buildHistory() {
|
||||
final messages = state.valueOrNull?.messages ?? [];
|
||||
return messages
|
||||
.map((m) => <String, String>{
|
||||
'role': m.isUser ? 'user' : 'assistant',
|
||||
'content': m.content,
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
// ── 5. POST to Qwen (http://localhost:8080/v1/chat/completions) ────────
|
||||
String aiResponse;
|
||||
Future<String> _streamResponse(
|
||||
String systemPrompt,
|
||||
List<Map<String, String>> history,
|
||||
) async {
|
||||
final generateStep = _createStep('Generating response...');
|
||||
String fullAiResponse = '';
|
||||
try {
|
||||
final response = await _dio.post<Map<String, dynamic>>(
|
||||
_chatApiUrl,
|
||||
final response = await _dio.post<ResponseBody>(
|
||||
AiConstants.chatApiUrl,
|
||||
options: Options(responseType: ResponseType.stream),
|
||||
data: {
|
||||
'messages': [
|
||||
{'role': 'system', 'content': systemPrompt},
|
||||
...history,
|
||||
],
|
||||
'temperature': 0.7,
|
||||
'temperature': AiConstants.chatTemperature,
|
||||
'stream': true,
|
||||
},
|
||||
);
|
||||
aiResponse =
|
||||
response.data!['choices'][0]['message']['content'] as String;
|
||||
_updateStep(
|
||||
generateStep.id,
|
||||
status: ThinkingStepStatus.running,
|
||||
title: 'Writing...',
|
||||
);
|
||||
final stream = response.data!.stream;
|
||||
await for (final chunk in stream) {
|
||||
final textChunk = utf8.decode(chunk);
|
||||
for (final line in textChunk.split('\n')) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
final dataStr = line.substring(6).trim();
|
||||
if (dataStr == '[DONE]') break;
|
||||
if (dataStr.isEmpty) continue;
|
||||
try {
|
||||
final data = jsonDecode(dataStr);
|
||||
final delta = data['choices']?[0]?['delta']?['content'] ?? '';
|
||||
if (delta.isNotEmpty) {
|
||||
fullAiResponse += delta;
|
||||
final updatedState = state.valueOrNull;
|
||||
if (updatedState != null) {
|
||||
state = AsyncValue.data(
|
||||
updatedState.copyWith(streamingContent: fullAiResponse),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
_updateStep(
|
||||
generateStep.id,
|
||||
status: ThinkingStepStatus.completed,
|
||||
title: 'Response generated',
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
aiResponse =
|
||||
'Could not reach the AI server (${e.message}). '
|
||||
'Make sure AI models are downloaded and the inference servers have '
|
||||
'had time to start.';
|
||||
fullAiResponse += '\n\n[AI model communication error]';
|
||||
_updateStep(
|
||||
generateStep.id,
|
||||
status: ThinkingStepStatus.error,
|
||||
title: 'Generation failed',
|
||||
details: '${e.message}',
|
||||
);
|
||||
} catch (e) {
|
||||
aiResponse = 'An unexpected error occurred: $e';
|
||||
fullAiResponse += '\n\n[Unexpected error]';
|
||||
_updateStep(
|
||||
generateStep.id,
|
||||
status: ThinkingStepStatus.error,
|
||||
title: 'Generation failed',
|
||||
details: e.toString(),
|
||||
);
|
||||
}
|
||||
return fullAiResponse;
|
||||
}
|
||||
|
||||
// ── 6. Persist response & update session title on first exchange ───────
|
||||
Future<void> _persistAssistantResponse(
|
||||
String sessionId,
|
||||
String userContent,
|
||||
String aiResponse,
|
||||
) async {
|
||||
await _repo.addMessage(
|
||||
sessionId: sessionId,
|
||||
role: 'assistant',
|
||||
content: aiResponse,
|
||||
);
|
||||
|
||||
final messagesAfterAi = await _repo.getMessages(sessionId);
|
||||
if (messagesAfterAi.length <= 2) {
|
||||
final title =
|
||||
content.length > 30 ? '${content.substring(0, 30)}…' : content;
|
||||
final title = userContent.length > 30
|
||||
? '${userContent.substring(0, 30)}…'
|
||||
: userContent;
|
||||
await _repo.updateSessionTitle(sessionId, title);
|
||||
}
|
||||
|
||||
final sessions = await _repo.getAllSessions();
|
||||
state = AsyncValue.data(
|
||||
state.valueOrNull!.copyWith(
|
||||
messages: messagesAfterAi,
|
||||
isTyping: false,
|
||||
streamingContent: null,
|
||||
thinkingSteps: [],
|
||||
sessions: sessions,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
ThinkingStep _createStep(String title) {
|
||||
final step = ThinkingStep(
|
||||
id: const Uuid().v4(),
|
||||
title: title,
|
||||
status: ThinkingStepStatus.pending,
|
||||
);
|
||||
final current = state.valueOrNull;
|
||||
if (current != null) {
|
||||
state = AsyncValue.data(
|
||||
current.copyWith(thinkingSteps: [...current.thinkingSteps, step]),
|
||||
);
|
||||
}
|
||||
return step;
|
||||
}
|
||||
|
||||
void _updateStep(
|
||||
String id, {
|
||||
ThinkingStepStatus? status,
|
||||
String? title,
|
||||
String? details,
|
||||
}) {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final updatedSteps = current.thinkingSteps.map((s) {
|
||||
if (s.id != id) return s;
|
||||
return s.copyWith(
|
||||
status: status ?? s.status,
|
||||
title: title ?? s.title,
|
||||
details: details ?? s.details,
|
||||
);
|
||||
}).toList();
|
||||
state = AsyncValue.data(current.copyWith(thinkingSteps: updatedSteps));
|
||||
}
|
||||
|
||||
/// Builds the system prompt, injecting RAG context when available.
|
||||
static String _buildSystemPrompt(List<String> chunks) {
|
||||
if (chunks.isEmpty) return _baseSystemPrompt;
|
||||
|
||||
if (chunks.isEmpty) return AiConstants.baseSystemPrompt;
|
||||
final contextBlock = chunks
|
||||
.asMap()
|
||||
.entries
|
||||
.map((e) => '[${e.key + 1}] ${e.value}')
|
||||
.join('\n\n');
|
||||
|
||||
return '$_baseSystemPrompt\n\n'
|
||||
return '${AiConstants.baseSystemPrompt}\n\n'
|
||||
'### Relevant notes from the trainer\'s knowledge base:\n'
|
||||
'$contextBlock\n\n'
|
||||
'Use the above context to inform your response when relevant. '
|
||||
|
||||
@@ -6,7 +6,24 @@ part of 'chat_controller.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$chatControllerHash() => r'06ffc6b53c1d878ffc0a758da4f7ee1261ae1340';
|
||||
String _$aiProcessManagerHash() => r'ae77b1e18c06f4192092e1489744626fc8516776';
|
||||
|
||||
/// See also [aiProcessManager].
|
||||
@ProviderFor(aiProcessManager)
|
||||
final aiProcessManagerProvider = AutoDisposeProvider<AiProcessManager>.internal(
|
||||
aiProcessManager,
|
||||
name: r'aiProcessManagerProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$aiProcessManagerHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef AiProcessManagerRef = AutoDisposeProviderRef<AiProcessManager>;
|
||||
String _$chatControllerHash() => r'266d8a5ac91cbe6c112f85f15adf5a8046e85682';
|
||||
|
||||
/// See also [ChatController].
|
||||
@ProviderFor(ChatController)
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
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/data/services/ai_process_manager.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/chat_session.dart';
|
||||
import 'package:trainhub_flutter/presentation/chat/chat_controller.dart';
|
||||
import 'package:trainhub_flutter/presentation/chat/chat_state.dart';
|
||||
import 'package:trainhub_flutter/presentation/chat/widgets/message_bubble.dart';
|
||||
import 'package:trainhub_flutter/presentation/chat/widgets/missing_models_state.dart';
|
||||
import 'package:trainhub_flutter/presentation/chat/widgets/new_chat_button.dart';
|
||||
import 'package:trainhub_flutter/presentation/chat/widgets/typing_bubble.dart';
|
||||
import 'package:trainhub_flutter/presentation/chat/widgets/typing_indicator.dart';
|
||||
import 'package:trainhub_flutter/presentation/settings/ai_model_settings_controller.dart';
|
||||
|
||||
@RoutePage()
|
||||
@@ -49,11 +51,10 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
|
||||
void _sendMessage(ChatController controller) {
|
||||
final text = _inputController.text.trim();
|
||||
if (text.isNotEmpty) {
|
||||
controller.sendMessage(text);
|
||||
_inputController.clear();
|
||||
_inputFocusNode.requestFocus();
|
||||
}
|
||||
if (text.isEmpty) return;
|
||||
controller.sendMessage(text);
|
||||
_inputController.clear();
|
||||
_inputFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
String _formatTimestamp(String timestamp) {
|
||||
@@ -75,16 +76,10 @@ 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 modelsValidated =
|
||||
ref.watch(aiModelSettingsControllerProvider).areModelsValidated;
|
||||
final state = ref.watch(chatControllerProvider);
|
||||
final controller = ref.read(chatControllerProvider.notifier);
|
||||
|
||||
ref.listen(chatControllerProvider, (prev, next) {
|
||||
if (next.hasValue &&
|
||||
(prev?.value?.messages.length ?? 0) <
|
||||
@@ -97,16 +92,12 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
_scrollToBottom();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Show "models missing" placeholder ─────────────────────────────────
|
||||
if (!modelsValidated) {
|
||||
return const Scaffold(
|
||||
backgroundColor: AppColors.surface,
|
||||
body: _MissingModelsState(),
|
||||
body: MissingModelsState(),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Normal chat UI ─────────────────────────────────────────────────────
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.surface,
|
||||
body: Row(
|
||||
@@ -118,9 +109,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Side Panel
|
||||
// ---------------------------------------------------------------------------
|
||||
Widget _buildSidePanel(
|
||||
AsyncValue<ChatState> asyncState,
|
||||
ChatController controller,
|
||||
@@ -129,9 +117,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
width: 250,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
border: Border(
|
||||
right: BorderSide(color: AppColors.border, width: 1),
|
||||
),
|
||||
border: Border(right: BorderSide(color: AppColors.border, width: 1)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -139,19 +125,17 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
padding: const EdgeInsets.all(UIConstants.spacing12),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: _NewChatButton(onPressed: controller.createSession),
|
||||
child: NewChatButton(onPressed: controller.createSession),
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(height: 1, color: AppColors.border),
|
||||
|
||||
Expanded(
|
||||
child: asyncState.when(
|
||||
data: (data) {
|
||||
if (data.sessions.isEmpty) {
|
||||
return Center(
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UIConstants.spacing24),
|
||||
padding: EdgeInsets.all(UIConstants.spacing24),
|
||||
child: Text(
|
||||
'No conversations yet',
|
||||
style: TextStyle(
|
||||
@@ -169,23 +153,18 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
itemCount: data.sessions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final session = data.sessions[index];
|
||||
final isActive =
|
||||
session.id == data.activeSession?.id;
|
||||
return _buildSessionTile(
|
||||
session: session,
|
||||
isActive: isActive,
|
||||
isActive: session.id == data.activeSession?.id,
|
||||
controller: controller,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
error: (_, __) => Center(
|
||||
error: (_, __) => const Center(
|
||||
child: Text(
|
||||
'Error loading sessions',
|
||||
style: TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 13,
|
||||
),
|
||||
style: TextStyle(color: AppColors.textMuted, fontSize: 13),
|
||||
),
|
||||
),
|
||||
loading: () => const Center(
|
||||
@@ -211,7 +190,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
required ChatController controller,
|
||||
}) {
|
||||
final isHovered = _hoveredSessionId == session.id;
|
||||
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hoveredSessionId = session.id),
|
||||
onExit: (_) => setState(() {
|
||||
@@ -240,7 +218,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
border: isActive
|
||||
? Border.all(
|
||||
color: AppColors.accent.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
@@ -283,7 +260,8 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
Icons.delete_outline_rounded,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
onPressed: () => controller.deleteSession(session.id),
|
||||
onPressed: () =>
|
||||
controller.deleteSession(session.id),
|
||||
tooltip: 'Delete',
|
||||
),
|
||||
),
|
||||
@@ -296,9 +274,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chat Area
|
||||
// ---------------------------------------------------------------------------
|
||||
Widget _buildChatArea(
|
||||
AsyncValue<ChatState> asyncState,
|
||||
ChatController controller,
|
||||
@@ -315,29 +290,38 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
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();
|
||||
if (data.thinkingSteps.isNotEmpty ||
|
||||
(data.streamingContent != null &&
|
||||
data.streamingContent!.isNotEmpty)) {
|
||||
return TypingBubble(
|
||||
thinkingSteps: data.thinkingSteps,
|
||||
streamingContent: data.streamingContent,
|
||||
);
|
||||
}
|
||||
return const TypingIndicator();
|
||||
}
|
||||
final msg = data.messages[index];
|
||||
return _MessageBubble(
|
||||
return MessageBubble(
|
||||
message: msg,
|
||||
formattedTime: _formatTimestamp(msg.createdAt),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
error: (e, _) => Center(
|
||||
error: (e, _) => const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icon(
|
||||
Icons.error_outline_rounded,
|
||||
color: AppColors.destructive,
|
||||
size: 40,
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing12),
|
||||
SizedBox(height: UIConstants.spacing12),
|
||||
Text(
|
||||
'Something went wrong',
|
||||
style: TextStyle(
|
||||
@@ -402,15 +386,28 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input Bar
|
||||
// ---------------------------------------------------------------------------
|
||||
Widget _buildInputBar(
|
||||
AsyncValue<ChatState> asyncState,
|
||||
ChatController controller,
|
||||
) {
|
||||
final aiManager = ref.watch(aiProcessManagerProvider);
|
||||
final isTyping = asyncState.valueOrNull?.isTyping ?? false;
|
||||
|
||||
final isStarting = aiManager.status == AiServerStatus.starting;
|
||||
final isError = aiManager.status == AiServerStatus.error;
|
||||
final isReady = aiManager.status == AiServerStatus.ready;
|
||||
String? statusMessage;
|
||||
Color statusColor = AppColors.textMuted;
|
||||
if (isStarting) {
|
||||
statusMessage =
|
||||
'Starting AI inference server (this may take a moment)...';
|
||||
statusColor = AppColors.info;
|
||||
} else if (isError) {
|
||||
statusMessage =
|
||||
'AI Server Error: ${aiManager.errorMessage ?? "Unknown error"}';
|
||||
statusColor = AppColors.destructive;
|
||||
} else if (!isReady) {
|
||||
statusMessage = 'AI Server offline. Reconnecting...';
|
||||
}
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
@@ -420,466 +417,136 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
horizontal: UIConstants.spacing16,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.borderRadius),
|
||||
border: Border.all(color: AppColors.border, width: 1),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _inputController,
|
||||
focusNode: _inputFocusNode,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 4,
|
||||
minLines: 1,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Type a message...',
|
||||
hintStyle: TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 14,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing16,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
),
|
||||
onSubmitted: (_) => _sendMessage(controller),
|
||||
textInputAction: TextInputAction.send,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Material(
|
||||
color: isTyping ? AppColors.zinc700 : AppColors.accent,
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.borderRadius),
|
||||
child: InkWell(
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.borderRadius),
|
||||
onTap: isTyping ? null : () => _sendMessage(controller),
|
||||
child: Icon(
|
||||
Icons.arrow_upward_rounded,
|
||||
color:
|
||||
isTyping ? AppColors.textMuted : AppColors.zinc950,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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,
|
||||
),
|
||||
if (statusMessage != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: UIConstants.spacing8),
|
||||
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,
|
||||
if (isStarting)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: const CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.info,
|
||||
),
|
||||
)
|
||||
else if (isError)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 8),
|
||||
child: Icon(
|
||||
Icons.error_outline,
|
||||
size: 14,
|
||||
color: AppColors.destructive,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
statusMessage,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
color: statusColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// New Chat Button
|
||||
// =============================================================================
|
||||
class _NewChatButton extends StatefulWidget {
|
||||
const _NewChatButton({required this.onPressed});
|
||||
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
State<_NewChatButton> createState() => _NewChatButtonState();
|
||||
}
|
||||
|
||||
class _NewChatButtonState extends State<_NewChatButton> {
|
||||
bool _isHovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _isHovered = true),
|
||||
onExit: (_) => setState(() => _isHovered = false),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
decoration: BoxDecoration(
|
||||
color: _isHovered
|
||||
? AppColors.zinc700
|
||||
: AppColors.surfaceContainerHigh,
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
border: Border.all(color: AppColors.border, width: 1),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
onTap: widget.onPressed,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing12,
|
||||
vertical: UIConstants.spacing8,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: const [
|
||||
Icon(
|
||||
Icons.add_rounded,
|
||||
size: 16,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
SizedBox(width: UIConstants.spacing8),
|
||||
Text(
|
||||
'New Chat',
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Message Bubble
|
||||
// =============================================================================
|
||||
class _MessageBubble extends StatelessWidget {
|
||||
const _MessageBubble({
|
||||
required this.message,
|
||||
required this.formattedTime,
|
||||
});
|
||||
|
||||
final ChatMessageEntity message;
|
||||
final String formattedTime;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isUser = message.isUser;
|
||||
final maxWidth = MediaQuery.of(context).size.width * 0.55;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UIConstants.spacing12),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
isUser ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isUser) ...[
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.auto_awesome_rounded,
|
||||
size: 14,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
],
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: isUser
|
||||
? CrossAxisAlignment.end
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing16,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isUser
|
||||
? AppColors.zinc700
|
||||
: AppColors.surfaceContainer,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(16),
|
||||
topRight: const Radius.circular(16),
|
||||
bottomLeft: isUser
|
||||
? const Radius.circular(16)
|
||||
: const Radius.circular(4),
|
||||
bottomRight: isUser
|
||||
? const Radius.circular(4)
|
||||
: const Radius.circular(16),
|
||||
),
|
||||
border: isUser
|
||||
? null
|
||||
: Border.all(color: AppColors.border, width: 1),
|
||||
),
|
||||
child: SelectableText(
|
||||
message.content,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 14,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text(
|
||||
formattedTime,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isUser) ...[
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.accent.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.person_rounded,
|
||||
size: 14,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Typing Indicator (3 animated bouncing dots)
|
||||
// =============================================================================
|
||||
class _TypingIndicator extends StatefulWidget {
|
||||
const _TypingIndicator();
|
||||
|
||||
@override
|
||||
State<_TypingIndicator> createState() => _TypingIndicatorState();
|
||||
}
|
||||
|
||||
class _TypingIndicatorState extends State<_TypingIndicator>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1200),
|
||||
)..repeat();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UIConstants.spacing12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.auto_awesome_rounded,
|
||||
size: 14,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing16,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
bottomLeft: Radius.circular(4),
|
||||
bottomRight: Radius.circular(16),
|
||||
),
|
||||
border: Border.all(color: AppColors.border, width: 1),
|
||||
),
|
||||
child: AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, _) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(3, (index) {
|
||||
final delay = index * 0.2;
|
||||
final t = (_controller.value - delay) % 1.0;
|
||||
final bounce =
|
||||
t < 0.5 ? math.sin(t * math.pi * 2) * 4.0 : 0.0;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(left: index == 0 ? 0 : 4),
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, -bounce.abs()),
|
||||
child: Container(
|
||||
width: 7,
|
||||
height: 7,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.textMuted,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
if (isError)
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
onPressed: () => aiManager.startServers(),
|
||||
child: Text(
|
||||
'Retry',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.borderRadius),
|
||||
border:
|
||||
Border.all(color: AppColors.border, width: 1),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _inputController,
|
||||
focusNode: _inputFocusNode,
|
||||
enabled: isReady,
|
||||
style: TextStyle(
|
||||
color: isReady
|
||||
? AppColors.textPrimary
|
||||
: AppColors.textMuted,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 4,
|
||||
minLines: 1,
|
||||
decoration: InputDecoration(
|
||||
hintText: isReady
|
||||
? 'Type a message...'
|
||||
: 'Waiting for AI...',
|
||||
hintStyle: const TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 14,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing16,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
disabledBorder: InputBorder.none,
|
||||
),
|
||||
onSubmitted: isReady
|
||||
? (_) => _sendMessage(controller)
|
||||
: null,
|
||||
textInputAction: TextInputAction.send,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Material(
|
||||
color: (isTyping || !isReady)
|
||||
? AppColors.zinc700
|
||||
: AppColors.accent,
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.borderRadius),
|
||||
child: InkWell(
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.borderRadius),
|
||||
onTap: (isTyping || !isReady)
|
||||
? null
|
||||
: () => _sendMessage(controller),
|
||||
child: Icon(
|
||||
Icons.arrow_upward_rounded,
|
||||
color: (isTyping || !isReady)
|
||||
? AppColors.textMuted
|
||||
: AppColors.zinc950,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -4,6 +4,18 @@ import 'package:trainhub_flutter/domain/entities/chat_message.dart';
|
||||
|
||||
part 'chat_state.freezed.dart';
|
||||
|
||||
enum ThinkingStepStatus { pending, running, completed, error }
|
||||
|
||||
@freezed
|
||||
class ThinkingStep with _$ThinkingStep {
|
||||
const factory ThinkingStep({
|
||||
required String id,
|
||||
required String title,
|
||||
@Default(ThinkingStepStatus.running) ThinkingStepStatus status,
|
||||
String? details,
|
||||
}) = _ThinkingStep;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatState with _$ChatState {
|
||||
const factory ChatState({
|
||||
@@ -11,5 +23,7 @@ class ChatState with _$ChatState {
|
||||
ChatSessionEntity? activeSession,
|
||||
@Default([]) List<ChatMessageEntity> messages,
|
||||
@Default(false) bool isTyping,
|
||||
@Default([]) List<ThinkingStep> thinkingSteps,
|
||||
String? streamingContent,
|
||||
}) = _ChatState;
|
||||
}
|
||||
|
||||
@@ -15,12 +15,219 @@ final _privateConstructorUsedError = UnsupportedError(
|
||||
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
|
||||
);
|
||||
|
||||
/// @nodoc
|
||||
mixin _$ThinkingStep {
|
||||
String get id => throw _privateConstructorUsedError;
|
||||
String get title => throw _privateConstructorUsedError;
|
||||
ThinkingStepStatus get status => throw _privateConstructorUsedError;
|
||||
String? get details => throw _privateConstructorUsedError;
|
||||
|
||||
/// Create a copy of ThinkingStep
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
$ThinkingStepCopyWith<ThinkingStep> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $ThinkingStepCopyWith<$Res> {
|
||||
factory $ThinkingStepCopyWith(
|
||||
ThinkingStep value,
|
||||
$Res Function(ThinkingStep) then,
|
||||
) = _$ThinkingStepCopyWithImpl<$Res, ThinkingStep>;
|
||||
@useResult
|
||||
$Res call({
|
||||
String id,
|
||||
String title,
|
||||
ThinkingStepStatus status,
|
||||
String? details,
|
||||
});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$ThinkingStepCopyWithImpl<$Res, $Val extends ThinkingStep>
|
||||
implements $ThinkingStepCopyWith<$Res> {
|
||||
_$ThinkingStepCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
/// Create a copy of ThinkingStep
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? title = null,
|
||||
Object? status = null,
|
||||
Object? details = freezed,
|
||||
}) {
|
||||
return _then(
|
||||
_value.copyWith(
|
||||
id: null == id
|
||||
? _value.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
title: null == title
|
||||
? _value.title
|
||||
: title // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
status: null == status
|
||||
? _value.status
|
||||
: status // ignore: cast_nullable_to_non_nullable
|
||||
as ThinkingStepStatus,
|
||||
details: freezed == details
|
||||
? _value.details
|
||||
: details // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
)
|
||||
as $Val,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$ThinkingStepImplCopyWith<$Res>
|
||||
implements $ThinkingStepCopyWith<$Res> {
|
||||
factory _$$ThinkingStepImplCopyWith(
|
||||
_$ThinkingStepImpl value,
|
||||
$Res Function(_$ThinkingStepImpl) then,
|
||||
) = __$$ThinkingStepImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call({
|
||||
String id,
|
||||
String title,
|
||||
ThinkingStepStatus status,
|
||||
String? details,
|
||||
});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$ThinkingStepImplCopyWithImpl<$Res>
|
||||
extends _$ThinkingStepCopyWithImpl<$Res, _$ThinkingStepImpl>
|
||||
implements _$$ThinkingStepImplCopyWith<$Res> {
|
||||
__$$ThinkingStepImplCopyWithImpl(
|
||||
_$ThinkingStepImpl _value,
|
||||
$Res Function(_$ThinkingStepImpl) _then,
|
||||
) : super(_value, _then);
|
||||
|
||||
/// Create a copy of ThinkingStep
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? title = null,
|
||||
Object? status = null,
|
||||
Object? details = freezed,
|
||||
}) {
|
||||
return _then(
|
||||
_$ThinkingStepImpl(
|
||||
id: null == id
|
||||
? _value.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
title: null == title
|
||||
? _value.title
|
||||
: title // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
status: null == status
|
||||
? _value.status
|
||||
: status // ignore: cast_nullable_to_non_nullable
|
||||
as ThinkingStepStatus,
|
||||
details: freezed == details
|
||||
? _value.details
|
||||
: details // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
class _$ThinkingStepImpl implements _ThinkingStep {
|
||||
const _$ThinkingStepImpl({
|
||||
required this.id,
|
||||
required this.title,
|
||||
this.status = ThinkingStepStatus.running,
|
||||
this.details,
|
||||
});
|
||||
|
||||
@override
|
||||
final String id;
|
||||
@override
|
||||
final String title;
|
||||
@override
|
||||
@JsonKey()
|
||||
final ThinkingStepStatus status;
|
||||
@override
|
||||
final String? details;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ThinkingStep(id: $id, title: $title, status: $status, details: $details)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$ThinkingStepImpl &&
|
||||
(identical(other.id, id) || other.id == id) &&
|
||||
(identical(other.title, title) || other.title == title) &&
|
||||
(identical(other.status, status) || other.status == status) &&
|
||||
(identical(other.details, details) || other.details == details));
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType, id, title, status, details);
|
||||
|
||||
/// Create a copy of ThinkingStep
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$ThinkingStepImplCopyWith<_$ThinkingStepImpl> get copyWith =>
|
||||
__$$ThinkingStepImplCopyWithImpl<_$ThinkingStepImpl>(this, _$identity);
|
||||
}
|
||||
|
||||
abstract class _ThinkingStep implements ThinkingStep {
|
||||
const factory _ThinkingStep({
|
||||
required final String id,
|
||||
required final String title,
|
||||
final ThinkingStepStatus status,
|
||||
final String? details,
|
||||
}) = _$ThinkingStepImpl;
|
||||
|
||||
@override
|
||||
String get id;
|
||||
@override
|
||||
String get title;
|
||||
@override
|
||||
ThinkingStepStatus get status;
|
||||
@override
|
||||
String? get details;
|
||||
|
||||
/// Create a copy of ThinkingStep
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
_$$ThinkingStepImplCopyWith<_$ThinkingStepImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
mixin _$ChatState {
|
||||
List<ChatSessionEntity> get sessions => throw _privateConstructorUsedError;
|
||||
ChatSessionEntity? get activeSession => throw _privateConstructorUsedError;
|
||||
List<ChatMessageEntity> get messages => throw _privateConstructorUsedError;
|
||||
bool get isTyping => throw _privateConstructorUsedError;
|
||||
List<ThinkingStep> get thinkingSteps => throw _privateConstructorUsedError;
|
||||
String? get streamingContent => throw _privateConstructorUsedError;
|
||||
|
||||
/// Create a copy of ChatState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@@ -39,6 +246,8 @@ abstract class $ChatStateCopyWith<$Res> {
|
||||
ChatSessionEntity? activeSession,
|
||||
List<ChatMessageEntity> messages,
|
||||
bool isTyping,
|
||||
List<ThinkingStep> thinkingSteps,
|
||||
String? streamingContent,
|
||||
});
|
||||
|
||||
$ChatSessionEntityCopyWith<$Res>? get activeSession;
|
||||
@@ -63,6 +272,8 @@ class _$ChatStateCopyWithImpl<$Res, $Val extends ChatState>
|
||||
Object? activeSession = freezed,
|
||||
Object? messages = null,
|
||||
Object? isTyping = null,
|
||||
Object? thinkingSteps = null,
|
||||
Object? streamingContent = freezed,
|
||||
}) {
|
||||
return _then(
|
||||
_value.copyWith(
|
||||
@@ -82,6 +293,14 @@ class _$ChatStateCopyWithImpl<$Res, $Val extends ChatState>
|
||||
? _value.isTyping
|
||||
: isTyping // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
thinkingSteps: null == thinkingSteps
|
||||
? _value.thinkingSteps
|
||||
: thinkingSteps // ignore: cast_nullable_to_non_nullable
|
||||
as List<ThinkingStep>,
|
||||
streamingContent: freezed == streamingContent
|
||||
? _value.streamingContent
|
||||
: streamingContent // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
)
|
||||
as $Val,
|
||||
);
|
||||
@@ -116,6 +335,8 @@ abstract class _$$ChatStateImplCopyWith<$Res>
|
||||
ChatSessionEntity? activeSession,
|
||||
List<ChatMessageEntity> messages,
|
||||
bool isTyping,
|
||||
List<ThinkingStep> thinkingSteps,
|
||||
String? streamingContent,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -140,6 +361,8 @@ class __$$ChatStateImplCopyWithImpl<$Res>
|
||||
Object? activeSession = freezed,
|
||||
Object? messages = null,
|
||||
Object? isTyping = null,
|
||||
Object? thinkingSteps = null,
|
||||
Object? streamingContent = freezed,
|
||||
}) {
|
||||
return _then(
|
||||
_$ChatStateImpl(
|
||||
@@ -159,6 +382,14 @@ class __$$ChatStateImplCopyWithImpl<$Res>
|
||||
? _value.isTyping
|
||||
: isTyping // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
thinkingSteps: null == thinkingSteps
|
||||
? _value._thinkingSteps
|
||||
: thinkingSteps // ignore: cast_nullable_to_non_nullable
|
||||
as List<ThinkingStep>,
|
||||
streamingContent: freezed == streamingContent
|
||||
? _value.streamingContent
|
||||
: streamingContent // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -172,8 +403,11 @@ class _$ChatStateImpl implements _ChatState {
|
||||
this.activeSession,
|
||||
final List<ChatMessageEntity> messages = const [],
|
||||
this.isTyping = false,
|
||||
final List<ThinkingStep> thinkingSteps = const [],
|
||||
this.streamingContent,
|
||||
}) : _sessions = sessions,
|
||||
_messages = messages;
|
||||
_messages = messages,
|
||||
_thinkingSteps = thinkingSteps;
|
||||
|
||||
final List<ChatSessionEntity> _sessions;
|
||||
@override
|
||||
@@ -198,10 +432,21 @@ class _$ChatStateImpl implements _ChatState {
|
||||
@override
|
||||
@JsonKey()
|
||||
final bool isTyping;
|
||||
final List<ThinkingStep> _thinkingSteps;
|
||||
@override
|
||||
@JsonKey()
|
||||
List<ThinkingStep> get thinkingSteps {
|
||||
if (_thinkingSteps is EqualUnmodifiableListView) return _thinkingSteps;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_thinkingSteps);
|
||||
}
|
||||
|
||||
@override
|
||||
final String? streamingContent;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ChatState(sessions: $sessions, activeSession: $activeSession, messages: $messages, isTyping: $isTyping)';
|
||||
return 'ChatState(sessions: $sessions, activeSession: $activeSession, messages: $messages, isTyping: $isTyping, thinkingSteps: $thinkingSteps, streamingContent: $streamingContent)';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -214,7 +459,13 @@ class _$ChatStateImpl implements _ChatState {
|
||||
other.activeSession == activeSession) &&
|
||||
const DeepCollectionEquality().equals(other._messages, _messages) &&
|
||||
(identical(other.isTyping, isTyping) ||
|
||||
other.isTyping == isTyping));
|
||||
other.isTyping == isTyping) &&
|
||||
const DeepCollectionEquality().equals(
|
||||
other._thinkingSteps,
|
||||
_thinkingSteps,
|
||||
) &&
|
||||
(identical(other.streamingContent, streamingContent) ||
|
||||
other.streamingContent == streamingContent));
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -224,6 +475,8 @@ class _$ChatStateImpl implements _ChatState {
|
||||
activeSession,
|
||||
const DeepCollectionEquality().hash(_messages),
|
||||
isTyping,
|
||||
const DeepCollectionEquality().hash(_thinkingSteps),
|
||||
streamingContent,
|
||||
);
|
||||
|
||||
/// Create a copy of ChatState
|
||||
@@ -241,6 +494,8 @@ abstract class _ChatState implements ChatState {
|
||||
final ChatSessionEntity? activeSession,
|
||||
final List<ChatMessageEntity> messages,
|
||||
final bool isTyping,
|
||||
final List<ThinkingStep> thinkingSteps,
|
||||
final String? streamingContent,
|
||||
}) = _$ChatStateImpl;
|
||||
|
||||
@override
|
||||
@@ -251,6 +506,10 @@ abstract class _ChatState implements ChatState {
|
||||
List<ChatMessageEntity> get messages;
|
||||
@override
|
||||
bool get isTyping;
|
||||
@override
|
||||
List<ThinkingStep> get thinkingSteps;
|
||||
@override
|
||||
String? get streamingContent;
|
||||
|
||||
/// Create a copy of ChatState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
|
||||
121
lib/presentation/chat/widgets/message_bubble.dart
Normal file
121
lib/presentation/chat/widgets/message_bubble.dart
Normal file
@@ -0,0 +1,121 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/chat_message.dart';
|
||||
|
||||
class MessageBubble extends StatelessWidget {
|
||||
const MessageBubble({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.formattedTime,
|
||||
});
|
||||
|
||||
final ChatMessageEntity message;
|
||||
final String formattedTime;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isUser = message.isUser;
|
||||
final maxWidth = MediaQuery.of(context).size.width * 0.55;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UIConstants.spacing12),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
isUser ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isUser) ...[
|
||||
_buildAvatar(
|
||||
Icons.auto_awesome_rounded,
|
||||
AppColors.surfaceContainerHigh,
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
],
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing16,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isUser
|
||||
? AppColors.zinc700
|
||||
: AppColors.surfaceContainer,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(16),
|
||||
topRight: const Radius.circular(16),
|
||||
bottomLeft: isUser
|
||||
? const Radius.circular(16)
|
||||
: const Radius.circular(4),
|
||||
bottomRight: isUser
|
||||
? const Radius.circular(4)
|
||||
: const Radius.circular(16),
|
||||
),
|
||||
border: isUser
|
||||
? null
|
||||
: Border.all(color: AppColors.border, width: 1),
|
||||
),
|
||||
child: isUser
|
||||
? SelectableText(
|
||||
message.content,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 14,
|
||||
height: 1.5,
|
||||
),
|
||||
)
|
||||
: MarkdownBody(
|
||||
data: message.content,
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
p: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 14,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text(
|
||||
formattedTime,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isUser) ...[
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
_buildAvatar(
|
||||
Icons.person_rounded,
|
||||
AppColors.accent.withValues(alpha: 0.15),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatar(IconData icon, Color backgroundColor) {
|
||||
return Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(icon, size: 14, color: AppColors.accent),
|
||||
);
|
||||
}
|
||||
}
|
||||
115
lib/presentation/chat/widgets/missing_models_state.dart
Normal file
115
lib/presentation/chat/widgets/missing_models_state.dart
Normal file
@@ -0,0 +1,115 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/router/app_router.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
|
||||
class MissingModelsState extends StatelessWidget {
|
||||
const MissingModelsState({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.cloud_download_outlined,
|
||||
size: 36,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing24),
|
||||
Text(
|
||||
'AI models are missing.',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing8),
|
||||
Text(
|
||||
'Please download them to use the AI chat.',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing32),
|
||||
const _GoToSettingsButton(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GoToSettingsButton extends StatefulWidget {
|
||||
const _GoToSettingsButton();
|
||||
|
||||
@override
|
||||
State<_GoToSettingsButton> createState() => _GoToSettingsButtonState();
|
||||
}
|
||||
|
||||
class _GoToSettingsButtonState extends State<_GoToSettingsButton> {
|
||||
bool _isHovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _isHovered = true),
|
||||
onExit: (_) => setState(() => _isHovered = false),
|
||||
child: AnimatedContainer(
|
||||
duration: UIConstants.animationDuration,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: _isHovered
|
||||
? AppColors.accent.withValues(alpha: 0.85)
|
||||
: AppColors.accent,
|
||||
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
onTap: () => context.router.push(const SettingsRoute()),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing24,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.settings_outlined,
|
||||
size: 16,
|
||||
color: AppColors.zinc950,
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
Text(
|
||||
'Go to Settings',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.zinc950,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
67
lib/presentation/chat/widgets/new_chat_button.dart
Normal file
67
lib/presentation/chat/widgets/new_chat_button.dart
Normal file
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
|
||||
class NewChatButton extends StatefulWidget {
|
||||
const NewChatButton({super.key, required this.onPressed});
|
||||
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
State<NewChatButton> createState() => _NewChatButtonState();
|
||||
}
|
||||
|
||||
class _NewChatButtonState extends State<NewChatButton> {
|
||||
bool _isHovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _isHovered = true),
|
||||
onExit: (_) => setState(() => _isHovered = false),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
decoration: BoxDecoration(
|
||||
color: _isHovered
|
||||
? AppColors.zinc700
|
||||
: AppColors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
border: Border.all(color: AppColors.border, width: 1),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
onTap: widget.onPressed,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing12,
|
||||
vertical: UIConstants.spacing8,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.add_rounded,
|
||||
size: 16,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
SizedBox(width: UIConstants.spacing8),
|
||||
Text(
|
||||
'New Chat',
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
180
lib/presentation/chat/widgets/thinking_block.dart
Normal file
180
lib/presentation/chat/widgets/thinking_block.dart
Normal file
@@ -0,0 +1,180 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
import 'package:trainhub_flutter/presentation/chat/chat_state.dart';
|
||||
|
||||
class ThinkingBlock extends StatefulWidget {
|
||||
const ThinkingBlock({super.key, required this.steps});
|
||||
|
||||
final List<ThinkingStep> steps;
|
||||
|
||||
@override
|
||||
State<ThinkingBlock> createState() => _ThinkingBlockState();
|
||||
}
|
||||
|
||||
class _ThinkingBlockState extends State<ThinkingBlock> {
|
||||
bool _isExpanded = true;
|
||||
|
||||
Color _getStatusColor(ThinkingStepStatus status) {
|
||||
switch (status) {
|
||||
case ThinkingStepStatus.running:
|
||||
return AppColors.info;
|
||||
case ThinkingStepStatus.completed:
|
||||
return AppColors.success;
|
||||
case ThinkingStepStatus.error:
|
||||
return AppColors.destructive;
|
||||
case ThinkingStepStatus.pending:
|
||||
return AppColors.textMuted;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildStatusIcon(ThinkingStepStatus status) {
|
||||
if (status == ThinkingStepStatus.running) {
|
||||
return const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.info,
|
||||
),
|
||||
);
|
||||
}
|
||||
final IconData icon;
|
||||
switch (status) {
|
||||
case ThinkingStepStatus.completed:
|
||||
icon = Icons.check_circle_rounded;
|
||||
case ThinkingStepStatus.error:
|
||||
icon = Icons.error_outline_rounded;
|
||||
default:
|
||||
icon = Icons.circle_outlined;
|
||||
}
|
||||
return Icon(icon, size: 14, color: _getStatusColor(status));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
border: Border.all(color: AppColors.border, width: 1),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
onTap: () => setState(() => _isExpanded = !_isExpanded),
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.settings_suggest_rounded,
|
||||
size: 16,
|
||||
color: AppColors.warning,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Assistant actions',
|
||||
style: TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'(${widget.steps.length} steps)',
|
||||
style: const TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
_isExpanded
|
||||
? Icons.keyboard_arrow_up
|
||||
: Icons.keyboard_arrow_down,
|
||||
size: 16,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isExpanded)
|
||||
Container(
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(color: AppColors.border, width: 1),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
children: widget.steps.map(_buildStepRow).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStepRow(ThinkingStep step) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: _buildStatusIcon(step.status),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
step.title,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (step.details != null && step.details!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border:
|
||||
Border.all(color: AppColors.border, width: 1),
|
||||
),
|
||||
child: Text(
|
||||
step.details!,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
89
lib/presentation/chat/widgets/typing_bubble.dart
Normal file
89
lib/presentation/chat/widgets/typing_bubble.dart
Normal file
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
import 'package:trainhub_flutter/presentation/chat/chat_state.dart';
|
||||
import 'package:trainhub_flutter/presentation/chat/widgets/thinking_block.dart';
|
||||
import 'package:trainhub_flutter/presentation/chat/widgets/typing_indicator.dart';
|
||||
|
||||
class TypingBubble extends StatelessWidget {
|
||||
const TypingBubble({
|
||||
super.key,
|
||||
required this.thinkingSteps,
|
||||
this.streamingContent,
|
||||
});
|
||||
|
||||
final List<ThinkingStep> thinkingSteps;
|
||||
final String? streamingContent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maxWidth = MediaQuery.of(context).size.width * 0.55;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UIConstants.spacing12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.auto_awesome_rounded,
|
||||
size: 14,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (thinkingSteps.isNotEmpty)
|
||||
Container(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ThinkingBlock(steps: thinkingSteps),
|
||||
),
|
||||
if (streamingContent != null && streamingContent!.isNotEmpty)
|
||||
Container(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing16,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
bottomLeft: Radius.circular(4),
|
||||
bottomRight: Radius.circular(16),
|
||||
),
|
||||
border:
|
||||
Border.all(color: AppColors.border, width: 1),
|
||||
),
|
||||
child: MarkdownBody(
|
||||
data: streamingContent!,
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
p: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 14,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
const TypingIndicator(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
111
lib/presentation/chat/widgets/typing_indicator.dart
Normal file
111
lib/presentation/chat/widgets/typing_indicator.dart
Normal file
@@ -0,0 +1,111 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
|
||||
class TypingIndicator extends StatefulWidget {
|
||||
const TypingIndicator({super.key});
|
||||
|
||||
@override
|
||||
State<TypingIndicator> createState() => _TypingIndicatorState();
|
||||
}
|
||||
|
||||
class _TypingIndicatorState extends State<TypingIndicator>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1200),
|
||||
)..repeat();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UIConstants.spacing12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.auto_awesome_rounded,
|
||||
size: 14,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing16,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
bottomLeft: Radius.circular(4),
|
||||
bottomRight: Radius.circular(16),
|
||||
),
|
||||
border: Border.all(color: AppColors.border, width: 1),
|
||||
),
|
||||
child: AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, _) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(3, (index) {
|
||||
final delay = index * 0.2;
|
||||
final t = (_controller.value - delay) % 1.0;
|
||||
final bounce =
|
||||
t < 0.5 ? math.sin(t * math.pi * 2) * 4.0 : 0.0;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(left: index == 0 ? 0 : 4),
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, -bounce.abs()),
|
||||
child: const _Dot(),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Dot extends StatelessWidget {
|
||||
const _Dot();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 7,
|
||||
height: 7,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.textMuted,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/program_workout.dart';
|
||||
import 'package:trainhub_flutter/presentation/common/widgets/app_empty_state.dart';
|
||||
import 'package:trainhub_flutter/presentation/common/widgets/app_stat_card.dart';
|
||||
import 'package:trainhub_flutter/presentation/home/home_controller.dart';
|
||||
import 'package:trainhub_flutter/presentation/home/home_state.dart';
|
||||
import 'package:trainhub_flutter/presentation/home/widgets/next_workout_banner.dart';
|
||||
import 'package:trainhub_flutter/presentation/home/widgets/quick_actions_row.dart';
|
||||
import 'package:trainhub_flutter/presentation/home/widgets/recent_activity_section.dart';
|
||||
import 'package:trainhub_flutter/presentation/home/widgets/stat_cards_row.dart';
|
||||
import 'package:trainhub_flutter/presentation/home/widgets/welcome_header.dart';
|
||||
|
||||
@RoutePage()
|
||||
class HomePage extends ConsumerWidget {
|
||||
@@ -20,45 +23,9 @@ class HomePage extends ConsumerWidget {
|
||||
|
||||
return asyncState.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UIConstants.pagePadding),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 48,
|
||||
color: AppColors.destructive,
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing16),
|
||||
Text(
|
||||
'Something went wrong',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing8),
|
||||
Text(
|
||||
'$e',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing24),
|
||||
FilledButton.icon(
|
||||
onPressed: () =>
|
||||
ref.read(homeControllerProvider.notifier).refresh(),
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
label: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
error: (e, _) => _ErrorView(
|
||||
error: e,
|
||||
onRetry: () => ref.read(homeControllerProvider.notifier).refresh(),
|
||||
),
|
||||
data: (data) {
|
||||
if (data.activeProgramName == null) {
|
||||
@@ -68,9 +35,7 @@ class HomePage extends ConsumerWidget {
|
||||
subtitle:
|
||||
'Head to Calendar to create or select a training program to get started.',
|
||||
actionLabel: 'Go to Calendar',
|
||||
onAction: () {
|
||||
AutoTabsRouter.of(context).setActiveIndex(3);
|
||||
},
|
||||
onAction: () => AutoTabsRouter.of(context).setActiveIndex(3),
|
||||
);
|
||||
}
|
||||
return _HomeContent(data: data);
|
||||
@@ -79,11 +44,61 @@ class HomePage extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _HomeContent extends StatelessWidget {
|
||||
final HomeState data;
|
||||
class _ErrorView extends StatelessWidget {
|
||||
const _ErrorView({required this.error, required this.onRetry});
|
||||
|
||||
final Object error;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UIConstants.pagePadding),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 48,
|
||||
color: AppColors.destructive,
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing16),
|
||||
Text(
|
||||
'Something went wrong',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing8),
|
||||
Text(
|
||||
'$error',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing24),
|
||||
FilledButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
label: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HomeContent extends StatelessWidget {
|
||||
const _HomeContent({required this.data});
|
||||
|
||||
final HomeState data;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
@@ -91,609 +106,22 @@ class _HomeContent extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// -- Welcome header --
|
||||
_WelcomeHeader(programName: data.activeProgramName!),
|
||||
WelcomeHeader(programName: data.activeProgramName!),
|
||||
const SizedBox(height: UIConstants.spacing24),
|
||||
|
||||
// -- Stat cards row --
|
||||
_StatCardsRow(
|
||||
StatCardsRow(
|
||||
completed: data.completedWorkouts,
|
||||
total: data.totalWorkouts,
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing24),
|
||||
|
||||
// -- Next workout banner --
|
||||
if (data.nextWorkoutName != null) ...[
|
||||
_NextWorkoutBanner(workoutName: data.nextWorkoutName!),
|
||||
NextWorkoutBanner(workoutName: data.nextWorkoutName!),
|
||||
const SizedBox(height: UIConstants.spacing24),
|
||||
],
|
||||
|
||||
// -- Quick actions --
|
||||
_QuickActionsRow(),
|
||||
const QuickActionsRow(),
|
||||
const SizedBox(height: UIConstants.spacing32),
|
||||
|
||||
// -- Recent activity --
|
||||
_RecentActivitySection(activity: data.recentActivity),
|
||||
RecentActivitySection(activity: data.recentActivity),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Welcome header
|
||||
// ---------------------------------------------------------------------------
|
||||
class _WelcomeHeader extends StatelessWidget {
|
||||
final String programName;
|
||||
|
||||
const _WelcomeHeader({required this.programName});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Welcome back',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing4),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
programName,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.accentMuted,
|
||||
borderRadius: UIConstants.smallCardBorderRadius,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.fitness_center,
|
||||
size: 14,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Active Program',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stat cards row
|
||||
// ---------------------------------------------------------------------------
|
||||
class _StatCardsRow extends StatelessWidget {
|
||||
final int completed;
|
||||
final int total;
|
||||
|
||||
const _StatCardsRow({required this.completed, required this.total});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final progress = total == 0 ? 0 : (completed / total * 100).round();
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AppStatCard(
|
||||
title: 'Completed',
|
||||
value: '$completed',
|
||||
icon: Icons.check_circle_outline,
|
||||
accentColor: AppColors.success,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing16),
|
||||
Expanded(
|
||||
child: AppStatCard(
|
||||
title: 'Total Workouts',
|
||||
value: '$total',
|
||||
icon: Icons.list_alt,
|
||||
accentColor: AppColors.info,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing16),
|
||||
Expanded(
|
||||
child: AppStatCard(
|
||||
title: 'Progress',
|
||||
value: '$progress%',
|
||||
icon: Icons.trending_up,
|
||||
accentColor: AppColors.purple,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Next workout banner
|
||||
// ---------------------------------------------------------------------------
|
||||
class _NextWorkoutBanner extends StatelessWidget {
|
||||
final String workoutName;
|
||||
|
||||
const _NextWorkoutBanner({required this.workoutName});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(UIConstants.cardPadding),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
borderRadius: UIConstants.cardBorderRadius,
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.accentMuted,
|
||||
borderRadius: UIConstants.smallCardBorderRadius,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
color: AppColors.accent,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Up Next',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
workoutName,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: AppColors.textMuted,
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Quick actions row
|
||||
// ---------------------------------------------------------------------------
|
||||
class _QuickActionsRow extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Quick Actions',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing12),
|
||||
Row(
|
||||
children: [
|
||||
_QuickActionButton(
|
||||
icon: Icons.play_arrow_rounded,
|
||||
label: 'New Workout',
|
||||
color: AppColors.accent,
|
||||
onTap: () {
|
||||
AutoTabsRouter.of(context).setActiveIndex(1);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing12),
|
||||
_QuickActionButton(
|
||||
icon: Icons.description_outlined,
|
||||
label: 'View Plans',
|
||||
color: AppColors.info,
|
||||
onTap: () {
|
||||
AutoTabsRouter.of(context).setActiveIndex(1);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing12),
|
||||
_QuickActionButton(
|
||||
icon: Icons.chat_bubble_outline,
|
||||
label: 'AI Chat',
|
||||
color: AppColors.purple,
|
||||
onTap: () {
|
||||
AutoTabsRouter.of(context).setActiveIndex(4);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QuickActionButton extends StatefulWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _QuickActionButton({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_QuickActionButton> createState() => _QuickActionButtonState();
|
||||
}
|
||||
|
||||
class _QuickActionButtonState extends State<_QuickActionButton> {
|
||||
bool _isHovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _isHovered = true),
|
||||
onExit: (_) => setState(() => _isHovered = false),
|
||||
child: AnimatedContainer(
|
||||
duration: UIConstants.animationDuration,
|
||||
decoration: BoxDecoration(
|
||||
color: _isHovered
|
||||
? widget.color.withValues(alpha: 0.08)
|
||||
: Colors.transparent,
|
||||
borderRadius: UIConstants.smallCardBorderRadius,
|
||||
border: Border.all(
|
||||
color: _isHovered ? widget.color.withValues(alpha: 0.4) : AppColors.border,
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: widget.onTap,
|
||||
borderRadius: UIConstants.smallCardBorderRadius,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing24,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
widget.icon,
|
||||
size: 18,
|
||||
color: widget.color,
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
Text(
|
||||
widget.label,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: _isHovered
|
||||
? widget.color
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recent activity section
|
||||
// ---------------------------------------------------------------------------
|
||||
class _RecentActivitySection extends StatelessWidget {
|
||||
final List<ProgramWorkoutEntity> activity;
|
||||
|
||||
const _RecentActivitySection({required this.activity});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Recent Activity',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (activity.isNotEmpty)
|
||||
Text(
|
||||
'${activity.length} workout${activity.length == 1 ? '' : 's'}',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing12),
|
||||
if (activity.isEmpty)
|
||||
_EmptyActivity()
|
||||
else
|
||||
_ActivityList(activity: activity),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyActivity extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 40,
|
||||
horizontal: UIConstants.spacing24,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
borderRadius: UIConstants.cardBorderRadius,
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.history,
|
||||
size: 32,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing12),
|
||||
Text(
|
||||
'No completed workouts yet',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing4),
|
||||
Text(
|
||||
'Your recent workout history will appear here.',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActivityList extends StatelessWidget {
|
||||
final List<ProgramWorkoutEntity> activity;
|
||||
|
||||
const _ActivityList({required this.activity});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
borderRadius: UIConstants.cardBorderRadius,
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: UIConstants.cardBorderRadius,
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 0; i < activity.length; i++) ...[
|
||||
if (i > 0)
|
||||
const Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: AppColors.border,
|
||||
),
|
||||
_ActivityItem(workout: activity[i]),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActivityItem extends StatefulWidget {
|
||||
final ProgramWorkoutEntity workout;
|
||||
|
||||
const _ActivityItem({required this.workout});
|
||||
|
||||
@override
|
||||
State<_ActivityItem> createState() => _ActivityItemState();
|
||||
}
|
||||
|
||||
class _ActivityItemState extends State<_ActivityItem> {
|
||||
bool _isHovered = false;
|
||||
|
||||
Color get _typeColor {
|
||||
switch (widget.workout.type.toLowerCase()) {
|
||||
case 'strength':
|
||||
return AppColors.accent;
|
||||
case 'cardio':
|
||||
return AppColors.info;
|
||||
case 'flexibility':
|
||||
case 'mobility':
|
||||
return AppColors.purple;
|
||||
case 'rest':
|
||||
return AppColors.textMuted;
|
||||
default:
|
||||
return AppColors.success;
|
||||
}
|
||||
}
|
||||
|
||||
IconData get _typeIcon {
|
||||
switch (widget.workout.type.toLowerCase()) {
|
||||
case 'strength':
|
||||
return Icons.fitness_center;
|
||||
case 'cardio':
|
||||
return Icons.directions_run;
|
||||
case 'flexibility':
|
||||
case 'mobility':
|
||||
return Icons.self_improvement;
|
||||
case 'rest':
|
||||
return Icons.bedtime_outlined;
|
||||
default:
|
||||
return Icons.check_circle;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _isHovered = true),
|
||||
onExit: (_) => setState(() => _isHovered = false),
|
||||
child: AnimatedContainer(
|
||||
duration: UIConstants.animationDuration,
|
||||
color: _isHovered
|
||||
? AppColors.surfaceContainerHigh.withValues(alpha: 0.5)
|
||||
: Colors.transparent,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.cardPadding,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Leading icon with color coding
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.workout.completed
|
||||
? _typeColor.withValues(alpha: 0.15)
|
||||
: AppColors.zinc800,
|
||||
borderRadius: UIConstants.smallCardBorderRadius,
|
||||
),
|
||||
child: Icon(
|
||||
widget.workout.completed ? _typeIcon : Icons.circle_outlined,
|
||||
size: 18,
|
||||
color: widget.workout.completed
|
||||
? _typeColor
|
||||
: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing12),
|
||||
|
||||
// Workout info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.workout.name ?? 'Workout',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Week ${widget.workout.weekId} · Day ${widget.workout.day}',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Type badge
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: _typeColor.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
widget.workout.type,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: _typeColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing12),
|
||||
|
||||
// Status indicator
|
||||
if (widget.workout.completed)
|
||||
const Icon(
|
||||
Icons.check_circle,
|
||||
size: 18,
|
||||
color: AppColors.success,
|
||||
)
|
||||
else
|
||||
const Icon(
|
||||
Icons.radio_button_unchecked,
|
||||
size: 18,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
70
lib/presentation/home/widgets/next_workout_banner.dart
Normal file
70
lib/presentation/home/widgets/next_workout_banner.dart
Normal file
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
|
||||
class NextWorkoutBanner extends StatelessWidget {
|
||||
const NextWorkoutBanner({super.key, required this.workoutName});
|
||||
|
||||
final String workoutName;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(UIConstants.cardPadding),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
borderRadius: UIConstants.cardBorderRadius,
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.accentMuted,
|
||||
borderRadius: UIConstants.smallCardBorderRadius,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
color: AppColors.accent,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Up Next',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
workoutName,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.chevron_right,
|
||||
color: AppColors.textMuted,
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
124
lib/presentation/home/widgets/quick_actions_row.dart
Normal file
124
lib/presentation/home/widgets/quick_actions_row.dart
Normal file
@@ -0,0 +1,124 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
|
||||
class QuickActionsRow extends StatelessWidget {
|
||||
const QuickActionsRow({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Quick Actions',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing12),
|
||||
Row(
|
||||
children: [
|
||||
QuickActionButton(
|
||||
icon: Icons.play_arrow_rounded,
|
||||
label: 'New Workout',
|
||||
color: AppColors.accent,
|
||||
onTap: () => AutoTabsRouter.of(context).setActiveIndex(1),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing12),
|
||||
QuickActionButton(
|
||||
icon: Icons.description_outlined,
|
||||
label: 'View Plans',
|
||||
color: AppColors.info,
|
||||
onTap: () => AutoTabsRouter.of(context).setActiveIndex(1),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing12),
|
||||
QuickActionButton(
|
||||
icon: Icons.chat_bubble_outline,
|
||||
label: 'AI Chat',
|
||||
color: AppColors.purple,
|
||||
onTap: () => AutoTabsRouter.of(context).setActiveIndex(4),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class QuickActionButton extends StatefulWidget {
|
||||
const QuickActionButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
State<QuickActionButton> createState() => _QuickActionButtonState();
|
||||
}
|
||||
|
||||
class _QuickActionButtonState extends State<QuickActionButton> {
|
||||
bool _isHovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _isHovered = true),
|
||||
onExit: (_) => setState(() => _isHovered = false),
|
||||
child: AnimatedContainer(
|
||||
duration: UIConstants.animationDuration,
|
||||
decoration: BoxDecoration(
|
||||
color: _isHovered
|
||||
? widget.color.withValues(alpha: 0.08)
|
||||
: Colors.transparent,
|
||||
borderRadius: UIConstants.smallCardBorderRadius,
|
||||
border: Border.all(
|
||||
color: _isHovered
|
||||
? widget.color.withValues(alpha: 0.4)
|
||||
: AppColors.border,
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: widget.onTap,
|
||||
borderRadius: UIConstants.smallCardBorderRadius,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing24,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(widget.icon, size: 18, color: widget.color),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
Text(
|
||||
widget.label,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color:
|
||||
_isHovered ? widget.color : AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
272
lib/presentation/home/widgets/recent_activity_section.dart
Normal file
272
lib/presentation/home/widgets/recent_activity_section.dart
Normal file
@@ -0,0 +1,272 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/program_workout.dart';
|
||||
|
||||
class RecentActivitySection extends StatelessWidget {
|
||||
const RecentActivitySection({super.key, required this.activity});
|
||||
|
||||
final List<ProgramWorkoutEntity> activity;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Recent Activity',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (activity.isNotEmpty)
|
||||
Text(
|
||||
'${activity.length} workout${activity.length == 1 ? '' : 's'}',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing12),
|
||||
if (activity.isEmpty)
|
||||
const _EmptyActivity()
|
||||
else
|
||||
_ActivityList(activity: activity),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyActivity extends StatelessWidget {
|
||||
const _EmptyActivity();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 40,
|
||||
horizontal: UIConstants.spacing24,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
borderRadius: UIConstants.cardBorderRadius,
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(Icons.history, size: 32, color: AppColors.textMuted),
|
||||
const SizedBox(height: UIConstants.spacing12),
|
||||
Text(
|
||||
'No completed workouts yet',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing4),
|
||||
Text(
|
||||
'Your recent workout history will appear here.',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActivityList extends StatelessWidget {
|
||||
const _ActivityList({required this.activity});
|
||||
|
||||
final List<ProgramWorkoutEntity> activity;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
borderRadius: UIConstants.cardBorderRadius,
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: UIConstants.cardBorderRadius,
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 0; i < activity.length; i++) ...[
|
||||
if (i > 0)
|
||||
const Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: AppColors.border,
|
||||
),
|
||||
_ActivityItem(workout: activity[i]),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActivityItem extends StatefulWidget {
|
||||
const _ActivityItem({required this.workout});
|
||||
|
||||
final ProgramWorkoutEntity workout;
|
||||
|
||||
@override
|
||||
State<_ActivityItem> createState() => _ActivityItemState();
|
||||
}
|
||||
|
||||
class _ActivityItemState extends State<_ActivityItem> {
|
||||
bool _isHovered = false;
|
||||
|
||||
Color get _typeColor {
|
||||
switch (widget.workout.type.toLowerCase()) {
|
||||
case 'strength':
|
||||
return AppColors.accent;
|
||||
case 'cardio':
|
||||
return AppColors.info;
|
||||
case 'flexibility':
|
||||
case 'mobility':
|
||||
return AppColors.purple;
|
||||
case 'rest':
|
||||
return AppColors.textMuted;
|
||||
default:
|
||||
return AppColors.success;
|
||||
}
|
||||
}
|
||||
|
||||
IconData get _typeIcon {
|
||||
switch (widget.workout.type.toLowerCase()) {
|
||||
case 'strength':
|
||||
return Icons.fitness_center;
|
||||
case 'cardio':
|
||||
return Icons.directions_run;
|
||||
case 'flexibility':
|
||||
case 'mobility':
|
||||
return Icons.self_improvement;
|
||||
case 'rest':
|
||||
return Icons.bedtime_outlined;
|
||||
default:
|
||||
return Icons.check_circle;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _isHovered = true),
|
||||
onExit: (_) => setState(() => _isHovered = false),
|
||||
child: AnimatedContainer(
|
||||
duration: UIConstants.animationDuration,
|
||||
color: _isHovered
|
||||
? AppColors.surfaceContainerHigh.withValues(alpha: 0.5)
|
||||
: Colors.transparent,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.cardPadding,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_buildLeadingIcon(),
|
||||
const SizedBox(width: UIConstants.spacing12),
|
||||
_buildWorkoutInfo(),
|
||||
_buildTypeBadge(),
|
||||
const SizedBox(width: UIConstants.spacing12),
|
||||
_buildStatusIcon(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLeadingIcon() {
|
||||
return Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.workout.completed
|
||||
? _typeColor.withValues(alpha: 0.15)
|
||||
: AppColors.zinc800,
|
||||
borderRadius: UIConstants.smallCardBorderRadius,
|
||||
),
|
||||
child: Icon(
|
||||
widget.workout.completed ? _typeIcon : Icons.circle_outlined,
|
||||
size: 18,
|
||||
color: widget.workout.completed ? _typeColor : AppColors.textMuted,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWorkoutInfo() {
|
||||
return Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.workout.name ?? 'Workout',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Week ${widget.workout.weekId} · Day ${widget.workout.day}',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTypeBadge() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: _typeColor.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
widget.workout.type,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: _typeColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusIcon() {
|
||||
if (widget.workout.completed) {
|
||||
return const Icon(
|
||||
Icons.check_circle,
|
||||
size: 18,
|
||||
color: AppColors.success,
|
||||
);
|
||||
}
|
||||
return const Icon(
|
||||
Icons.radio_button_unchecked,
|
||||
size: 18,
|
||||
color: AppColors.textMuted,
|
||||
);
|
||||
}
|
||||
}
|
||||
51
lib/presentation/home/widgets/stat_cards_row.dart
Normal file
51
lib/presentation/home/widgets/stat_cards_row.dart
Normal file
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
import 'package:trainhub_flutter/presentation/common/widgets/app_stat_card.dart';
|
||||
|
||||
class StatCardsRow extends StatelessWidget {
|
||||
const StatCardsRow({
|
||||
super.key,
|
||||
required this.completed,
|
||||
required this.total,
|
||||
});
|
||||
|
||||
final int completed;
|
||||
final int total;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final progress = total == 0 ? 0 : (completed / total * 100).round();
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AppStatCard(
|
||||
title: 'Completed',
|
||||
value: '$completed',
|
||||
icon: Icons.check_circle_outline,
|
||||
accentColor: AppColors.success,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing16),
|
||||
Expanded(
|
||||
child: AppStatCard(
|
||||
title: 'Total Workouts',
|
||||
value: '$total',
|
||||
icon: Icons.list_alt,
|
||||
accentColor: AppColors.info,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing16),
|
||||
Expanded(
|
||||
child: AppStatCard(
|
||||
title: 'Progress',
|
||||
value: '$progress%',
|
||||
icon: Icons.trending_up,
|
||||
accentColor: AppColors.purple,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
71
lib/presentation/home/widgets/welcome_header.dart
Normal file
71
lib/presentation/home/widgets/welcome_header.dart
Normal file
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
|
||||
class WelcomeHeader extends StatelessWidget {
|
||||
const WelcomeHeader({super.key, required this.programName});
|
||||
|
||||
final String programName;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Welcome back',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing4),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
programName,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.accentMuted,
|
||||
borderRadius: UIConstants.smallCardBorderRadius,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.fitness_center,
|
||||
size: 14,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Active Program',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,56 +5,28 @@ import 'package:dio/dio.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ai_constants.dart';
|
||||
import 'package:trainhub_flutter/data/services/ai_process_manager.dart';
|
||||
import 'package:trainhub_flutter/injection.dart' as di;
|
||||
import 'package:trainhub_flutter/presentation/settings/ai_model_settings_state.dart';
|
||||
|
||||
part 'ai_model_settings_controller.g.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 {
|
||||
final build = AiConstants.llamaBuild;
|
||||
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';
|
||||
}
|
||||
final suffix = arch == 'arm64' ? 'macos-arm64' : 'macos-x64';
|
||||
return 'https://github.com/ggml-org/llama.cpp/releases/download/$build/llama-$build-bin-$suffix.tar.gz';
|
||||
} else if (Platform.isWindows) {
|
||||
return 'https://github.com/ggml-org/llama.cpp/releases/download/$_llamaBuild/llama-$_llamaBuild-bin-win-vulkan-x64.zip';
|
||||
return 'https://github.com/ggml-org/llama.cpp/releases/download/$build/llama-$build-bin-win-vulkan-x64.zip';
|
||||
} else if (Platform.isLinux) {
|
||||
return 'https://github.com/ggml-org/llama.cpp/releases/download/$_llamaBuild/llama-$_llamaBuild-bin-ubuntu-vulkan-x64.tar.gz';
|
||||
return 'https://github.com/ggml-org/llama.cpp/releases/download/$build/llama-$build-bin-ubuntu-vulkan-x64.tar.gz';
|
||||
}
|
||||
throw UnsupportedError('Unsupported platform: ${Platform.operatingSystem}');
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -62,30 +34,21 @@ class AiModelSettingsController extends _$AiModelSettingsController {
|
||||
@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() &&
|
||||
final serverBin = File(p.join(base, AiConstants.serverBinaryName));
|
||||
final nomicModel = File(p.join(base, AiConstants.nomicModelFile));
|
||||
final qwenModel = File(p.join(base, AiConstants.qwenModelFile));
|
||||
final validated =
|
||||
serverBin.existsSync() &&
|
||||
nomicModel.existsSync() &&
|
||||
qwenModel.existsSync();
|
||||
|
||||
state = state.copyWith(
|
||||
areModelsValidated: validated,
|
||||
currentTask: validated ? 'All files present.' : 'Files missing.',
|
||||
@@ -99,29 +62,22 @@ class AiModelSettingsController extends _$AiModelSettingsController {
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Download & Install
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Downloads and installs the llama.cpp binary and both model files.
|
||||
Future<void> downloadAll() async {
|
||||
if (state.isDownloading) return;
|
||||
|
||||
try {
|
||||
await di.getIt<AiProcessManager>().stopServers();
|
||||
} catch (_) {}
|
||||
state = state.copyWith(
|
||||
isDownloading: true,
|
||||
progress: 0.0,
|
||||
areModelsValidated: false,
|
||||
errorMessage: null,
|
||||
);
|
||||
|
||||
try {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
|
||||
// -- 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,
|
||||
@@ -129,41 +85,32 @@ class AiModelSettingsController extends _$AiModelSettingsController {
|
||||
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),
|
||||
url: AiConstants.nomicModelUrl,
|
||||
savePath: p.join(dir.path, AiConstants.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),
|
||||
url: AiConstants.qwenModelUrl,
|
||||
savePath: p.join(dir.path, AiConstants.qwenModelFile),
|
||||
taskLabel: 'Downloading Qwen 2.5 7B model…',
|
||||
overallStart: 0.55,
|
||||
overallEnd: 1.0,
|
||||
);
|
||||
|
||||
state = state.copyWith(
|
||||
isDownloading: false,
|
||||
progress: 1.0,
|
||||
currentTask: 'Download complete.',
|
||||
);
|
||||
|
||||
await validateModels();
|
||||
} on DioException catch (e) {
|
||||
state = state.copyWith(
|
||||
@@ -180,11 +127,6 @@ class AiModelSettingsController extends _$AiModelSettingsController {
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Downloads a single file with progress mapped into [overallStart]..[overallEnd].
|
||||
Future<void> _downloadFile({
|
||||
required String url,
|
||||
required String savePath,
|
||||
@@ -193,7 +135,6 @@ class AiModelSettingsController extends _$AiModelSettingsController {
|
||||
required double overallEnd,
|
||||
}) async {
|
||||
state = state.copyWith(currentTask: taskLabel, progress: overallStart);
|
||||
|
||||
await _dio.download(
|
||||
url,
|
||||
savePath,
|
||||
@@ -212,52 +153,62 @@ class AiModelSettingsController extends _$AiModelSettingsController {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
await extractFileToDisk(archivePath, extractDir);
|
||||
bool foundServer = false;
|
||||
final binaryName = AiConstants.serverBinaryName;
|
||||
for (final entity in extractDirObj.listSync(recursive: true)) {
|
||||
if (entity is File) {
|
||||
final ext = p.extension(entity.path).toLowerCase();
|
||||
final name = p.basename(entity.path);
|
||||
if (name == binaryName ||
|
||||
ext == '.dll' ||
|
||||
ext == '.so' ||
|
||||
ext == '.dylib') {
|
||||
final destFile = p.join(destDir, name);
|
||||
int retryCount = 0;
|
||||
bool success = false;
|
||||
while (!success && retryCount < 5) {
|
||||
try {
|
||||
if (File(destFile).existsSync()) {
|
||||
File(destFile).deleteSync();
|
||||
}
|
||||
entity.copySync(destFile);
|
||||
success = true;
|
||||
} on FileSystemException catch (_) {
|
||||
if (retryCount >= 4) {
|
||||
throw Exception(
|
||||
'Failed to overwrite $name. Ensure no other applications are using it.',
|
||||
);
|
||||
}
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
retryCount++;
|
||||
}
|
||||
}
|
||||
if (name == binaryName) {
|
||||
foundServer = true;
|
||||
if (Platform.isMacOS || Platform.isLinux) {
|
||||
await Process.run('chmod', ['+x', destFile]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Walk the extracted tree to find the server binary
|
||||
final binary = _findFile(extractDirObj, _serverBinaryName);
|
||||
if (binary == null) {
|
||||
if (!foundServer) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ part of 'ai_model_settings_controller.dart';
|
||||
// **************************************************************************
|
||||
|
||||
String _$aiModelSettingsControllerHash() =>
|
||||
r'5bf80e85e734016b0fa80c6bb84315925f2595b3';
|
||||
r'27a37c3fafb21b93a8b5523718f1537419bd382a';
|
||||
|
||||
/// See also [AiModelSettingsController].
|
||||
@ProviderFor(AiModelSettingsController)
|
||||
|
||||
@@ -3,10 +3,12 @@ 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/core/theme/app_colors.dart';
|
||||
import 'package:trainhub_flutter/presentation/settings/ai_model_settings_controller.dart';
|
||||
import 'package:trainhub_flutter/presentation/settings/ai_model_settings_state.dart';
|
||||
import 'package:trainhub_flutter/presentation/settings/widgets/ai_models_section.dart';
|
||||
import 'package:trainhub_flutter/presentation/settings/widgets/knowledge_base_section.dart';
|
||||
import 'package:trainhub_flutter/presentation/settings/widgets/settings_top_bar.dart';
|
||||
|
||||
@RoutePage()
|
||||
class SettingsPage extends ConsumerWidget {
|
||||
@@ -15,17 +17,13 @@ class SettingsPage extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final modelState = ref.watch(aiModelSettingsControllerProvider);
|
||||
final controller =
|
||||
ref.read(aiModelSettingsControllerProvider.notifier);
|
||||
final controller = ref.read(aiModelSettingsControllerProvider.notifier);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.surface,
|
||||
body: Column(
|
||||
children: [
|
||||
// ── Top bar ──────────────────────────────────────────────────────
|
||||
_TopBar(onBack: () => context.router.maybePop()),
|
||||
|
||||
// ── Scrollable content ──────────────────────────────────────────
|
||||
SettingsTopBar(onBack: () => context.router.maybePop()),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(UIConstants.pagePadding),
|
||||
@@ -35,7 +33,6 @@ class SettingsPage extends ConsumerWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Page title
|
||||
Text(
|
||||
'Settings',
|
||||
style: GoogleFonts.inter(
|
||||
@@ -46,20 +43,15 @@ class SettingsPage extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing32),
|
||||
|
||||
// AI Models section
|
||||
_AiModelsSection(
|
||||
AiModelsSection(
|
||||
modelState: modelState,
|
||||
onDownload: controller.downloadAll,
|
||||
onValidate: controller.validateModels,
|
||||
),
|
||||
|
||||
const SizedBox(height: UIConstants.spacing32),
|
||||
|
||||
// Knowledge Base section
|
||||
_KnowledgeBaseSection(
|
||||
onTap: () => context.router
|
||||
.push(const KnowledgeBaseRoute()),
|
||||
KnowledgeBaseSection(
|
||||
onTap: () =>
|
||||
context.router.push(const KnowledgeBaseRoute()),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -72,626 +64,3 @@ class SettingsPage extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
322
lib/presentation/settings/widgets/ai_models_section.dart
Normal file
322
lib/presentation/settings/widgets/ai_models_section.dart
Normal file
@@ -0,0 +1,322 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
import 'package:trainhub_flutter/presentation/settings/ai_model_settings_state.dart';
|
||||
import 'package:trainhub_flutter/presentation/settings/widgets/settings_action_button.dart';
|
||||
|
||||
class AiModelsSection extends StatelessWidget {
|
||||
const AiModelsSection({
|
||||
super.key,
|
||||
required this.modelState,
|
||||
required this.onDownload,
|
||||
required this.onValidate,
|
||||
});
|
||||
|
||||
final AiModelSettingsState modelState;
|
||||
final VoidCallback onDownload;
|
||||
final VoidCallback onValidate;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'AI Models',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textMuted,
|
||||
letterSpacing: 0.8,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing12),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(UIConstants.borderRadius),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const _ModelRow(
|
||||
name: 'llama-server binary',
|
||||
description: 'llama.cpp inference server (build b8130)',
|
||||
icon: Icons.terminal_rounded,
|
||||
),
|
||||
const Divider(height: 1, color: AppColors.border),
|
||||
const _ModelRow(
|
||||
name: 'Nomic Embed v1.5 Q4_K_M',
|
||||
description: 'Text embedding model (~300 MB)',
|
||||
icon: Icons.hub_outlined,
|
||||
),
|
||||
const Divider(height: 1, color: AppColors.border),
|
||||
const _ModelRow(
|
||||
name: 'Qwen 2.5 7B Instruct Q4_K_M',
|
||||
description: 'Chat / reasoning model (~4.7 GB)',
|
||||
icon: Icons.psychology_outlined,
|
||||
),
|
||||
const Divider(height: 1, color: AppColors.border),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UIConstants.spacing16),
|
||||
child: _StatusAndActions(
|
||||
modelState: modelState,
|
||||
onDownload: onDownload,
|
||||
onValidate: onValidate,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ModelRow extends StatelessWidget {
|
||||
const _ModelRow({
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.icon,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String description;
|
||||
final IconData icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing16,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(icon, size: 16, color: AppColors.textSecondary),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
description,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusAndActions extends StatelessWidget {
|
||||
const _StatusAndActions({
|
||||
required this.modelState,
|
||||
required this.onDownload,
|
||||
required this.onValidate,
|
||||
});
|
||||
|
||||
final AiModelSettingsState modelState;
|
||||
final VoidCallback onDownload;
|
||||
final VoidCallback onValidate;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (modelState.isDownloading) {
|
||||
return _DownloadingView(modelState: modelState);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_StatusBadge(validated: modelState.areModelsValidated),
|
||||
if (modelState.errorMessage != null) ...[
|
||||
const SizedBox(height: UIConstants.spacing12),
|
||||
ErrorRow(message: modelState.errorMessage!),
|
||||
],
|
||||
const SizedBox(height: UIConstants.spacing16),
|
||||
if (!modelState.areModelsValidated)
|
||||
SettingsActionButton(
|
||||
label: 'Download AI Models (~5 GB)',
|
||||
icon: Icons.download_rounded,
|
||||
color: AppColors.accent,
|
||||
textColor: AppColors.zinc950,
|
||||
onPressed: onDownload,
|
||||
)
|
||||
else
|
||||
SettingsActionButton(
|
||||
label: 'Re-validate Files',
|
||||
icon: Icons.verified_outlined,
|
||||
color: Colors.transparent,
|
||||
textColor: AppColors.textSecondary,
|
||||
borderColor: AppColors.border,
|
||||
onPressed: onValidate,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
const _StatusBadge({required this.validated});
|
||||
|
||||
final bool validated;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = validated ? AppColors.success : AppColors.textMuted;
|
||||
final bgColor =
|
||||
validated ? AppColors.successMuted : AppColors.surfaceContainerHigh;
|
||||
final label = validated ? 'Ready' : 'Missing';
|
||||
final icon =
|
||||
validated ? Icons.check_circle_outline : Icons.radio_button_unchecked;
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Status: ',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 13, color: color),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
label,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DownloadingView extends StatelessWidget {
|
||||
const _DownloadingView({required this.modelState});
|
||||
|
||||
final AiModelSettingsState modelState;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pct = (modelState.progress * 100).toStringAsFixed(1);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
modelState.currentTask,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$pct %',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing8),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: modelState.progress,
|
||||
minHeight: 6,
|
||||
backgroundColor: AppColors.zinc800,
|
||||
valueColor:
|
||||
const AlwaysStoppedAnimation<Color>(AppColors.accent),
|
||||
),
|
||||
),
|
||||
if (modelState.errorMessage != null) ...[
|
||||
const SizedBox(height: UIConstants.spacing12),
|
||||
ErrorRow(message: modelState.errorMessage!),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ErrorRow extends StatelessWidget {
|
||||
const ErrorRow({super.key, required this.message});
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline_rounded,
|
||||
color: AppColors.destructive,
|
||||
size: 14,
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
color: AppColors.destructive,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
118
lib/presentation/settings/widgets/knowledge_base_section.dart
Normal file
118
lib/presentation/settings/widgets/knowledge_base_section.dart
Normal file
@@ -0,0 +1,118 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
|
||||
class KnowledgeBaseSection extends StatelessWidget {
|
||||
const KnowledgeBaseSection({super.key, required this.onTap});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Knowledge Base',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textMuted,
|
||||
letterSpacing: 0.8,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing12),
|
||||
_KnowledgeBaseCard(onTap: onTap),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _KnowledgeBaseCard extends StatefulWidget {
|
||||
const _KnowledgeBaseCard({required this.onTap});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
State<_KnowledgeBaseCard> createState() => _KnowledgeBaseCardState();
|
||||
}
|
||||
|
||||
class _KnowledgeBaseCardState extends State<_KnowledgeBaseCard> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: UIConstants.animationDuration,
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered
|
||||
? AppColors.surfaceContainerHigh
|
||||
: AppColors.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(UIConstants.borderRadius),
|
||||
border: Border.all(
|
||||
color: _hovered
|
||||
? AppColors.accent.withValues(alpha: 0.3)
|
||||
: AppColors.border,
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing16,
|
||||
vertical: UIConstants.spacing16,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.accentMuted,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.hub_outlined,
|
||||
color: AppColors.accent,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Manage Knowledge Base',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
'Add trainer notes to give the AI context-aware answers.',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: _hovered ? AppColors.accent : AppColors.textMuted,
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
|
||||
class SettingsActionButton extends StatefulWidget {
|
||||
const SettingsActionButton({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.textColor,
|
||||
required this.onPressed,
|
||||
this.borderColor,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final Color textColor;
|
||||
final Color? borderColor;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
State<SettingsActionButton> createState() => _SettingsActionButtonState();
|
||||
}
|
||||
|
||||
class _SettingsActionButtonState extends State<SettingsActionButton> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasBorder = widget.borderColor != null;
|
||||
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: AnimatedContainer(
|
||||
duration: UIConstants.animationDuration,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: hasBorder
|
||||
? (_hovered ? AppColors.zinc800 : Colors.transparent)
|
||||
: (_hovered
|
||||
? widget.color.withValues(alpha: 0.85)
|
||||
: widget.color),
|
||||
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
border: hasBorder ? Border.all(color: widget.borderColor!) : null,
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
onTap: widget.onPressed,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing16,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(widget.icon, size: 16, color: widget.textColor),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
Text(
|
||||
widget.label,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: widget.textColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
89
lib/presentation/settings/widgets/settings_top_bar.dart
Normal file
89
lib/presentation/settings/widgets/settings_top_bar.dart
Normal file
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
|
||||
class SettingsTopBar extends StatelessWidget {
|
||||
const SettingsTopBar({super.key, required this.onBack});
|
||||
|
||||
final VoidCallback onBack;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 52,
|
||||
padding: const EdgeInsets.symmetric(horizontal: UIConstants.spacing16),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
border: Border(bottom: BorderSide(color: AppColors.border)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SettingsIconButton(
|
||||
icon: Icons.arrow_back_rounded,
|
||||
tooltip: 'Go back',
|
||||
onTap: onBack,
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing12),
|
||||
Text(
|
||||
'Settings',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SettingsIconButton extends StatefulWidget {
|
||||
const SettingsIconButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
this.tooltip = '',
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
final String tooltip;
|
||||
|
||||
@override
|
||||
State<SettingsIconButton> createState() => _SettingsIconButtonState();
|
||||
}
|
||||
|
||||
class _SettingsIconButtonState extends State<SettingsIconButton> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: widget.tooltip,
|
||||
child: MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: UIConstants.animationDuration,
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered ? AppColors.zinc800 : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Icon(
|
||||
widget.icon,
|
||||
size: 18,
|
||||
color:
|
||||
_hovered ? AppColors.textPrimary : AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:trainhub_flutter/core/constants/app_constants.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/router/app_router.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
@@ -155,7 +156,7 @@ class _Sidebar extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'v2.0.0',
|
||||
'v${AppConstants.appVersion}',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 11,
|
||||
color: AppColors.textMuted,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
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';
|
||||
import 'package:trainhub_flutter/presentation/welcome/widgets/download_progress.dart';
|
||||
import 'package:trainhub_flutter/presentation/welcome/widgets/initial_prompt.dart';
|
||||
|
||||
@RoutePage()
|
||||
class WelcomeScreen extends ConsumerStatefulWidget {
|
||||
@@ -22,7 +23,6 @@ class _WelcomeScreenState extends ConsumerState<WelcomeScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Validate after the first frame so the provider is ready
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref
|
||||
.read(aiModelSettingsControllerProvider.notifier)
|
||||
@@ -43,19 +43,10 @@ class _WelcomeScreenState extends ConsumerState<WelcomeScreen> {
|
||||
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 &&
|
||||
@@ -73,10 +64,12 @@ class _WelcomeScreenState extends ConsumerState<WelcomeScreen> {
|
||||
child: AnimatedSwitcher(
|
||||
duration: UIConstants.animationDuration,
|
||||
child: modelState.isDownloading
|
||||
? _DownloadProgress(modelState: modelState)
|
||||
: _InitialPrompt(
|
||||
onDownload: _startDownload,
|
||||
onSkip: _skip,
|
||||
? DownloadProgress(modelState: modelState)
|
||||
: InitialPrompt(
|
||||
onDownload: () => ref
|
||||
.read(aiModelSettingsControllerProvider.notifier)
|
||||
.downloadAll(),
|
||||
onSkip: _navigateToApp,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -84,452 +77,3 @@ class _WelcomeScreenState extends ConsumerState<WelcomeScreen> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
135
lib/presentation/welcome/widgets/download_progress.dart
Normal file
135
lib/presentation/welcome/widgets/download_progress.dart
Normal file
@@ -0,0 +1,135 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
import 'package:trainhub_flutter/presentation/settings/ai_model_settings_state.dart';
|
||||
|
||||
class DownloadProgress extends StatelessWidget {
|
||||
const DownloadProgress({super.key, required this.modelState});
|
||||
|
||||
final AiModelSettingsState modelState;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pct = (modelState.progress * 100).toStringAsFixed(1);
|
||||
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.accentMuted,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.download_rounded,
|
||||
color: AppColors.accent,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing24),
|
||||
Text(
|
||||
'Setting up AI models\u2026',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
letterSpacing: -0.3,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing8),
|
||||
Text(
|
||||
'Please keep the app open. This only happens once.',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing32),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: modelState.progress,
|
||||
minHeight: 6,
|
||||
backgroundColor: AppColors.zinc800,
|
||||
valueColor:
|
||||
const AlwaysStoppedAnimation<Color>(AppColors.accent),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
modelState.currentTask,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$pct %',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (modelState.errorMessage != null) ...[
|
||||
const SizedBox(height: UIConstants.spacing16),
|
||||
ErrorBanner(message: modelState.errorMessage!),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ErrorBanner extends StatelessWidget {
|
||||
const ErrorBanner({super.key, required this.message});
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UIConstants.spacing12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.destructiveMuted,
|
||||
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
border: Border.all(
|
||||
color: AppColors.destructive.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline_rounded,
|
||||
color: AppColors.destructive,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
color: AppColors.destructive,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
208
lib/presentation/welcome/widgets/initial_prompt.dart
Normal file
208
lib/presentation/welcome/widgets/initial_prompt.dart
Normal file
@@ -0,0 +1,208 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
import 'package:trainhub_flutter/presentation/welcome/widgets/welcome_buttons.dart';
|
||||
|
||||
class InitialPrompt extends StatelessWidget {
|
||||
const InitialPrompt({
|
||||
super.key,
|
||||
required this.onDownload,
|
||||
required this.onSkip,
|
||||
});
|
||||
|
||||
final VoidCallback onDownload;
|
||||
final VoidCallback onSkip;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildLogoRow(),
|
||||
const SizedBox(height: UIConstants.spacing32),
|
||||
_buildHeadline(),
|
||||
const SizedBox(height: UIConstants.spacing16),
|
||||
_buildDescription(),
|
||||
const SizedBox(height: UIConstants.spacing24),
|
||||
_buildFeatureList(),
|
||||
const SizedBox(height: UIConstants.spacing32),
|
||||
_buildDownloadNotice(),
|
||||
const SizedBox(height: UIConstants.spacing32),
|
||||
_buildActionButtons(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLogoRow() {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.accentMuted,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.fitness_center,
|
||||
color: AppColors.accent,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing12),
|
||||
Text(
|
||||
'TrainHub',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeadline() {
|
||||
return Text(
|
||||
'AI-powered coaching,\nright on your device.',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.25,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDescription() {
|
||||
return Text(
|
||||
'TrainHub uses on-device AI models to give you intelligent '
|
||||
'training advice, exercise analysis, and personalised chat — '
|
||||
'with zero data sent to the cloud.',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
height: 1.6,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFeatureList() {
|
||||
return const Column(
|
||||
children: [
|
||||
FeatureRow(
|
||||
icon: Icons.lock_outline_rounded,
|
||||
label: '100 % local — your data never leaves this machine.',
|
||||
),
|
||||
SizedBox(height: UIConstants.spacing12),
|
||||
FeatureRow(
|
||||
icon: Icons.psychology_outlined,
|
||||
label: 'Qwen 2.5 7B chat model for training advice.',
|
||||
),
|
||||
SizedBox(height: UIConstants.spacing12),
|
||||
FeatureRow(
|
||||
icon: Icons.search_rounded,
|
||||
label: 'Nomic embedding model for semantic exercise search.',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDownloadNotice() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing16,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.accentMuted,
|
||||
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
border: Border.all(
|
||||
color: AppColors.accent.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.download_outlined,
|
||||
color: AppColors.accent,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'The download is ~5 GB and only needs to happen once. '
|
||||
'You can skip now and download later from Settings.',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
color: AppColors.accent,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButtons() {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: WelcomePrimaryButton(
|
||||
label: 'Download Now',
|
||||
icon: Icons.download_rounded,
|
||||
onPressed: onDownload,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing12),
|
||||
Expanded(
|
||||
child: WelcomeSecondaryButton(
|
||||
label: 'Skip for Now',
|
||||
onPressed: onSkip,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FeatureRow extends StatelessWidget {
|
||||
const FeatureRow({super.key, required this.icon, required this.label});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Icon(icon, size: 14, color: AppColors.accent),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
119
lib/presentation/welcome/widgets/welcome_buttons.dart
Normal file
119
lib/presentation/welcome/widgets/welcome_buttons.dart
Normal file
@@ -0,0 +1,119 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
|
||||
class WelcomePrimaryButton extends StatefulWidget {
|
||||
const WelcomePrimaryButton({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
State<WelcomePrimaryButton> createState() => _WelcomePrimaryButtonState();
|
||||
}
|
||||
|
||||
class _WelcomePrimaryButtonState extends State<WelcomePrimaryButton> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: AnimatedContainer(
|
||||
duration: UIConstants.animationDuration,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered
|
||||
? AppColors.accent.withValues(alpha: 0.85)
|
||||
: AppColors.accent,
|
||||
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
onTap: widget.onPressed,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(widget.icon, color: AppColors.zinc950, size: 16),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
Text(
|
||||
widget.label,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.zinc950,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WelcomeSecondaryButton extends StatefulWidget {
|
||||
const WelcomeSecondaryButton({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
State<WelcomeSecondaryButton> createState() =>
|
||||
_WelcomeSecondaryButtonState();
|
||||
}
|
||||
|
||||
class _WelcomeSecondaryButtonState extends State<WelcomeSecondaryButton> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: AnimatedContainer(
|
||||
duration: UIConstants.animationDuration,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered ? AppColors.zinc800 : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
onTap: widget.onPressed,
|
||||
child: Center(
|
||||
child: Text(
|
||||
widget.label,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user