Next refactors
Some checks failed
Build Linux App / build (push) Failing after 1m18s

This commit is contained in:
Kazimierz Ciołek
2026-02-24 02:19:28 +01:00
parent 0c9eb8878d
commit 9dcc4b87de
40 changed files with 3515 additions and 2575 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
import 'package:trainhub_flutter/domain/entities/chat_message.dart';
class MessageBubble extends StatelessWidget {
const MessageBubble({
super.key,
required this.message,
required this.formattedTime,
});
final ChatMessageEntity message;
final String formattedTime;
@override
Widget build(BuildContext context) {
final isUser = message.isUser;
final maxWidth = MediaQuery.of(context).size.width * 0.55;
return Padding(
padding: const EdgeInsets.only(bottom: UIConstants.spacing12),
child: Row(
mainAxisAlignment:
isUser ? MainAxisAlignment.end : MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isUser) ...[
_buildAvatar(
Icons.auto_awesome_rounded,
AppColors.surfaceContainerHigh,
),
const SizedBox(width: UIConstants.spacing8),
],
Flexible(
child: Column(
crossAxisAlignment:
isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
Container(
constraints: BoxConstraints(maxWidth: maxWidth),
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing16,
vertical: UIConstants.spacing12,
),
decoration: BoxDecoration(
color: isUser
? AppColors.zinc700
: AppColors.surfaceContainer,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),
bottomLeft: isUser
? const Radius.circular(16)
: const Radius.circular(4),
bottomRight: isUser
? const Radius.circular(4)
: const Radius.circular(16),
),
border: isUser
? null
: Border.all(color: AppColors.border, width: 1),
),
child: isUser
? SelectableText(
message.content,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 14,
height: 1.5,
),
)
: MarkdownBody(
data: message.content,
styleSheet: MarkdownStyleSheet(
p: const TextStyle(
color: AppColors.textPrimary,
fontSize: 14,
height: 1.5,
),
),
),
),
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text(
formattedTime,
style: const TextStyle(
color: AppColors.textMuted,
fontSize: 11,
),
),
),
],
),
),
if (isUser) ...[
const SizedBox(width: UIConstants.spacing8),
_buildAvatar(
Icons.person_rounded,
AppColors.accent.withValues(alpha: 0.15),
),
],
],
),
);
}
Widget _buildAvatar(IconData icon, Color backgroundColor) {
return Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, size: 14, color: AppColors.accent),
);
}
}

View File

@@ -0,0 +1,115 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/router/app_router.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
class MissingModelsState extends StatelessWidget {
const MissingModelsState({super.key});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: AppColors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(18),
),
child: const Icon(
Icons.cloud_download_outlined,
size: 36,
color: AppColors.textMuted,
),
),
const SizedBox(height: UIConstants.spacing24),
Text(
'AI models are missing.',
style: GoogleFonts.inter(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
letterSpacing: -0.2,
),
),
const SizedBox(height: UIConstants.spacing8),
Text(
'Please download them to use the AI chat.',
style: GoogleFonts.inter(
fontSize: 14,
color: AppColors.textSecondary,
),
),
const SizedBox(height: UIConstants.spacing32),
const _GoToSettingsButton(),
],
),
);
}
}
class _GoToSettingsButton extends StatefulWidget {
const _GoToSettingsButton();
@override
State<_GoToSettingsButton> createState() => _GoToSettingsButtonState();
}
class _GoToSettingsButtonState extends State<_GoToSettingsButton> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: AnimatedContainer(
duration: UIConstants.animationDuration,
height: 44,
decoration: BoxDecoration(
color: _isHovered
? AppColors.accent.withValues(alpha: 0.85)
: AppColors.accent,
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius:
BorderRadius.circular(UIConstants.smallBorderRadius),
onTap: () => context.router.push(const SettingsRoute()),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing24,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.settings_outlined,
size: 16,
color: AppColors.zinc950,
),
const SizedBox(width: UIConstants.spacing8),
Text(
'Go to Settings',
style: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.zinc950,
),
),
],
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
class NewChatButton extends StatefulWidget {
const NewChatButton({super.key, required this.onPressed});
final VoidCallback onPressed;
@override
State<NewChatButton> createState() => _NewChatButtonState();
}
class _NewChatButtonState extends State<NewChatButton> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
decoration: BoxDecoration(
color: _isHovered
? AppColors.zinc700
: AppColors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
border: Border.all(color: AppColors.border, width: 1),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius:
BorderRadius.circular(UIConstants.smallBorderRadius),
onTap: widget.onPressed,
child: const Padding(
padding: EdgeInsets.symmetric(
horizontal: UIConstants.spacing12,
vertical: UIConstants.spacing8,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.add_rounded,
size: 16,
color: AppColors.textSecondary,
),
SizedBox(width: UIConstants.spacing8),
Text(
'New Chat',
style: TextStyle(
color: AppColors.textSecondary,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,180 @@
import 'package:flutter/material.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
import 'package:trainhub_flutter/presentation/chat/chat_state.dart';
class ThinkingBlock extends StatefulWidget {
const ThinkingBlock({super.key, required this.steps});
final List<ThinkingStep> steps;
@override
State<ThinkingBlock> createState() => _ThinkingBlockState();
}
class _ThinkingBlockState extends State<ThinkingBlock> {
bool _isExpanded = true;
Color _getStatusColor(ThinkingStepStatus status) {
switch (status) {
case ThinkingStepStatus.running:
return AppColors.info;
case ThinkingStepStatus.completed:
return AppColors.success;
case ThinkingStepStatus.error:
return AppColors.destructive;
case ThinkingStepStatus.pending:
return AppColors.textMuted;
}
}
Widget _buildStatusIcon(ThinkingStepStatus status) {
if (status == ThinkingStepStatus.running) {
return const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.info,
),
);
}
final IconData icon;
switch (status) {
case ThinkingStepStatus.completed:
icon = Icons.check_circle_rounded;
case ThinkingStepStatus.error:
icon = Icons.error_outline_rounded;
default:
icon = Icons.circle_outlined;
}
return Icon(icon, size: 14, color: _getStatusColor(status));
}
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: AppColors.surfaceContainer,
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
border: Border.all(color: AppColors.border, width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Material(
color: Colors.transparent,
child: InkWell(
borderRadius:
BorderRadius.circular(UIConstants.smallBorderRadius),
onTap: () => setState(() => _isExpanded = !_isExpanded),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
const Icon(
Icons.settings_suggest_rounded,
size: 16,
color: AppColors.warning,
),
const SizedBox(width: 8),
const Expanded(
child: Text(
'Assistant actions',
style: TextStyle(
color: AppColors.textPrimary,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
Text(
'(${widget.steps.length} steps)',
style: const TextStyle(
color: AppColors.textMuted,
fontSize: 11,
),
),
const SizedBox(width: 8),
Icon(
_isExpanded
? Icons.keyboard_arrow_up
: Icons.keyboard_arrow_down,
size: 16,
color: AppColors.textMuted,
),
],
),
),
),
),
if (_isExpanded)
Container(
decoration: const BoxDecoration(
border: Border(
top: BorderSide(color: AppColors.border, width: 1),
),
),
padding: const EdgeInsets.all(12),
child: Column(
children: widget.steps.map(_buildStepRow).toList(),
),
),
],
),
);
}
Widget _buildStepRow(ThinkingStep step) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: _buildStatusIcon(step.status),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
step.title,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
if (step.details != null && step.details!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(4),
border:
Border.all(color: AppColors.border, width: 1),
),
child: Text(
step.details!,
style: const TextStyle(
fontFamily: 'monospace',
color: AppColors.textSecondary,
fontSize: 11,
),
),
),
),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
import 'package:trainhub_flutter/presentation/chat/chat_state.dart';
import 'package:trainhub_flutter/presentation/chat/widgets/thinking_block.dart';
import 'package:trainhub_flutter/presentation/chat/widgets/typing_indicator.dart';
class TypingBubble extends StatelessWidget {
const TypingBubble({
super.key,
required this.thinkingSteps,
this.streamingContent,
});
final List<ThinkingStep> thinkingSteps;
final String? streamingContent;
@override
Widget build(BuildContext context) {
final maxWidth = MediaQuery.of(context).size.width * 0.55;
return Padding(
padding: const EdgeInsets.only(bottom: UIConstants.spacing12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: AppColors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.auto_awesome_rounded,
size: 14,
color: AppColors.accent,
),
),
const SizedBox(width: UIConstants.spacing8),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (thinkingSteps.isNotEmpty)
Container(
constraints: BoxConstraints(maxWidth: maxWidth),
margin: const EdgeInsets.only(bottom: 8),
child: ThinkingBlock(steps: thinkingSteps),
),
if (streamingContent != null && streamingContent!.isNotEmpty)
Container(
constraints: BoxConstraints(maxWidth: maxWidth),
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing16,
vertical: UIConstants.spacing12,
),
decoration: BoxDecoration(
color: AppColors.surfaceContainer,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
bottomLeft: Radius.circular(4),
bottomRight: Radius.circular(16),
),
border:
Border.all(color: AppColors.border, width: 1),
),
child: MarkdownBody(
data: streamingContent!,
styleSheet: MarkdownStyleSheet(
p: const TextStyle(
color: AppColors.textPrimary,
fontSize: 14,
height: 1.5,
),
),
),
)
else
const TypingIndicator(),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,111 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
class TypingIndicator extends StatefulWidget {
const TypingIndicator({super.key});
@override
State<TypingIndicator> createState() => _TypingIndicatorState();
}
class _TypingIndicatorState extends State<TypingIndicator>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: UIConstants.spacing12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: AppColors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.auto_awesome_rounded,
size: 14,
color: AppColors.accent,
),
),
const SizedBox(width: UIConstants.spacing8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing16,
vertical: UIConstants.spacing12,
),
decoration: BoxDecoration(
color: AppColors.surfaceContainer,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
bottomLeft: Radius.circular(4),
bottomRight: Radius.circular(16),
),
border: Border.all(color: AppColors.border, width: 1),
),
child: AnimatedBuilder(
animation: _controller,
builder: (context, _) {
return Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(3, (index) {
final delay = index * 0.2;
final t = (_controller.value - delay) % 1.0;
final bounce =
t < 0.5 ? math.sin(t * math.pi * 2) * 4.0 : 0.0;
return Padding(
padding: EdgeInsets.only(left: index == 0 ? 0 : 4),
child: Transform.translate(
offset: Offset(0, -bounce.abs()),
child: const _Dot(),
),
);
}),
);
},
),
),
],
),
);
}
}
class _Dot extends StatelessWidget {
const _Dot();
@override
Widget build(BuildContext context) {
return Container(
width: 7,
height: 7,
decoration: const BoxDecoration(
color: AppColors.textMuted,
shape: BoxShape.circle,
),
);
}
}

View File

@@ -4,11 +4,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
import 'package:trainhub_flutter/domain/entities/program_workout.dart';
import 'package:trainhub_flutter/presentation/common/widgets/app_empty_state.dart';
import 'package:trainhub_flutter/presentation/common/widgets/app_stat_card.dart';
import 'package:trainhub_flutter/presentation/home/home_controller.dart';
import 'package:trainhub_flutter/presentation/home/home_state.dart';
import 'package:trainhub_flutter/presentation/home/widgets/next_workout_banner.dart';
import 'package:trainhub_flutter/presentation/home/widgets/quick_actions_row.dart';
import 'package:trainhub_flutter/presentation/home/widgets/recent_activity_section.dart';
import 'package:trainhub_flutter/presentation/home/widgets/stat_cards_row.dart';
import 'package:trainhub_flutter/presentation/home/widgets/welcome_header.dart';
@RoutePage()
class HomePage extends ConsumerWidget {
@@ -20,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,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
class NextWorkoutBanner extends StatelessWidget {
const NextWorkoutBanner({super.key, required this.workoutName});
final String workoutName;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(UIConstants.cardPadding),
decoration: BoxDecoration(
color: AppColors.surfaceContainer,
borderRadius: UIConstants.cardBorderRadius,
border: Border.all(color: AppColors.border),
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColors.accentMuted,
borderRadius: UIConstants.smallCardBorderRadius,
),
child: const Icon(
Icons.play_arrow_rounded,
color: AppColors.accent,
size: 22,
),
),
const SizedBox(width: UIConstants.spacing12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Up Next',
style: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.textMuted,
),
),
const SizedBox(height: 2),
Text(
workoutName,
style: GoogleFonts.inter(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
],
),
),
const Icon(
Icons.chevron_right,
color: AppColors.textMuted,
size: 20,
),
],
),
);
}
}

View File

@@ -0,0 +1,124 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
class QuickActionsRow extends StatelessWidget {
const QuickActionsRow({super.key});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Quick Actions',
style: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(height: UIConstants.spacing12),
Row(
children: [
QuickActionButton(
icon: Icons.play_arrow_rounded,
label: 'New Workout',
color: AppColors.accent,
onTap: () => AutoTabsRouter.of(context).setActiveIndex(1),
),
const SizedBox(width: UIConstants.spacing12),
QuickActionButton(
icon: Icons.description_outlined,
label: 'View Plans',
color: AppColors.info,
onTap: () => AutoTabsRouter.of(context).setActiveIndex(1),
),
const SizedBox(width: UIConstants.spacing12),
QuickActionButton(
icon: Icons.chat_bubble_outline,
label: 'AI Chat',
color: AppColors.purple,
onTap: () => AutoTabsRouter.of(context).setActiveIndex(4),
),
],
),
],
);
}
}
class QuickActionButton extends StatefulWidget {
const QuickActionButton({
super.key,
required this.icon,
required this.label,
required this.color,
required this.onTap,
});
final IconData icon;
final String label;
final Color color;
final VoidCallback onTap;
@override
State<QuickActionButton> createState() => _QuickActionButtonState();
}
class _QuickActionButtonState extends State<QuickActionButton> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: AnimatedContainer(
duration: UIConstants.animationDuration,
decoration: BoxDecoration(
color: _isHovered
? widget.color.withValues(alpha: 0.08)
: Colors.transparent,
borderRadius: UIConstants.smallCardBorderRadius,
border: Border.all(
color: _isHovered
? widget.color.withValues(alpha: 0.4)
: AppColors.border,
),
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: widget.onTap,
borderRadius: UIConstants.smallCardBorderRadius,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing24,
vertical: UIConstants.spacing12,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(widget.icon, size: 18, color: widget.color),
const SizedBox(width: UIConstants.spacing8),
Text(
widget.label,
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w500,
color:
_isHovered ? widget.color : AppColors.textSecondary,
),
),
],
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,272 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
import 'package:trainhub_flutter/domain/entities/program_workout.dart';
class RecentActivitySection extends StatelessWidget {
const RecentActivitySection({super.key, required this.activity});
final List<ProgramWorkoutEntity> activity;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
'Recent Activity',
style: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
),
if (activity.isNotEmpty)
Text(
'${activity.length} workout${activity.length == 1 ? '' : 's'}',
style: GoogleFonts.inter(
fontSize: 12,
color: AppColors.textMuted,
),
),
],
),
const SizedBox(height: UIConstants.spacing12),
if (activity.isEmpty)
const _EmptyActivity()
else
_ActivityList(activity: activity),
],
);
}
}
class _EmptyActivity extends StatelessWidget {
const _EmptyActivity();
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
vertical: 40,
horizontal: UIConstants.spacing24,
),
decoration: BoxDecoration(
color: AppColors.surfaceContainer,
borderRadius: UIConstants.cardBorderRadius,
border: Border.all(color: AppColors.border),
),
child: Column(
children: [
const Icon(Icons.history, size: 32, color: AppColors.textMuted),
const SizedBox(height: UIConstants.spacing12),
Text(
'No completed workouts yet',
style: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
),
),
const SizedBox(height: UIConstants.spacing4),
Text(
'Your recent workout history will appear here.',
style: GoogleFonts.inter(
fontSize: 13,
color: AppColors.textMuted,
),
),
],
),
);
}
}
class _ActivityList extends StatelessWidget {
const _ActivityList({required this.activity});
final List<ProgramWorkoutEntity> activity;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: AppColors.surfaceContainer,
borderRadius: UIConstants.cardBorderRadius,
border: Border.all(color: AppColors.border),
),
child: ClipRRect(
borderRadius: UIConstants.cardBorderRadius,
child: Column(
children: [
for (int i = 0; i < activity.length; i++) ...[
if (i > 0)
const Divider(
height: 1,
thickness: 1,
color: AppColors.border,
),
_ActivityItem(workout: activity[i]),
],
],
),
),
);
}
}
class _ActivityItem extends StatefulWidget {
const _ActivityItem({required this.workout});
final ProgramWorkoutEntity workout;
@override
State<_ActivityItem> createState() => _ActivityItemState();
}
class _ActivityItemState extends State<_ActivityItem> {
bool _isHovered = false;
Color get _typeColor {
switch (widget.workout.type.toLowerCase()) {
case 'strength':
return AppColors.accent;
case 'cardio':
return AppColors.info;
case 'flexibility':
case 'mobility':
return AppColors.purple;
case 'rest':
return AppColors.textMuted;
default:
return AppColors.success;
}
}
IconData get _typeIcon {
switch (widget.workout.type.toLowerCase()) {
case 'strength':
return Icons.fitness_center;
case 'cardio':
return Icons.directions_run;
case 'flexibility':
case 'mobility':
return Icons.self_improvement;
case 'rest':
return Icons.bedtime_outlined;
default:
return Icons.check_circle;
}
}
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: AnimatedContainer(
duration: UIConstants.animationDuration,
color: _isHovered
? AppColors.surfaceContainerHigh.withValues(alpha: 0.5)
: Colors.transparent,
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.cardPadding,
vertical: UIConstants.spacing12,
),
child: Row(
children: [
_buildLeadingIcon(),
const SizedBox(width: UIConstants.spacing12),
_buildWorkoutInfo(),
_buildTypeBadge(),
const SizedBox(width: UIConstants.spacing12),
_buildStatusIcon(),
],
),
),
);
}
Widget _buildLeadingIcon() {
return Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: widget.workout.completed
? _typeColor.withValues(alpha: 0.15)
: AppColors.zinc800,
borderRadius: UIConstants.smallCardBorderRadius,
),
child: Icon(
widget.workout.completed ? _typeIcon : Icons.circle_outlined,
size: 18,
color: widget.workout.completed ? _typeColor : AppColors.textMuted,
),
);
}
Widget _buildWorkoutInfo() {
return Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.workout.name ?? 'Workout',
style: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 2),
Text(
'Week ${widget.workout.weekId} · Day ${widget.workout.day}',
style: GoogleFonts.inter(
fontSize: 12,
color: AppColors.textMuted,
),
),
],
),
);
}
Widget _buildTypeBadge() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: _typeColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
),
child: Text(
widget.workout.type,
style: GoogleFonts.inter(
fontSize: 11,
fontWeight: FontWeight.w500,
color: _typeColor,
),
),
);
}
Widget _buildStatusIcon() {
if (widget.workout.completed) {
return const Icon(
Icons.check_circle,
size: 18,
color: AppColors.success,
);
}
return const Icon(
Icons.radio_button_unchecked,
size: 18,
color: AppColors.textMuted,
);
}
}

View File

@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
import 'package:trainhub_flutter/presentation/common/widgets/app_stat_card.dart';
class StatCardsRow extends StatelessWidget {
const StatCardsRow({
super.key,
required this.completed,
required this.total,
});
final int completed;
final int total;
@override
Widget build(BuildContext context) {
final progress = total == 0 ? 0 : (completed / total * 100).round();
return Row(
children: [
Expanded(
child: AppStatCard(
title: 'Completed',
value: '$completed',
icon: Icons.check_circle_outline,
accentColor: AppColors.success,
),
),
const SizedBox(width: UIConstants.spacing16),
Expanded(
child: AppStatCard(
title: 'Total Workouts',
value: '$total',
icon: Icons.list_alt,
accentColor: AppColors.info,
),
),
const SizedBox(width: UIConstants.spacing16),
Expanded(
child: AppStatCard(
title: 'Progress',
value: '$progress%',
icon: Icons.trending_up,
accentColor: AppColors.purple,
),
),
],
);
}
}

View File

@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
class WelcomeHeader extends StatelessWidget {
const WelcomeHeader({super.key, required this.programName});
final String programName;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Welcome back',
style: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.textMuted,
),
),
const SizedBox(height: UIConstants.spacing4),
Row(
children: [
Expanded(
child: Text(
programName,
style: GoogleFonts.inter(
fontSize: 28,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing12,
vertical: 6,
),
decoration: BoxDecoration(
color: AppColors.accentMuted,
borderRadius: UIConstants.smallCardBorderRadius,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.fitness_center,
size: 14,
color: AppColors.accent,
),
const SizedBox(width: 6),
Text(
'Active Program',
style: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.accent,
),
),
],
),
),
],
),
],
);
}
}

View File

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

View File

@@ -7,7 +7,7 @@ part of 'ai_model_settings_controller.dart';
// **************************************************************************
String _$aiModelSettingsControllerHash() =>
r'5bf80e85e734016b0fa80c6bb84315925f2595b3';
r'27a37c3fafb21b93a8b5523718f1537419bd382a';
/// See also [AiModelSettingsController].
@ProviderFor(AiModelSettingsController)

View File

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

View File

@@ -0,0 +1,322 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
import 'package:trainhub_flutter/presentation/settings/ai_model_settings_state.dart';
import 'package:trainhub_flutter/presentation/settings/widgets/settings_action_button.dart';
class AiModelsSection extends StatelessWidget {
const AiModelsSection({
super.key,
required this.modelState,
required this.onDownload,
required this.onValidate,
});
final AiModelSettingsState modelState;
final VoidCallback onDownload;
final VoidCallback onValidate;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'AI Models',
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textMuted,
letterSpacing: 0.8,
),
),
const SizedBox(height: UIConstants.spacing12),
Container(
decoration: BoxDecoration(
color: AppColors.surfaceContainer,
borderRadius: BorderRadius.circular(UIConstants.borderRadius),
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _ModelRow(
name: 'llama-server binary',
description: 'llama.cpp inference server (build b8130)',
icon: Icons.terminal_rounded,
),
const Divider(height: 1, color: AppColors.border),
const _ModelRow(
name: 'Nomic Embed v1.5 Q4_K_M',
description: 'Text embedding model (~300 MB)',
icon: Icons.hub_outlined,
),
const Divider(height: 1, color: AppColors.border),
const _ModelRow(
name: 'Qwen 2.5 7B Instruct Q4_K_M',
description: 'Chat / reasoning model (~4.7 GB)',
icon: Icons.psychology_outlined,
),
const Divider(height: 1, color: AppColors.border),
Padding(
padding: const EdgeInsets.all(UIConstants.spacing16),
child: _StatusAndActions(
modelState: modelState,
onDownload: onDownload,
onValidate: onValidate,
),
),
],
),
),
],
);
}
}
class _ModelRow extends StatelessWidget {
const _ModelRow({
required this.name,
required this.description,
required this.icon,
});
final String name;
final String description;
final IconData icon;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing16,
vertical: UIConstants.spacing12,
),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: AppColors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, size: 16, color: AppColors.textSecondary),
),
const SizedBox(width: UIConstants.spacing12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 2),
Text(
description,
style: GoogleFonts.inter(
fontSize: 12,
color: AppColors.textMuted,
),
),
],
),
),
],
),
);
}
}
class _StatusAndActions extends StatelessWidget {
const _StatusAndActions({
required this.modelState,
required this.onDownload,
required this.onValidate,
});
final AiModelSettingsState modelState;
final VoidCallback onDownload;
final VoidCallback onValidate;
@override
Widget build(BuildContext context) {
if (modelState.isDownloading) {
return _DownloadingView(modelState: modelState);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_StatusBadge(validated: modelState.areModelsValidated),
if (modelState.errorMessage != null) ...[
const SizedBox(height: UIConstants.spacing12),
ErrorRow(message: modelState.errorMessage!),
],
const SizedBox(height: UIConstants.spacing16),
if (!modelState.areModelsValidated)
SettingsActionButton(
label: 'Download AI Models (~5 GB)',
icon: Icons.download_rounded,
color: AppColors.accent,
textColor: AppColors.zinc950,
onPressed: onDownload,
)
else
SettingsActionButton(
label: 'Re-validate Files',
icon: Icons.verified_outlined,
color: Colors.transparent,
textColor: AppColors.textSecondary,
borderColor: AppColors.border,
onPressed: onValidate,
),
],
);
}
}
class _StatusBadge extends StatelessWidget {
const _StatusBadge({required this.validated});
final bool validated;
@override
Widget build(BuildContext context) {
final color = validated ? AppColors.success : AppColors.textMuted;
final bgColor =
validated ? AppColors.successMuted : AppColors.surfaceContainerHigh;
final label = validated ? 'Ready' : 'Missing';
final icon =
validated ? Icons.check_circle_outline : Icons.radio_button_unchecked;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Status: ',
style: GoogleFonts.inter(
fontSize: 13,
color: AppColors.textSecondary,
),
),
const SizedBox(width: UIConstants.spacing4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 13, color: color),
const SizedBox(width: 5),
Text(
label,
style: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w600,
color: color,
),
),
],
),
),
],
);
}
}
class _DownloadingView extends StatelessWidget {
const _DownloadingView({required this.modelState});
final AiModelSettingsState modelState;
@override
Widget build(BuildContext context) {
final pct = (modelState.progress * 100).toStringAsFixed(1);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
modelState.currentTask,
style: GoogleFonts.inter(
fontSize: 13,
color: AppColors.textSecondary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Text(
'$pct %',
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.accent,
),
),
],
),
const SizedBox(height: UIConstants.spacing8),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: modelState.progress,
minHeight: 6,
backgroundColor: AppColors.zinc800,
valueColor:
const AlwaysStoppedAnimation<Color>(AppColors.accent),
),
),
if (modelState.errorMessage != null) ...[
const SizedBox(height: UIConstants.spacing12),
ErrorRow(message: modelState.errorMessage!),
],
],
);
}
}
class ErrorRow extends StatelessWidget {
const ErrorRow({super.key, required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.error_outline_rounded,
color: AppColors.destructive,
size: 14,
),
const SizedBox(width: UIConstants.spacing8),
Expanded(
child: Text(
message,
style: GoogleFonts.inter(
fontSize: 12,
color: AppColors.destructive,
height: 1.4,
),
),
),
],
);
}
}

View File

@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
class KnowledgeBaseSection extends StatelessWidget {
const KnowledgeBaseSection({super.key, required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Knowledge Base',
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textMuted,
letterSpacing: 0.8,
),
),
const SizedBox(height: UIConstants.spacing12),
_KnowledgeBaseCard(onTap: onTap),
],
);
}
}
class _KnowledgeBaseCard extends StatefulWidget {
const _KnowledgeBaseCard({required this.onTap});
final VoidCallback onTap;
@override
State<_KnowledgeBaseCard> createState() => _KnowledgeBaseCardState();
}
class _KnowledgeBaseCardState extends State<_KnowledgeBaseCard> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: UIConstants.animationDuration,
decoration: BoxDecoration(
color: _hovered
? AppColors.surfaceContainerHigh
: AppColors.surfaceContainer,
borderRadius: BorderRadius.circular(UIConstants.borderRadius),
border: Border.all(
color: _hovered
? AppColors.accent.withValues(alpha: 0.3)
: AppColors.border,
),
),
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing16,
vertical: UIConstants.spacing16,
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColors.accentMuted,
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.hub_outlined,
color: AppColors.accent,
size: 20,
),
),
const SizedBox(width: UIConstants.spacing16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Manage Knowledge Base',
style: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 3),
Text(
'Add trainer notes to give the AI context-aware answers.',
style: GoogleFonts.inter(
fontSize: 12,
color: AppColors.textMuted,
),
),
],
),
),
Icon(
Icons.chevron_right_rounded,
color: _hovered ? AppColors.accent : AppColors.textMuted,
size: 20,
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
class SettingsActionButton extends StatefulWidget {
const SettingsActionButton({
super.key,
required this.label,
required this.icon,
required this.color,
required this.textColor,
required this.onPressed,
this.borderColor,
});
final String label;
final IconData icon;
final Color color;
final Color textColor;
final Color? borderColor;
final VoidCallback onPressed;
@override
State<SettingsActionButton> createState() => _SettingsActionButtonState();
}
class _SettingsActionButtonState extends State<SettingsActionButton> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final hasBorder = widget.borderColor != null;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: AnimatedContainer(
duration: UIConstants.animationDuration,
height: 40,
decoration: BoxDecoration(
color: hasBorder
? (_hovered ? AppColors.zinc800 : Colors.transparent)
: (_hovered
? widget.color.withValues(alpha: 0.85)
: widget.color),
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
border: hasBorder ? Border.all(color: widget.borderColor!) : null,
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius:
BorderRadius.circular(UIConstants.smallBorderRadius),
onTap: widget.onPressed,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing16,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(widget.icon, size: 16, color: widget.textColor),
const SizedBox(width: UIConstants.spacing8),
Text(
widget.label,
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w600,
color: widget.textColor,
),
),
],
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
class SettingsTopBar extends StatelessWidget {
const SettingsTopBar({super.key, required this.onBack});
final VoidCallback onBack;
@override
Widget build(BuildContext context) {
return Container(
height: 52,
padding: const EdgeInsets.symmetric(horizontal: UIConstants.spacing16),
decoration: const BoxDecoration(
color: AppColors.surfaceContainer,
border: Border(bottom: BorderSide(color: AppColors.border)),
),
child: Row(
children: [
SettingsIconButton(
icon: Icons.arrow_back_rounded,
tooltip: 'Go back',
onTap: onBack,
),
const SizedBox(width: UIConstants.spacing12),
Text(
'Settings',
style: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
],
),
);
}
}
class SettingsIconButton extends StatefulWidget {
const SettingsIconButton({
super.key,
required this.icon,
required this.onTap,
this.tooltip = '',
});
final IconData icon;
final VoidCallback onTap;
final String tooltip;
@override
State<SettingsIconButton> createState() => _SettingsIconButtonState();
}
class _SettingsIconButtonState extends State<SettingsIconButton> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
return Tooltip(
message: widget.tooltip,
child: MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: UIConstants.animationDuration,
width: 32,
height: 32,
decoration: BoxDecoration(
color: _hovered ? AppColors.zinc800 : Colors.transparent,
borderRadius: BorderRadius.circular(6),
),
child: Icon(
widget.icon,
size: 18,
color:
_hovered ? AppColors.textPrimary : AppColors.textSecondary,
),
),
),
),
);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,135 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
import 'package:trainhub_flutter/presentation/settings/ai_model_settings_state.dart';
class DownloadProgress extends StatelessWidget {
const DownloadProgress({super.key, required this.modelState});
final AiModelSettingsState modelState;
@override
Widget build(BuildContext context) {
final pct = (modelState.progress * 100).toStringAsFixed(1);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: AppColors.accentMuted,
borderRadius: BorderRadius.circular(14),
),
child: const Icon(
Icons.download_rounded,
color: AppColors.accent,
size: 28,
),
),
const SizedBox(height: UIConstants.spacing24),
Text(
'Setting up AI models\u2026',
style: GoogleFonts.inter(
fontSize: 22,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
letterSpacing: -0.3,
),
),
const SizedBox(height: UIConstants.spacing8),
Text(
'Please keep the app open. This only happens once.',
style: GoogleFonts.inter(
fontSize: 13,
color: AppColors.textSecondary,
),
),
const SizedBox(height: UIConstants.spacing32),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: modelState.progress,
minHeight: 6,
backgroundColor: AppColors.zinc800,
valueColor:
const AlwaysStoppedAnimation<Color>(AppColors.accent),
),
),
const SizedBox(height: UIConstants.spacing12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
modelState.currentTask,
style: GoogleFonts.inter(
fontSize: 13,
color: AppColors.textSecondary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Text(
'$pct %',
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.accent,
),
),
],
),
if (modelState.errorMessage != null) ...[
const SizedBox(height: UIConstants.spacing16),
ErrorBanner(message: modelState.errorMessage!),
],
],
);
}
}
class ErrorBanner extends StatelessWidget {
const ErrorBanner({super.key, required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(UIConstants.spacing12),
decoration: BoxDecoration(
color: AppColors.destructiveMuted,
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
border: Border.all(
color: AppColors.destructive.withValues(alpha: 0.4),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.error_outline_rounded,
color: AppColors.destructive,
size: 16,
),
const SizedBox(width: UIConstants.spacing8),
Expanded(
child: Text(
message,
style: GoogleFonts.inter(
fontSize: 12,
color: AppColors.destructive,
height: 1.5,
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,208 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
import 'package:trainhub_flutter/presentation/welcome/widgets/welcome_buttons.dart';
class InitialPrompt extends StatelessWidget {
const InitialPrompt({
super.key,
required this.onDownload,
required this.onSkip,
});
final VoidCallback onDownload;
final VoidCallback onSkip;
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildLogoRow(),
const SizedBox(height: UIConstants.spacing32),
_buildHeadline(),
const SizedBox(height: UIConstants.spacing16),
_buildDescription(),
const SizedBox(height: UIConstants.spacing24),
_buildFeatureList(),
const SizedBox(height: UIConstants.spacing32),
_buildDownloadNotice(),
const SizedBox(height: UIConstants.spacing32),
_buildActionButtons(),
],
);
}
Widget _buildLogoRow() {
return Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: AppColors.accentMuted,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.fitness_center,
color: AppColors.accent,
size: 24,
),
),
const SizedBox(width: UIConstants.spacing12),
Text(
'TrainHub',
style: GoogleFonts.inter(
fontSize: 26,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
letterSpacing: -0.5,
),
),
],
);
}
Widget _buildHeadline() {
return Text(
'AI-powered coaching,\nright on your device.',
style: GoogleFonts.inter(
fontSize: 28,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
height: 1.25,
letterSpacing: -0.5,
),
);
}
Widget _buildDescription() {
return Text(
'TrainHub uses on-device AI models to give you intelligent '
'training advice, exercise analysis, and personalised chat — '
'with zero data sent to the cloud.',
style: GoogleFonts.inter(
fontSize: 14,
color: AppColors.textSecondary,
height: 1.6,
),
);
}
Widget _buildFeatureList() {
return const Column(
children: [
FeatureRow(
icon: Icons.lock_outline_rounded,
label: '100 % local — your data never leaves this machine.',
),
SizedBox(height: UIConstants.spacing12),
FeatureRow(
icon: Icons.psychology_outlined,
label: 'Qwen 2.5 7B chat model for training advice.',
),
SizedBox(height: UIConstants.spacing12),
FeatureRow(
icon: Icons.search_rounded,
label: 'Nomic embedding model for semantic exercise search.',
),
],
);
}
Widget _buildDownloadNotice() {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing16,
vertical: UIConstants.spacing12,
),
decoration: BoxDecoration(
color: AppColors.accentMuted,
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
border: Border.all(
color: AppColors.accent.withValues(alpha: 0.3),
),
),
child: Row(
children: [
const Icon(
Icons.download_outlined,
color: AppColors.accent,
size: 18,
),
const SizedBox(width: UIConstants.spacing12),
Expanded(
child: Text(
'The download is ~5 GB and only needs to happen once. '
'You can skip now and download later from Settings.',
style: GoogleFonts.inter(
fontSize: 13,
color: AppColors.accent,
height: 1.5,
),
),
),
],
),
);
}
Widget _buildActionButtons() {
return Row(
children: [
Expanded(
child: WelcomePrimaryButton(
label: 'Download Now',
icon: Icons.download_rounded,
onPressed: onDownload,
),
),
const SizedBox(width: UIConstants.spacing12),
Expanded(
child: WelcomeSecondaryButton(
label: 'Skip for Now',
onPressed: onSkip,
),
),
],
);
}
}
class FeatureRow extends StatelessWidget {
const FeatureRow({super.key, required this.icon, required this.label});
final IconData icon;
final String label;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: AppColors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(6),
),
child: Icon(icon, size: 14, color: AppColors.accent),
),
const SizedBox(width: UIConstants.spacing12),
Expanded(
child: Text(
label,
style: GoogleFonts.inter(
fontSize: 13,
color: AppColors.textSecondary,
height: 1.5,
),
),
),
],
);
}
}

View File

@@ -0,0 +1,119 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
class WelcomePrimaryButton extends StatefulWidget {
const WelcomePrimaryButton({
super.key,
required this.label,
required this.icon,
required this.onPressed,
});
final String label;
final IconData icon;
final VoidCallback onPressed;
@override
State<WelcomePrimaryButton> createState() => _WelcomePrimaryButtonState();
}
class _WelcomePrimaryButtonState extends State<WelcomePrimaryButton> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: AnimatedContainer(
duration: UIConstants.animationDuration,
height: 44,
decoration: BoxDecoration(
color: _hovered
? AppColors.accent.withValues(alpha: 0.85)
: AppColors.accent,
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius:
BorderRadius.circular(UIConstants.smallBorderRadius),
onTap: widget.onPressed,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(widget.icon, color: AppColors.zinc950, size: 16),
const SizedBox(width: UIConstants.spacing8),
Text(
widget.label,
style: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.zinc950,
),
),
],
),
),
),
),
);
}
}
class WelcomeSecondaryButton extends StatefulWidget {
const WelcomeSecondaryButton({
super.key,
required this.label,
required this.onPressed,
});
final String label;
final VoidCallback onPressed;
@override
State<WelcomeSecondaryButton> createState() =>
_WelcomeSecondaryButtonState();
}
class _WelcomeSecondaryButtonState extends State<WelcomeSecondaryButton> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: AnimatedContainer(
duration: UIConstants.animationDuration,
height: 44,
decoration: BoxDecoration(
color: _hovered ? AppColors.zinc800 : Colors.transparent,
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
border: Border.all(color: AppColors.border),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius:
BorderRadius.circular(UIConstants.smallBorderRadius),
onTap: widget.onPressed,
child: Center(
child: Text(
widget.label,
style: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
),
),
),
),
),
),
);
}
}