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