This commit is contained in:
@@ -1,30 +1,32 @@
|
||||
import 'dart:convert';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ai_constants.dart';
|
||||
import 'package:trainhub_flutter/domain/repositories/chat_repository.dart';
|
||||
import 'package:trainhub_flutter/domain/repositories/note_repository.dart';
|
||||
import 'package:trainhub_flutter/data/services/ai_process_manager.dart';
|
||||
import 'package:trainhub_flutter/injection.dart';
|
||||
import 'package:trainhub_flutter/presentation/chat/chat_state.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
part 'chat_controller.g.dart';
|
||||
|
||||
const _chatApiUrl = 'http://localhost:8080/v1/chat/completions';
|
||||
|
||||
/// Base system prompt that is always included.
|
||||
const _baseSystemPrompt =
|
||||
'You are a helpful AI fitness assistant for personal trainers. '
|
||||
'Help users design training plans, analyse exercise technique, '
|
||||
'and answer questions about sports science and nutrition.';
|
||||
@riverpod
|
||||
AiProcessManager aiProcessManager(AiProcessManagerRef ref) {
|
||||
final manager = getIt<AiProcessManager>();
|
||||
manager.addListener(() => ref.notifyListeners());
|
||||
return manager;
|
||||
}
|
||||
|
||||
@riverpod
|
||||
class ChatController extends _$ChatController {
|
||||
late ChatRepository _repo;
|
||||
late NoteRepository _noteRepo;
|
||||
|
||||
// Shared Dio client — generous timeout for 7B models running on CPU.
|
||||
final _dio = Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 30),
|
||||
receiveTimeout: const Duration(minutes: 5),
|
||||
connectTimeout: AiConstants.serverConnectTimeout,
|
||||
receiveTimeout: AiConstants.serverReceiveTimeout,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -32,14 +34,14 @@ class ChatController extends _$ChatController {
|
||||
Future<ChatState> build() async {
|
||||
_repo = getIt<ChatRepository>();
|
||||
_noteRepo = getIt<NoteRepository>();
|
||||
final aiManager = ref.read(aiProcessManagerProvider);
|
||||
if (aiManager.status == AiServerStatus.offline) {
|
||||
aiManager.startServers();
|
||||
}
|
||||
final sessions = await _repo.getAllSessions();
|
||||
return ChatState(sessions: sessions);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Session management (unchanged)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
Future<void> createSession() async {
|
||||
final session = await _repo.createSession();
|
||||
final sessions = await _repo.getAllSessions();
|
||||
@@ -72,28 +74,29 @@ class ChatController extends _$ChatController {
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Send message (RAG + Step D)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
Future<void> sendMessage(String content) async {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final sessionId = await _resolveSession(current, content);
|
||||
await _persistUserMessage(sessionId, content);
|
||||
final contextChunks = await _searchKnowledgeBase(content);
|
||||
final systemPrompt = _buildSystemPrompt(contextChunks);
|
||||
final history = _buildHistory();
|
||||
final fullAiResponse = await _streamResponse(systemPrompt, history);
|
||||
await _persistAssistantResponse(sessionId, content, fullAiResponse);
|
||||
}
|
||||
|
||||
// ── 1. Resolve / create a session ─────────────────────────────────────
|
||||
String sessionId;
|
||||
if (current.activeSession == null) {
|
||||
final session = await _repo.createSession();
|
||||
sessionId = session.id;
|
||||
final sessions = await _repo.getAllSessions();
|
||||
state = AsyncValue.data(
|
||||
current.copyWith(sessions: sessions, activeSession: session),
|
||||
);
|
||||
} else {
|
||||
sessionId = current.activeSession!.id;
|
||||
}
|
||||
Future<String> _resolveSession(ChatState current, String content) async {
|
||||
if (current.activeSession != null) return current.activeSession!.id;
|
||||
final session = await _repo.createSession();
|
||||
final sessions = await _repo.getAllSessions();
|
||||
state = AsyncValue.data(
|
||||
current.copyWith(sessions: sessions, activeSession: session),
|
||||
);
|
||||
return session.id;
|
||||
}
|
||||
|
||||
// ── 2. Persist user message & show typing indicator ───────────────────
|
||||
Future<void> _persistUserMessage(String sessionId, String content) async {
|
||||
await _repo.addMessage(
|
||||
sessionId: sessionId,
|
||||
role: 'user',
|
||||
@@ -104,95 +107,196 @@ class ChatController extends _$ChatController {
|
||||
state.valueOrNull!.copyWith(
|
||||
messages: messagesAfterUser,
|
||||
isTyping: true,
|
||||
thinkingSteps: [],
|
||||
streamingContent: '',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── 3. RAG: retrieve relevant chunks from the knowledge base ──────────
|
||||
// Gracefully degrades — if Nomic server is unavailable or no chunks
|
||||
// exist, the chat still works with the base system prompt alone.
|
||||
Future<List<String>> _searchKnowledgeBase(String query) async {
|
||||
final searchStep = _createStep('Searching knowledge base...');
|
||||
List<String> contextChunks = [];
|
||||
try {
|
||||
contextChunks = await _noteRepo.searchSimilar(content, topK: 3);
|
||||
} catch (_) {
|
||||
// Nomic server not running or no chunks stored — continue without RAG.
|
||||
contextChunks = await _noteRepo.searchSimilar(query, topK: 3);
|
||||
if (contextChunks.isNotEmpty) {
|
||||
_updateStep(
|
||||
searchStep.id,
|
||||
status: ThinkingStepStatus.completed,
|
||||
title: 'Found ${contextChunks.length} documents',
|
||||
details: 'Context added for assistant.',
|
||||
);
|
||||
} else {
|
||||
_updateStep(
|
||||
searchStep.id,
|
||||
status: ThinkingStepStatus.completed,
|
||||
title: 'No matching documents in knowledge base',
|
||||
details: 'Responding based on general knowledge.',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_updateStep(
|
||||
searchStep.id,
|
||||
status: ThinkingStepStatus.error,
|
||||
title: 'Knowledge base search error',
|
||||
details: e.toString(),
|
||||
);
|
||||
}
|
||||
return contextChunks;
|
||||
}
|
||||
|
||||
// ── 4. Build enriched system prompt (Step D) ──────────────────────────
|
||||
final systemPrompt = _buildSystemPrompt(contextChunks);
|
||||
|
||||
// Build the full conversation history so the model maintains context.
|
||||
final history = messagesAfterUser
|
||||
.map(
|
||||
(m) => <String, String>{
|
||||
'role': m.isUser ? 'user' : 'assistant',
|
||||
'content': m.content,
|
||||
},
|
||||
)
|
||||
List<Map<String, String>> _buildHistory() {
|
||||
final messages = state.valueOrNull?.messages ?? [];
|
||||
return messages
|
||||
.map((m) => <String, String>{
|
||||
'role': m.isUser ? 'user' : 'assistant',
|
||||
'content': m.content,
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
// ── 5. POST to Qwen (http://localhost:8080/v1/chat/completions) ────────
|
||||
String aiResponse;
|
||||
Future<String> _streamResponse(
|
||||
String systemPrompt,
|
||||
List<Map<String, String>> history,
|
||||
) async {
|
||||
final generateStep = _createStep('Generating response...');
|
||||
String fullAiResponse = '';
|
||||
try {
|
||||
final response = await _dio.post<Map<String, dynamic>>(
|
||||
_chatApiUrl,
|
||||
final response = await _dio.post<ResponseBody>(
|
||||
AiConstants.chatApiUrl,
|
||||
options: Options(responseType: ResponseType.stream),
|
||||
data: {
|
||||
'messages': [
|
||||
{'role': 'system', 'content': systemPrompt},
|
||||
...history,
|
||||
],
|
||||
'temperature': 0.7,
|
||||
'temperature': AiConstants.chatTemperature,
|
||||
'stream': true,
|
||||
},
|
||||
);
|
||||
aiResponse =
|
||||
response.data!['choices'][0]['message']['content'] as String;
|
||||
_updateStep(
|
||||
generateStep.id,
|
||||
status: ThinkingStepStatus.running,
|
||||
title: 'Writing...',
|
||||
);
|
||||
final stream = response.data!.stream;
|
||||
await for (final chunk in stream) {
|
||||
final textChunk = utf8.decode(chunk);
|
||||
for (final line in textChunk.split('\n')) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
final dataStr = line.substring(6).trim();
|
||||
if (dataStr == '[DONE]') break;
|
||||
if (dataStr.isEmpty) continue;
|
||||
try {
|
||||
final data = jsonDecode(dataStr);
|
||||
final delta = data['choices']?[0]?['delta']?['content'] ?? '';
|
||||
if (delta.isNotEmpty) {
|
||||
fullAiResponse += delta;
|
||||
final updatedState = state.valueOrNull;
|
||||
if (updatedState != null) {
|
||||
state = AsyncValue.data(
|
||||
updatedState.copyWith(streamingContent: fullAiResponse),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
_updateStep(
|
||||
generateStep.id,
|
||||
status: ThinkingStepStatus.completed,
|
||||
title: 'Response generated',
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
aiResponse =
|
||||
'Could not reach the AI server (${e.message}). '
|
||||
'Make sure AI models are downloaded and the inference servers have '
|
||||
'had time to start.';
|
||||
fullAiResponse += '\n\n[AI model communication error]';
|
||||
_updateStep(
|
||||
generateStep.id,
|
||||
status: ThinkingStepStatus.error,
|
||||
title: 'Generation failed',
|
||||
details: '${e.message}',
|
||||
);
|
||||
} catch (e) {
|
||||
aiResponse = 'An unexpected error occurred: $e';
|
||||
fullAiResponse += '\n\n[Unexpected error]';
|
||||
_updateStep(
|
||||
generateStep.id,
|
||||
status: ThinkingStepStatus.error,
|
||||
title: 'Generation failed',
|
||||
details: e.toString(),
|
||||
);
|
||||
}
|
||||
return fullAiResponse;
|
||||
}
|
||||
|
||||
// ── 6. Persist response & update session title on first exchange ───────
|
||||
Future<void> _persistAssistantResponse(
|
||||
String sessionId,
|
||||
String userContent,
|
||||
String aiResponse,
|
||||
) async {
|
||||
await _repo.addMessage(
|
||||
sessionId: sessionId,
|
||||
role: 'assistant',
|
||||
content: aiResponse,
|
||||
);
|
||||
|
||||
final messagesAfterAi = await _repo.getMessages(sessionId);
|
||||
if (messagesAfterAi.length <= 2) {
|
||||
final title =
|
||||
content.length > 30 ? '${content.substring(0, 30)}…' : content;
|
||||
final title = userContent.length > 30
|
||||
? '${userContent.substring(0, 30)}…'
|
||||
: userContent;
|
||||
await _repo.updateSessionTitle(sessionId, title);
|
||||
}
|
||||
|
||||
final sessions = await _repo.getAllSessions();
|
||||
state = AsyncValue.data(
|
||||
state.valueOrNull!.copyWith(
|
||||
messages: messagesAfterAi,
|
||||
isTyping: false,
|
||||
streamingContent: null,
|
||||
thinkingSteps: [],
|
||||
sessions: sessions,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
ThinkingStep _createStep(String title) {
|
||||
final step = ThinkingStep(
|
||||
id: const Uuid().v4(),
|
||||
title: title,
|
||||
status: ThinkingStepStatus.pending,
|
||||
);
|
||||
final current = state.valueOrNull;
|
||||
if (current != null) {
|
||||
state = AsyncValue.data(
|
||||
current.copyWith(thinkingSteps: [...current.thinkingSteps, step]),
|
||||
);
|
||||
}
|
||||
return step;
|
||||
}
|
||||
|
||||
void _updateStep(
|
||||
String id, {
|
||||
ThinkingStepStatus? status,
|
||||
String? title,
|
||||
String? details,
|
||||
}) {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final updatedSteps = current.thinkingSteps.map((s) {
|
||||
if (s.id != id) return s;
|
||||
return s.copyWith(
|
||||
status: status ?? s.status,
|
||||
title: title ?? s.title,
|
||||
details: details ?? s.details,
|
||||
);
|
||||
}).toList();
|
||||
state = AsyncValue.data(current.copyWith(thinkingSteps: updatedSteps));
|
||||
}
|
||||
|
||||
/// Builds the system prompt, injecting RAG context when available.
|
||||
static String _buildSystemPrompt(List<String> chunks) {
|
||||
if (chunks.isEmpty) return _baseSystemPrompt;
|
||||
|
||||
if (chunks.isEmpty) return AiConstants.baseSystemPrompt;
|
||||
final contextBlock = chunks
|
||||
.asMap()
|
||||
.entries
|
||||
.map((e) => '[${e.key + 1}] ${e.value}')
|
||||
.join('\n\n');
|
||||
|
||||
return '$_baseSystemPrompt\n\n'
|
||||
return '${AiConstants.baseSystemPrompt}\n\n'
|
||||
'### Relevant notes from the trainer\'s knowledge base:\n'
|
||||
'$contextBlock\n\n'
|
||||
'Use the above context to inform your response when relevant. '
|
||||
|
||||
@@ -6,7 +6,24 @@ part of 'chat_controller.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$chatControllerHash() => r'06ffc6b53c1d878ffc0a758da4f7ee1261ae1340';
|
||||
String _$aiProcessManagerHash() => r'ae77b1e18c06f4192092e1489744626fc8516776';
|
||||
|
||||
/// See also [aiProcessManager].
|
||||
@ProviderFor(aiProcessManager)
|
||||
final aiProcessManagerProvider = AutoDisposeProvider<AiProcessManager>.internal(
|
||||
aiProcessManager,
|
||||
name: r'aiProcessManagerProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$aiProcessManagerHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef AiProcessManagerRef = AutoDisposeProviderRef<AiProcessManager>;
|
||||
String _$chatControllerHash() => r'266d8a5ac91cbe6c112f85f15adf5a8046e85682';
|
||||
|
||||
/// See also [ChatController].
|
||||
@ProviderFor(ChatController)
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/router/app_router.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/chat_message.dart';
|
||||
import 'package:trainhub_flutter/data/services/ai_process_manager.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/chat_session.dart';
|
||||
import 'package:trainhub_flutter/presentation/chat/chat_controller.dart';
|
||||
import 'package:trainhub_flutter/presentation/chat/chat_state.dart';
|
||||
import 'package:trainhub_flutter/presentation/chat/widgets/message_bubble.dart';
|
||||
import 'package:trainhub_flutter/presentation/chat/widgets/missing_models_state.dart';
|
||||
import 'package:trainhub_flutter/presentation/chat/widgets/new_chat_button.dart';
|
||||
import 'package:trainhub_flutter/presentation/chat/widgets/typing_bubble.dart';
|
||||
import 'package:trainhub_flutter/presentation/chat/widgets/typing_indicator.dart';
|
||||
import 'package:trainhub_flutter/presentation/settings/ai_model_settings_controller.dart';
|
||||
|
||||
@RoutePage()
|
||||
@@ -49,11 +51,10 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
|
||||
void _sendMessage(ChatController controller) {
|
||||
final text = _inputController.text.trim();
|
||||
if (text.isNotEmpty) {
|
||||
controller.sendMessage(text);
|
||||
_inputController.clear();
|
||||
_inputFocusNode.requestFocus();
|
||||
}
|
||||
if (text.isEmpty) return;
|
||||
controller.sendMessage(text);
|
||||
_inputController.clear();
|
||||
_inputFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
String _formatTimestamp(String timestamp) {
|
||||
@@ -75,16 +76,10 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// ── Gate: check whether AI models are present on disk ─────────────────
|
||||
final modelsValidated = ref
|
||||
.watch(aiModelSettingsControllerProvider)
|
||||
.areModelsValidated;
|
||||
|
||||
// Watch chat state regardless of gate so Riverpod keeps the provider
|
||||
// alive and the scroll listener is always registered.
|
||||
final modelsValidated =
|
||||
ref.watch(aiModelSettingsControllerProvider).areModelsValidated;
|
||||
final state = ref.watch(chatControllerProvider);
|
||||
final controller = ref.read(chatControllerProvider.notifier);
|
||||
|
||||
ref.listen(chatControllerProvider, (prev, next) {
|
||||
if (next.hasValue &&
|
||||
(prev?.value?.messages.length ?? 0) <
|
||||
@@ -97,16 +92,12 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
_scrollToBottom();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Show "models missing" placeholder ─────────────────────────────────
|
||||
if (!modelsValidated) {
|
||||
return const Scaffold(
|
||||
backgroundColor: AppColors.surface,
|
||||
body: _MissingModelsState(),
|
||||
body: MissingModelsState(),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Normal chat UI ─────────────────────────────────────────────────────
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.surface,
|
||||
body: Row(
|
||||
@@ -118,9 +109,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Side Panel
|
||||
// ---------------------------------------------------------------------------
|
||||
Widget _buildSidePanel(
|
||||
AsyncValue<ChatState> asyncState,
|
||||
ChatController controller,
|
||||
@@ -129,9 +117,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
width: 250,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
border: Border(
|
||||
right: BorderSide(color: AppColors.border, width: 1),
|
||||
),
|
||||
border: Border(right: BorderSide(color: AppColors.border, width: 1)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -139,19 +125,17 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
padding: const EdgeInsets.all(UIConstants.spacing12),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: _NewChatButton(onPressed: controller.createSession),
|
||||
child: NewChatButton(onPressed: controller.createSession),
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(height: 1, color: AppColors.border),
|
||||
|
||||
Expanded(
|
||||
child: asyncState.when(
|
||||
data: (data) {
|
||||
if (data.sessions.isEmpty) {
|
||||
return Center(
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UIConstants.spacing24),
|
||||
padding: EdgeInsets.all(UIConstants.spacing24),
|
||||
child: Text(
|
||||
'No conversations yet',
|
||||
style: TextStyle(
|
||||
@@ -169,23 +153,18 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
itemCount: data.sessions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final session = data.sessions[index];
|
||||
final isActive =
|
||||
session.id == data.activeSession?.id;
|
||||
return _buildSessionTile(
|
||||
session: session,
|
||||
isActive: isActive,
|
||||
isActive: session.id == data.activeSession?.id,
|
||||
controller: controller,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
error: (_, __) => Center(
|
||||
error: (_, __) => const Center(
|
||||
child: Text(
|
||||
'Error loading sessions',
|
||||
style: TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 13,
|
||||
),
|
||||
style: TextStyle(color: AppColors.textMuted, fontSize: 13),
|
||||
),
|
||||
),
|
||||
loading: () => const Center(
|
||||
@@ -211,7 +190,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
required ChatController controller,
|
||||
}) {
|
||||
final isHovered = _hoveredSessionId == session.id;
|
||||
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hoveredSessionId = session.id),
|
||||
onExit: (_) => setState(() {
|
||||
@@ -240,7 +218,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
border: isActive
|
||||
? Border.all(
|
||||
color: AppColors.accent.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
@@ -283,7 +260,8 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
Icons.delete_outline_rounded,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
onPressed: () => controller.deleteSession(session.id),
|
||||
onPressed: () =>
|
||||
controller.deleteSession(session.id),
|
||||
tooltip: 'Delete',
|
||||
),
|
||||
),
|
||||
@@ -296,9 +274,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chat Area
|
||||
// ---------------------------------------------------------------------------
|
||||
Widget _buildChatArea(
|
||||
AsyncValue<ChatState> asyncState,
|
||||
ChatController controller,
|
||||
@@ -315,29 +290,38 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
horizontal: UIConstants.spacing24,
|
||||
vertical: UIConstants.spacing16,
|
||||
),
|
||||
itemCount: data.messages.length + (data.isTyping ? 1 : 0),
|
||||
itemCount:
|
||||
data.messages.length + (data.isTyping ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == data.messages.length) {
|
||||
return const _TypingIndicator();
|
||||
if (data.thinkingSteps.isNotEmpty ||
|
||||
(data.streamingContent != null &&
|
||||
data.streamingContent!.isNotEmpty)) {
|
||||
return TypingBubble(
|
||||
thinkingSteps: data.thinkingSteps,
|
||||
streamingContent: data.streamingContent,
|
||||
);
|
||||
}
|
||||
return const TypingIndicator();
|
||||
}
|
||||
final msg = data.messages[index];
|
||||
return _MessageBubble(
|
||||
return MessageBubble(
|
||||
message: msg,
|
||||
formattedTime: _formatTimestamp(msg.createdAt),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
error: (e, _) => Center(
|
||||
error: (e, _) => const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icon(
|
||||
Icons.error_outline_rounded,
|
||||
color: AppColors.destructive,
|
||||
size: 40,
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing12),
|
||||
SizedBox(height: UIConstants.spacing12),
|
||||
Text(
|
||||
'Something went wrong',
|
||||
style: TextStyle(
|
||||
@@ -402,15 +386,28 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input Bar
|
||||
// ---------------------------------------------------------------------------
|
||||
Widget _buildInputBar(
|
||||
AsyncValue<ChatState> asyncState,
|
||||
ChatController controller,
|
||||
) {
|
||||
final aiManager = ref.watch(aiProcessManagerProvider);
|
||||
final isTyping = asyncState.valueOrNull?.isTyping ?? false;
|
||||
|
||||
final isStarting = aiManager.status == AiServerStatus.starting;
|
||||
final isError = aiManager.status == AiServerStatus.error;
|
||||
final isReady = aiManager.status == AiServerStatus.ready;
|
||||
String? statusMessage;
|
||||
Color statusColor = AppColors.textMuted;
|
||||
if (isStarting) {
|
||||
statusMessage =
|
||||
'Starting AI inference server (this may take a moment)...';
|
||||
statusColor = AppColors.info;
|
||||
} else if (isError) {
|
||||
statusMessage =
|
||||
'AI Server Error: ${aiManager.errorMessage ?? "Unknown error"}';
|
||||
statusColor = AppColors.destructive;
|
||||
} else if (!isReady) {
|
||||
statusMessage = 'AI Server offline. Reconnecting...';
|
||||
}
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
@@ -420,466 +417,136 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
horizontal: UIConstants.spacing16,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.borderRadius),
|
||||
border: Border.all(color: AppColors.border, width: 1),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _inputController,
|
||||
focusNode: _inputFocusNode,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 4,
|
||||
minLines: 1,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Type a message...',
|
||||
hintStyle: TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 14,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing16,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
),
|
||||
onSubmitted: (_) => _sendMessage(controller),
|
||||
textInputAction: TextInputAction.send,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Material(
|
||||
color: isTyping ? AppColors.zinc700 : AppColors.accent,
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.borderRadius),
|
||||
child: InkWell(
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.borderRadius),
|
||||
onTap: isTyping ? null : () => _sendMessage(controller),
|
||||
child: Icon(
|
||||
Icons.arrow_upward_rounded,
|
||||
color:
|
||||
isTyping ? AppColors.textMuted : AppColors.zinc950,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Missing Models State — shown when AI files have not been downloaded yet
|
||||
// =============================================================================
|
||||
class _MissingModelsState extends StatelessWidget {
|
||||
const _MissingModelsState();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Icon
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.cloud_download_outlined,
|
||||
size: 36,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: UIConstants.spacing24),
|
||||
|
||||
Text(
|
||||
'AI models are missing.',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: UIConstants.spacing8),
|
||||
|
||||
Text(
|
||||
'Please download them to use the AI chat.',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: UIConstants.spacing32),
|
||||
|
||||
// "Go to Settings" button
|
||||
_GoToSettingsButton(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GoToSettingsButton extends StatefulWidget {
|
||||
@override
|
||||
State<_GoToSettingsButton> createState() => _GoToSettingsButtonState();
|
||||
}
|
||||
|
||||
class _GoToSettingsButtonState extends State<_GoToSettingsButton> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: AnimatedContainer(
|
||||
duration: UIConstants.animationDuration,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered
|
||||
? AppColors.accent.withValues(alpha: 0.85)
|
||||
: AppColors.accent,
|
||||
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
onTap: () => context.router.push(const SettingsRoute()),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing24,
|
||||
),
|
||||
if (statusMessage != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: UIConstants.spacing8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.settings_outlined,
|
||||
size: 16,
|
||||
color: AppColors.zinc950,
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
Text(
|
||||
'Go to Settings',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.zinc950,
|
||||
if (isStarting)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: const CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.info,
|
||||
),
|
||||
)
|
||||
else if (isError)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 8),
|
||||
child: Icon(
|
||||
Icons.error_outline,
|
||||
size: 14,
|
||||
color: AppColors.destructive,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
statusMessage,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
color: statusColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// New Chat Button
|
||||
// =============================================================================
|
||||
class _NewChatButton extends StatefulWidget {
|
||||
const _NewChatButton({required this.onPressed});
|
||||
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
State<_NewChatButton> createState() => _NewChatButtonState();
|
||||
}
|
||||
|
||||
class _NewChatButtonState extends State<_NewChatButton> {
|
||||
bool _isHovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _isHovered = true),
|
||||
onExit: (_) => setState(() => _isHovered = false),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
decoration: BoxDecoration(
|
||||
color: _isHovered
|
||||
? AppColors.zinc700
|
||||
: AppColors.surfaceContainerHigh,
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
border: Border.all(color: AppColors.border, width: 1),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
onTap: widget.onPressed,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing12,
|
||||
vertical: UIConstants.spacing8,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: const [
|
||||
Icon(
|
||||
Icons.add_rounded,
|
||||
size: 16,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
SizedBox(width: UIConstants.spacing8),
|
||||
Text(
|
||||
'New Chat',
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Message Bubble
|
||||
// =============================================================================
|
||||
class _MessageBubble extends StatelessWidget {
|
||||
const _MessageBubble({
|
||||
required this.message,
|
||||
required this.formattedTime,
|
||||
});
|
||||
|
||||
final ChatMessageEntity message;
|
||||
final String formattedTime;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isUser = message.isUser;
|
||||
final maxWidth = MediaQuery.of(context).size.width * 0.55;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UIConstants.spacing12),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
isUser ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isUser) ...[
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.auto_awesome_rounded,
|
||||
size: 14,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
],
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: isUser
|
||||
? CrossAxisAlignment.end
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing16,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isUser
|
||||
? AppColors.zinc700
|
||||
: AppColors.surfaceContainer,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(16),
|
||||
topRight: const Radius.circular(16),
|
||||
bottomLeft: isUser
|
||||
? const Radius.circular(16)
|
||||
: const Radius.circular(4),
|
||||
bottomRight: isUser
|
||||
? const Radius.circular(4)
|
||||
: const Radius.circular(16),
|
||||
),
|
||||
border: isUser
|
||||
? null
|
||||
: Border.all(color: AppColors.border, width: 1),
|
||||
),
|
||||
child: SelectableText(
|
||||
message.content,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 14,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text(
|
||||
formattedTime,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isUser) ...[
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.accent.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.person_rounded,
|
||||
size: 14,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Typing Indicator (3 animated bouncing dots)
|
||||
// =============================================================================
|
||||
class _TypingIndicator extends StatefulWidget {
|
||||
const _TypingIndicator();
|
||||
|
||||
@override
|
||||
State<_TypingIndicator> createState() => _TypingIndicatorState();
|
||||
}
|
||||
|
||||
class _TypingIndicatorState extends State<_TypingIndicator>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1200),
|
||||
)..repeat();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UIConstants.spacing12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.auto_awesome_rounded,
|
||||
size: 14,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing16,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
bottomLeft: Radius.circular(4),
|
||||
bottomRight: Radius.circular(16),
|
||||
),
|
||||
border: Border.all(color: AppColors.border, width: 1),
|
||||
),
|
||||
child: AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, _) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(3, (index) {
|
||||
final delay = index * 0.2;
|
||||
final t = (_controller.value - delay) % 1.0;
|
||||
final bounce =
|
||||
t < 0.5 ? math.sin(t * math.pi * 2) * 4.0 : 0.0;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(left: index == 0 ? 0 : 4),
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, -bounce.abs()),
|
||||
child: Container(
|
||||
width: 7,
|
||||
height: 7,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.textMuted,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
if (isError)
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
onPressed: () => aiManager.startServers(),
|
||||
child: Text(
|
||||
'Retry',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.borderRadius),
|
||||
border:
|
||||
Border.all(color: AppColors.border, width: 1),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _inputController,
|
||||
focusNode: _inputFocusNode,
|
||||
enabled: isReady,
|
||||
style: TextStyle(
|
||||
color: isReady
|
||||
? AppColors.textPrimary
|
||||
: AppColors.textMuted,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 4,
|
||||
minLines: 1,
|
||||
decoration: InputDecoration(
|
||||
hintText: isReady
|
||||
? 'Type a message...'
|
||||
: 'Waiting for AI...',
|
||||
hintStyle: const TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 14,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing16,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
disabledBorder: InputBorder.none,
|
||||
),
|
||||
onSubmitted: isReady
|
||||
? (_) => _sendMessage(controller)
|
||||
: null,
|
||||
textInputAction: TextInputAction.send,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Material(
|
||||
color: (isTyping || !isReady)
|
||||
? AppColors.zinc700
|
||||
: AppColors.accent,
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.borderRadius),
|
||||
child: InkWell(
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.borderRadius),
|
||||
onTap: (isTyping || !isReady)
|
||||
? null
|
||||
: () => _sendMessage(controller),
|
||||
child: Icon(
|
||||
Icons.arrow_upward_rounded,
|
||||
color: (isTyping || !isReady)
|
||||
? AppColors.textMuted
|
||||
: AppColors.zinc950,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -4,6 +4,18 @@ import 'package:trainhub_flutter/domain/entities/chat_message.dart';
|
||||
|
||||
part 'chat_state.freezed.dart';
|
||||
|
||||
enum ThinkingStepStatus { pending, running, completed, error }
|
||||
|
||||
@freezed
|
||||
class ThinkingStep with _$ThinkingStep {
|
||||
const factory ThinkingStep({
|
||||
required String id,
|
||||
required String title,
|
||||
@Default(ThinkingStepStatus.running) ThinkingStepStatus status,
|
||||
String? details,
|
||||
}) = _ThinkingStep;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatState with _$ChatState {
|
||||
const factory ChatState({
|
||||
@@ -11,5 +23,7 @@ class ChatState with _$ChatState {
|
||||
ChatSessionEntity? activeSession,
|
||||
@Default([]) List<ChatMessageEntity> messages,
|
||||
@Default(false) bool isTyping,
|
||||
@Default([]) List<ThinkingStep> thinkingSteps,
|
||||
String? streamingContent,
|
||||
}) = _ChatState;
|
||||
}
|
||||
|
||||
@@ -15,12 +15,219 @@ final _privateConstructorUsedError = UnsupportedError(
|
||||
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
|
||||
);
|
||||
|
||||
/// @nodoc
|
||||
mixin _$ThinkingStep {
|
||||
String get id => throw _privateConstructorUsedError;
|
||||
String get title => throw _privateConstructorUsedError;
|
||||
ThinkingStepStatus get status => throw _privateConstructorUsedError;
|
||||
String? get details => throw _privateConstructorUsedError;
|
||||
|
||||
/// Create a copy of ThinkingStep
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
$ThinkingStepCopyWith<ThinkingStep> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $ThinkingStepCopyWith<$Res> {
|
||||
factory $ThinkingStepCopyWith(
|
||||
ThinkingStep value,
|
||||
$Res Function(ThinkingStep) then,
|
||||
) = _$ThinkingStepCopyWithImpl<$Res, ThinkingStep>;
|
||||
@useResult
|
||||
$Res call({
|
||||
String id,
|
||||
String title,
|
||||
ThinkingStepStatus status,
|
||||
String? details,
|
||||
});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$ThinkingStepCopyWithImpl<$Res, $Val extends ThinkingStep>
|
||||
implements $ThinkingStepCopyWith<$Res> {
|
||||
_$ThinkingStepCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
/// Create a copy of ThinkingStep
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? title = null,
|
||||
Object? status = null,
|
||||
Object? details = freezed,
|
||||
}) {
|
||||
return _then(
|
||||
_value.copyWith(
|
||||
id: null == id
|
||||
? _value.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
title: null == title
|
||||
? _value.title
|
||||
: title // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
status: null == status
|
||||
? _value.status
|
||||
: status // ignore: cast_nullable_to_non_nullable
|
||||
as ThinkingStepStatus,
|
||||
details: freezed == details
|
||||
? _value.details
|
||||
: details // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
)
|
||||
as $Val,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$ThinkingStepImplCopyWith<$Res>
|
||||
implements $ThinkingStepCopyWith<$Res> {
|
||||
factory _$$ThinkingStepImplCopyWith(
|
||||
_$ThinkingStepImpl value,
|
||||
$Res Function(_$ThinkingStepImpl) then,
|
||||
) = __$$ThinkingStepImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call({
|
||||
String id,
|
||||
String title,
|
||||
ThinkingStepStatus status,
|
||||
String? details,
|
||||
});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$ThinkingStepImplCopyWithImpl<$Res>
|
||||
extends _$ThinkingStepCopyWithImpl<$Res, _$ThinkingStepImpl>
|
||||
implements _$$ThinkingStepImplCopyWith<$Res> {
|
||||
__$$ThinkingStepImplCopyWithImpl(
|
||||
_$ThinkingStepImpl _value,
|
||||
$Res Function(_$ThinkingStepImpl) _then,
|
||||
) : super(_value, _then);
|
||||
|
||||
/// Create a copy of ThinkingStep
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? title = null,
|
||||
Object? status = null,
|
||||
Object? details = freezed,
|
||||
}) {
|
||||
return _then(
|
||||
_$ThinkingStepImpl(
|
||||
id: null == id
|
||||
? _value.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
title: null == title
|
||||
? _value.title
|
||||
: title // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
status: null == status
|
||||
? _value.status
|
||||
: status // ignore: cast_nullable_to_non_nullable
|
||||
as ThinkingStepStatus,
|
||||
details: freezed == details
|
||||
? _value.details
|
||||
: details // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
class _$ThinkingStepImpl implements _ThinkingStep {
|
||||
const _$ThinkingStepImpl({
|
||||
required this.id,
|
||||
required this.title,
|
||||
this.status = ThinkingStepStatus.running,
|
||||
this.details,
|
||||
});
|
||||
|
||||
@override
|
||||
final String id;
|
||||
@override
|
||||
final String title;
|
||||
@override
|
||||
@JsonKey()
|
||||
final ThinkingStepStatus status;
|
||||
@override
|
||||
final String? details;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ThinkingStep(id: $id, title: $title, status: $status, details: $details)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$ThinkingStepImpl &&
|
||||
(identical(other.id, id) || other.id == id) &&
|
||||
(identical(other.title, title) || other.title == title) &&
|
||||
(identical(other.status, status) || other.status == status) &&
|
||||
(identical(other.details, details) || other.details == details));
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType, id, title, status, details);
|
||||
|
||||
/// Create a copy of ThinkingStep
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$ThinkingStepImplCopyWith<_$ThinkingStepImpl> get copyWith =>
|
||||
__$$ThinkingStepImplCopyWithImpl<_$ThinkingStepImpl>(this, _$identity);
|
||||
}
|
||||
|
||||
abstract class _ThinkingStep implements ThinkingStep {
|
||||
const factory _ThinkingStep({
|
||||
required final String id,
|
||||
required final String title,
|
||||
final ThinkingStepStatus status,
|
||||
final String? details,
|
||||
}) = _$ThinkingStepImpl;
|
||||
|
||||
@override
|
||||
String get id;
|
||||
@override
|
||||
String get title;
|
||||
@override
|
||||
ThinkingStepStatus get status;
|
||||
@override
|
||||
String? get details;
|
||||
|
||||
/// Create a copy of ThinkingStep
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
_$$ThinkingStepImplCopyWith<_$ThinkingStepImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
mixin _$ChatState {
|
||||
List<ChatSessionEntity> get sessions => throw _privateConstructorUsedError;
|
||||
ChatSessionEntity? get activeSession => throw _privateConstructorUsedError;
|
||||
List<ChatMessageEntity> get messages => throw _privateConstructorUsedError;
|
||||
bool get isTyping => throw _privateConstructorUsedError;
|
||||
List<ThinkingStep> get thinkingSteps => throw _privateConstructorUsedError;
|
||||
String? get streamingContent => throw _privateConstructorUsedError;
|
||||
|
||||
/// Create a copy of ChatState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@@ -39,6 +246,8 @@ abstract class $ChatStateCopyWith<$Res> {
|
||||
ChatSessionEntity? activeSession,
|
||||
List<ChatMessageEntity> messages,
|
||||
bool isTyping,
|
||||
List<ThinkingStep> thinkingSteps,
|
||||
String? streamingContent,
|
||||
});
|
||||
|
||||
$ChatSessionEntityCopyWith<$Res>? get activeSession;
|
||||
@@ -63,6 +272,8 @@ class _$ChatStateCopyWithImpl<$Res, $Val extends ChatState>
|
||||
Object? activeSession = freezed,
|
||||
Object? messages = null,
|
||||
Object? isTyping = null,
|
||||
Object? thinkingSteps = null,
|
||||
Object? streamingContent = freezed,
|
||||
}) {
|
||||
return _then(
|
||||
_value.copyWith(
|
||||
@@ -82,6 +293,14 @@ class _$ChatStateCopyWithImpl<$Res, $Val extends ChatState>
|
||||
? _value.isTyping
|
||||
: isTyping // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
thinkingSteps: null == thinkingSteps
|
||||
? _value.thinkingSteps
|
||||
: thinkingSteps // ignore: cast_nullable_to_non_nullable
|
||||
as List<ThinkingStep>,
|
||||
streamingContent: freezed == streamingContent
|
||||
? _value.streamingContent
|
||||
: streamingContent // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
)
|
||||
as $Val,
|
||||
);
|
||||
@@ -116,6 +335,8 @@ abstract class _$$ChatStateImplCopyWith<$Res>
|
||||
ChatSessionEntity? activeSession,
|
||||
List<ChatMessageEntity> messages,
|
||||
bool isTyping,
|
||||
List<ThinkingStep> thinkingSteps,
|
||||
String? streamingContent,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -140,6 +361,8 @@ class __$$ChatStateImplCopyWithImpl<$Res>
|
||||
Object? activeSession = freezed,
|
||||
Object? messages = null,
|
||||
Object? isTyping = null,
|
||||
Object? thinkingSteps = null,
|
||||
Object? streamingContent = freezed,
|
||||
}) {
|
||||
return _then(
|
||||
_$ChatStateImpl(
|
||||
@@ -159,6 +382,14 @@ class __$$ChatStateImplCopyWithImpl<$Res>
|
||||
? _value.isTyping
|
||||
: isTyping // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
thinkingSteps: null == thinkingSteps
|
||||
? _value._thinkingSteps
|
||||
: thinkingSteps // ignore: cast_nullable_to_non_nullable
|
||||
as List<ThinkingStep>,
|
||||
streamingContent: freezed == streamingContent
|
||||
? _value.streamingContent
|
||||
: streamingContent // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -172,8 +403,11 @@ class _$ChatStateImpl implements _ChatState {
|
||||
this.activeSession,
|
||||
final List<ChatMessageEntity> messages = const [],
|
||||
this.isTyping = false,
|
||||
final List<ThinkingStep> thinkingSteps = const [],
|
||||
this.streamingContent,
|
||||
}) : _sessions = sessions,
|
||||
_messages = messages;
|
||||
_messages = messages,
|
||||
_thinkingSteps = thinkingSteps;
|
||||
|
||||
final List<ChatSessionEntity> _sessions;
|
||||
@override
|
||||
@@ -198,10 +432,21 @@ class _$ChatStateImpl implements _ChatState {
|
||||
@override
|
||||
@JsonKey()
|
||||
final bool isTyping;
|
||||
final List<ThinkingStep> _thinkingSteps;
|
||||
@override
|
||||
@JsonKey()
|
||||
List<ThinkingStep> get thinkingSteps {
|
||||
if (_thinkingSteps is EqualUnmodifiableListView) return _thinkingSteps;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_thinkingSteps);
|
||||
}
|
||||
|
||||
@override
|
||||
final String? streamingContent;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ChatState(sessions: $sessions, activeSession: $activeSession, messages: $messages, isTyping: $isTyping)';
|
||||
return 'ChatState(sessions: $sessions, activeSession: $activeSession, messages: $messages, isTyping: $isTyping, thinkingSteps: $thinkingSteps, streamingContent: $streamingContent)';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -214,7 +459,13 @@ class _$ChatStateImpl implements _ChatState {
|
||||
other.activeSession == activeSession) &&
|
||||
const DeepCollectionEquality().equals(other._messages, _messages) &&
|
||||
(identical(other.isTyping, isTyping) ||
|
||||
other.isTyping == isTyping));
|
||||
other.isTyping == isTyping) &&
|
||||
const DeepCollectionEquality().equals(
|
||||
other._thinkingSteps,
|
||||
_thinkingSteps,
|
||||
) &&
|
||||
(identical(other.streamingContent, streamingContent) ||
|
||||
other.streamingContent == streamingContent));
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -224,6 +475,8 @@ class _$ChatStateImpl implements _ChatState {
|
||||
activeSession,
|
||||
const DeepCollectionEquality().hash(_messages),
|
||||
isTyping,
|
||||
const DeepCollectionEquality().hash(_thinkingSteps),
|
||||
streamingContent,
|
||||
);
|
||||
|
||||
/// Create a copy of ChatState
|
||||
@@ -241,6 +494,8 @@ abstract class _ChatState implements ChatState {
|
||||
final ChatSessionEntity? activeSession,
|
||||
final List<ChatMessageEntity> messages,
|
||||
final bool isTyping,
|
||||
final List<ThinkingStep> thinkingSteps,
|
||||
final String? streamingContent,
|
||||
}) = _$ChatStateImpl;
|
||||
|
||||
@override
|
||||
@@ -251,6 +506,10 @@ abstract class _ChatState implements ChatState {
|
||||
List<ChatMessageEntity> get messages;
|
||||
@override
|
||||
bool get isTyping;
|
||||
@override
|
||||
List<ThinkingStep> get thinkingSteps;
|
||||
@override
|
||||
String? get streamingContent;
|
||||
|
||||
/// Create a copy of ChatState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
|
||||
121
lib/presentation/chat/widgets/message_bubble.dart
Normal file
121
lib/presentation/chat/widgets/message_bubble.dart
Normal file
@@ -0,0 +1,121 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/chat_message.dart';
|
||||
|
||||
class MessageBubble extends StatelessWidget {
|
||||
const MessageBubble({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.formattedTime,
|
||||
});
|
||||
|
||||
final ChatMessageEntity message;
|
||||
final String formattedTime;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isUser = message.isUser;
|
||||
final maxWidth = MediaQuery.of(context).size.width * 0.55;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UIConstants.spacing12),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
isUser ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isUser) ...[
|
||||
_buildAvatar(
|
||||
Icons.auto_awesome_rounded,
|
||||
AppColors.surfaceContainerHigh,
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
],
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing16,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isUser
|
||||
? AppColors.zinc700
|
||||
: AppColors.surfaceContainer,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(16),
|
||||
topRight: const Radius.circular(16),
|
||||
bottomLeft: isUser
|
||||
? const Radius.circular(16)
|
||||
: const Radius.circular(4),
|
||||
bottomRight: isUser
|
||||
? const Radius.circular(4)
|
||||
: const Radius.circular(16),
|
||||
),
|
||||
border: isUser
|
||||
? null
|
||||
: Border.all(color: AppColors.border, width: 1),
|
||||
),
|
||||
child: isUser
|
||||
? SelectableText(
|
||||
message.content,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 14,
|
||||
height: 1.5,
|
||||
),
|
||||
)
|
||||
: MarkdownBody(
|
||||
data: message.content,
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
p: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 14,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text(
|
||||
formattedTime,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isUser) ...[
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
_buildAvatar(
|
||||
Icons.person_rounded,
|
||||
AppColors.accent.withValues(alpha: 0.15),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatar(IconData icon, Color backgroundColor) {
|
||||
return Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(icon, size: 14, color: AppColors.accent),
|
||||
);
|
||||
}
|
||||
}
|
||||
115
lib/presentation/chat/widgets/missing_models_state.dart
Normal file
115
lib/presentation/chat/widgets/missing_models_state.dart
Normal file
@@ -0,0 +1,115 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/router/app_router.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
|
||||
class MissingModelsState extends StatelessWidget {
|
||||
const MissingModelsState({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.cloud_download_outlined,
|
||||
size: 36,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing24),
|
||||
Text(
|
||||
'AI models are missing.',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing8),
|
||||
Text(
|
||||
'Please download them to use the AI chat.',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing32),
|
||||
const _GoToSettingsButton(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GoToSettingsButton extends StatefulWidget {
|
||||
const _GoToSettingsButton();
|
||||
|
||||
@override
|
||||
State<_GoToSettingsButton> createState() => _GoToSettingsButtonState();
|
||||
}
|
||||
|
||||
class _GoToSettingsButtonState extends State<_GoToSettingsButton> {
|
||||
bool _isHovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _isHovered = true),
|
||||
onExit: (_) => setState(() => _isHovered = false),
|
||||
child: AnimatedContainer(
|
||||
duration: UIConstants.animationDuration,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: _isHovered
|
||||
? AppColors.accent.withValues(alpha: 0.85)
|
||||
: AppColors.accent,
|
||||
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
onTap: () => context.router.push(const SettingsRoute()),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing24,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.settings_outlined,
|
||||
size: 16,
|
||||
color: AppColors.zinc950,
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
Text(
|
||||
'Go to Settings',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.zinc950,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
67
lib/presentation/chat/widgets/new_chat_button.dart
Normal file
67
lib/presentation/chat/widgets/new_chat_button.dart
Normal file
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
|
||||
class NewChatButton extends StatefulWidget {
|
||||
const NewChatButton({super.key, required this.onPressed});
|
||||
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
State<NewChatButton> createState() => _NewChatButtonState();
|
||||
}
|
||||
|
||||
class _NewChatButtonState extends State<NewChatButton> {
|
||||
bool _isHovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _isHovered = true),
|
||||
onExit: (_) => setState(() => _isHovered = false),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
decoration: BoxDecoration(
|
||||
color: _isHovered
|
||||
? AppColors.zinc700
|
||||
: AppColors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
border: Border.all(color: AppColors.border, width: 1),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
onTap: widget.onPressed,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing12,
|
||||
vertical: UIConstants.spacing8,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.add_rounded,
|
||||
size: 16,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
SizedBox(width: UIConstants.spacing8),
|
||||
Text(
|
||||
'New Chat',
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
180
lib/presentation/chat/widgets/thinking_block.dart
Normal file
180
lib/presentation/chat/widgets/thinking_block.dart
Normal file
@@ -0,0 +1,180 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
import 'package:trainhub_flutter/presentation/chat/chat_state.dart';
|
||||
|
||||
class ThinkingBlock extends StatefulWidget {
|
||||
const ThinkingBlock({super.key, required this.steps});
|
||||
|
||||
final List<ThinkingStep> steps;
|
||||
|
||||
@override
|
||||
State<ThinkingBlock> createState() => _ThinkingBlockState();
|
||||
}
|
||||
|
||||
class _ThinkingBlockState extends State<ThinkingBlock> {
|
||||
bool _isExpanded = true;
|
||||
|
||||
Color _getStatusColor(ThinkingStepStatus status) {
|
||||
switch (status) {
|
||||
case ThinkingStepStatus.running:
|
||||
return AppColors.info;
|
||||
case ThinkingStepStatus.completed:
|
||||
return AppColors.success;
|
||||
case ThinkingStepStatus.error:
|
||||
return AppColors.destructive;
|
||||
case ThinkingStepStatus.pending:
|
||||
return AppColors.textMuted;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildStatusIcon(ThinkingStepStatus status) {
|
||||
if (status == ThinkingStepStatus.running) {
|
||||
return const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.info,
|
||||
),
|
||||
);
|
||||
}
|
||||
final IconData icon;
|
||||
switch (status) {
|
||||
case ThinkingStepStatus.completed:
|
||||
icon = Icons.check_circle_rounded;
|
||||
case ThinkingStepStatus.error:
|
||||
icon = Icons.error_outline_rounded;
|
||||
default:
|
||||
icon = Icons.circle_outlined;
|
||||
}
|
||||
return Icon(icon, size: 14, color: _getStatusColor(status));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
border: Border.all(color: AppColors.border, width: 1),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
onTap: () => setState(() => _isExpanded = !_isExpanded),
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.settings_suggest_rounded,
|
||||
size: 16,
|
||||
color: AppColors.warning,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Assistant actions',
|
||||
style: TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'(${widget.steps.length} steps)',
|
||||
style: const TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
_isExpanded
|
||||
? Icons.keyboard_arrow_up
|
||||
: Icons.keyboard_arrow_down,
|
||||
size: 16,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isExpanded)
|
||||
Container(
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(color: AppColors.border, width: 1),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
children: widget.steps.map(_buildStepRow).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStepRow(ThinkingStep step) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: _buildStatusIcon(step.status),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
step.title,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (step.details != null && step.details!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border:
|
||||
Border.all(color: AppColors.border, width: 1),
|
||||
),
|
||||
child: Text(
|
||||
step.details!,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
89
lib/presentation/chat/widgets/typing_bubble.dart
Normal file
89
lib/presentation/chat/widgets/typing_bubble.dart
Normal file
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
import 'package:trainhub_flutter/presentation/chat/chat_state.dart';
|
||||
import 'package:trainhub_flutter/presentation/chat/widgets/thinking_block.dart';
|
||||
import 'package:trainhub_flutter/presentation/chat/widgets/typing_indicator.dart';
|
||||
|
||||
class TypingBubble extends StatelessWidget {
|
||||
const TypingBubble({
|
||||
super.key,
|
||||
required this.thinkingSteps,
|
||||
this.streamingContent,
|
||||
});
|
||||
|
||||
final List<ThinkingStep> thinkingSteps;
|
||||
final String? streamingContent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maxWidth = MediaQuery.of(context).size.width * 0.55;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UIConstants.spacing12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.auto_awesome_rounded,
|
||||
size: 14,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (thinkingSteps.isNotEmpty)
|
||||
Container(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ThinkingBlock(steps: thinkingSteps),
|
||||
),
|
||||
if (streamingContent != null && streamingContent!.isNotEmpty)
|
||||
Container(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing16,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
bottomLeft: Radius.circular(4),
|
||||
bottomRight: Radius.circular(16),
|
||||
),
|
||||
border:
|
||||
Border.all(color: AppColors.border, width: 1),
|
||||
),
|
||||
child: MarkdownBody(
|
||||
data: streamingContent!,
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
p: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 14,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
const TypingIndicator(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
111
lib/presentation/chat/widgets/typing_indicator.dart
Normal file
111
lib/presentation/chat/widgets/typing_indicator.dart
Normal file
@@ -0,0 +1,111 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
|
||||
class TypingIndicator extends StatefulWidget {
|
||||
const TypingIndicator({super.key});
|
||||
|
||||
@override
|
||||
State<TypingIndicator> createState() => _TypingIndicatorState();
|
||||
}
|
||||
|
||||
class _TypingIndicatorState extends State<TypingIndicator>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1200),
|
||||
)..repeat();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UIConstants.spacing12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.auto_awesome_rounded,
|
||||
size: 14,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing16,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
bottomLeft: Radius.circular(4),
|
||||
bottomRight: Radius.circular(16),
|
||||
),
|
||||
border: Border.all(color: AppColors.border, width: 1),
|
||||
),
|
||||
child: AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, _) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(3, (index) {
|
||||
final delay = index * 0.2;
|
||||
final t = (_controller.value - delay) % 1.0;
|
||||
final bounce =
|
||||
t < 0.5 ? math.sin(t * math.pi * 2) * 4.0 : 0.0;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(left: index == 0 ? 0 : 4),
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, -bounce.abs()),
|
||||
child: const _Dot(),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Dot extends StatelessWidget {
|
||||
const _Dot();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 7,
|
||||
height: 7,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.textMuted,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user