556 lines
19 KiB
Dart
556 lines
19 KiB
Dart
import 'package:auto_route/auto_route.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:google_fonts/google_fonts.dart';
|
|
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
|
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
|
import 'package:trainhub_flutter/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()
|
|
class ChatPage extends ConsumerStatefulWidget {
|
|
const ChatPage({super.key});
|
|
|
|
@override
|
|
ConsumerState<ChatPage> createState() => _ChatPageState();
|
|
}
|
|
|
|
class _ChatPageState extends ConsumerState<ChatPage> {
|
|
final TextEditingController _inputController = TextEditingController();
|
|
final ScrollController _scrollController = ScrollController();
|
|
final FocusNode _inputFocusNode = FocusNode();
|
|
String? _hoveredSessionId;
|
|
|
|
@override
|
|
void dispose() {
|
|
_inputController.dispose();
|
|
_scrollController.dispose();
|
|
_inputFocusNode.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _scrollToBottom() {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (_scrollController.hasClients) {
|
|
_scrollController.animateTo(
|
|
_scrollController.position.maxScrollExtent,
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeOut,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
void _sendMessage(ChatController controller) {
|
|
final text = _inputController.text.trim();
|
|
if (text.isEmpty) return;
|
|
controller.sendMessage(text);
|
|
_inputController.clear();
|
|
_inputFocusNode.requestFocus();
|
|
}
|
|
|
|
String _formatTimestamp(String timestamp) {
|
|
try {
|
|
final dt = DateTime.parse(timestamp);
|
|
final now = DateTime.now();
|
|
final hour = dt.hour.toString().padLeft(2, '0');
|
|
final minute = dt.minute.toString().padLeft(2, '0');
|
|
if (dt.year == now.year &&
|
|
dt.month == now.month &&
|
|
dt.day == now.day) {
|
|
return '$hour:$minute';
|
|
}
|
|
return '${dt.day}/${dt.month} $hour:$minute';
|
|
} catch (_) {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
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) <
|
|
next.value!.messages.length) {
|
|
_scrollToBottom();
|
|
}
|
|
if (next.hasValue &&
|
|
next.value!.isTyping &&
|
|
!(prev?.value?.isTyping ?? false)) {
|
|
_scrollToBottom();
|
|
}
|
|
});
|
|
if (!modelsValidated) {
|
|
return const Scaffold(
|
|
backgroundColor: AppColors.surface,
|
|
body: MissingModelsState(),
|
|
);
|
|
}
|
|
return Scaffold(
|
|
backgroundColor: AppColors.surface,
|
|
body: Row(
|
|
children: [
|
|
_buildSidePanel(state, controller),
|
|
Expanded(child: _buildChatArea(state, controller)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSidePanel(
|
|
AsyncValue<ChatState> asyncState,
|
|
ChatController controller,
|
|
) {
|
|
return Container(
|
|
width: 250,
|
|
decoration: const BoxDecoration(
|
|
color: AppColors.surfaceContainer,
|
|
border: Border(right: BorderSide(color: AppColors.border, width: 1)),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.all(UIConstants.spacing12),
|
|
child: SizedBox(
|
|
width: double.infinity,
|
|
child: NewChatButton(onPressed: controller.createSession),
|
|
),
|
|
),
|
|
const Divider(height: 1, color: AppColors.border),
|
|
Expanded(
|
|
child: asyncState.when(
|
|
data: (data) {
|
|
if (data.sessions.isEmpty) {
|
|
return const Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(UIConstants.spacing24),
|
|
child: Text(
|
|
'No conversations yet',
|
|
style: TextStyle(
|
|
color: AppColors.textMuted,
|
|
fontSize: 13,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return ListView.builder(
|
|
padding: const EdgeInsets.symmetric(
|
|
vertical: UIConstants.spacing8,
|
|
),
|
|
itemCount: data.sessions.length,
|
|
itemBuilder: (context, index) {
|
|
final session = data.sessions[index];
|
|
return _buildSessionTile(
|
|
session: session,
|
|
isActive: session.id == data.activeSession?.id,
|
|
controller: controller,
|
|
);
|
|
},
|
|
);
|
|
},
|
|
error: (_, __) => const Center(
|
|
child: Text(
|
|
'Error loading sessions',
|
|
style: TextStyle(color: AppColors.textMuted, fontSize: 13),
|
|
),
|
|
),
|
|
loading: () => const Center(
|
|
child: SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: AppColors.textMuted,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSessionTile({
|
|
required ChatSessionEntity session,
|
|
required bool isActive,
|
|
required ChatController controller,
|
|
}) {
|
|
final isHovered = _hoveredSessionId == session.id;
|
|
return MouseRegion(
|
|
onEnter: (_) => setState(() => _hoveredSessionId = session.id),
|
|
onExit: (_) => setState(() {
|
|
if (_hoveredSessionId == session.id) _hoveredSessionId = null;
|
|
}),
|
|
child: GestureDetector(
|
|
onTap: () => controller.loadSession(session.id),
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 150),
|
|
margin: const EdgeInsets.symmetric(
|
|
horizontal: UIConstants.spacing8,
|
|
vertical: 2,
|
|
),
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: UIConstants.spacing12,
|
|
vertical: UIConstants.spacing8,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: isActive
|
|
? AppColors.zinc700.withValues(alpha: 0.7)
|
|
: isHovered
|
|
? AppColors.zinc800.withValues(alpha: 0.6)
|
|
: Colors.transparent,
|
|
borderRadius:
|
|
BorderRadius.circular(UIConstants.smallBorderRadius),
|
|
border: isActive
|
|
? Border.all(
|
|
color: AppColors.accent.withValues(alpha: 0.3),
|
|
)
|
|
: null,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.chat_bubble_outline_rounded,
|
|
size: 14,
|
|
color: isActive ? AppColors.accent : AppColors.textMuted,
|
|
),
|
|
const SizedBox(width: UIConstants.spacing8),
|
|
Expanded(
|
|
child: Text(
|
|
session.title ?? 'New Chat',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: isActive
|
|
? AppColors.textPrimary
|
|
: AppColors.textSecondary,
|
|
fontSize: 13,
|
|
fontWeight:
|
|
isActive ? FontWeight.w500 : FontWeight.normal,
|
|
),
|
|
),
|
|
),
|
|
AnimatedOpacity(
|
|
duration: const Duration(milliseconds: 150),
|
|
opacity: isHovered ? 1.0 : 0.0,
|
|
child: IgnorePointer(
|
|
ignoring: !isHovered,
|
|
child: SizedBox(
|
|
width: 24,
|
|
height: 24,
|
|
child: IconButton(
|
|
padding: EdgeInsets.zero,
|
|
iconSize: 14,
|
|
splashRadius: 14,
|
|
icon: const Icon(
|
|
Icons.delete_outline_rounded,
|
|
color: AppColors.textMuted,
|
|
),
|
|
onPressed: () =>
|
|
controller.deleteSession(session.id),
|
|
tooltip: 'Delete',
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildChatArea(
|
|
AsyncValue<ChatState> asyncState,
|
|
ChatController controller,
|
|
) {
|
|
return Column(
|
|
children: [
|
|
Expanded(
|
|
child: asyncState.when(
|
|
data: (data) {
|
|
if (data.messages.isEmpty) return _buildEmptyState();
|
|
return ListView.builder(
|
|
controller: _scrollController,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: UIConstants.spacing24,
|
|
vertical: UIConstants.spacing16,
|
|
),
|
|
itemCount:
|
|
data.messages.length + (data.isTyping ? 1 : 0),
|
|
itemBuilder: (context, index) {
|
|
if (index == data.messages.length) {
|
|
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(
|
|
message: msg,
|
|
formattedTime: _formatTimestamp(msg.createdAt),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
error: (e, _) => const Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.error_outline_rounded,
|
|
color: AppColors.destructive,
|
|
size: 40,
|
|
),
|
|
SizedBox(height: UIConstants.spacing12),
|
|
Text(
|
|
'Something went wrong',
|
|
style: TextStyle(
|
|
color: AppColors.textSecondary,
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
loading: () => const Center(
|
|
child: SizedBox(
|
|
width: 24,
|
|
height: 24,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: AppColors.textMuted,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
_buildInputBar(asyncState, controller),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildEmptyState() {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Container(
|
|
width: 64,
|
|
height: 64,
|
|
decoration: BoxDecoration(
|
|
color: AppColors.surfaceContainerHigh,
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: const Icon(
|
|
Icons.auto_awesome_outlined,
|
|
size: 32,
|
|
color: AppColors.textMuted,
|
|
),
|
|
),
|
|
const SizedBox(height: UIConstants.spacing16),
|
|
const Text(
|
|
'Ask me anything about your training!',
|
|
style: TextStyle(
|
|
color: AppColors.textSecondary,
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
const SizedBox(height: UIConstants.spacing8),
|
|
const Text(
|
|
'Start a conversation to get personalized advice.',
|
|
style: TextStyle(color: AppColors.textMuted, fontSize: 13),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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,
|
|
border: Border(top: BorderSide(color: AppColors.border, width: 1)),
|
|
),
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: UIConstants.spacing16,
|
|
vertical: UIConstants.spacing12,
|
|
),
|
|
child: Column(
|
|
children: [
|
|
if (statusMessage != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: UIConstants.spacing8),
|
|
child: Row(
|
|
children: [
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|