This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user