Refactoring
Some checks failed
Build Linux App / build (push) Failing after 1m33s

This commit is contained in:
2026-02-23 10:02:23 -05:00
parent 21f1387fa8
commit 0c9eb8878d
57 changed files with 8179 additions and 1114 deletions

View File

@@ -3,12 +3,15 @@ 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/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/settings/ai_model_settings_controller.dart';
@RoutePage()
class ChatPage extends ConsumerStatefulWidget {
@@ -72,6 +75,13 @@ 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 state = ref.watch(chatControllerProvider);
final controller = ref.read(chatControllerProvider.notifier);
@@ -81,22 +91,28 @@ class _ChatPageState extends ConsumerState<ChatPage> {
next.value!.messages.length) {
_scrollToBottom();
}
if (next.hasValue && next.value!.isTyping && !(prev?.value?.isTyping ?? false)) {
if (next.hasValue &&
next.value!.isTyping &&
!(prev?.value?.isTyping ?? false)) {
_scrollToBottom();
}
});
// ── Show "models missing" placeholder ─────────────────────────────────
if (!modelsValidated) {
return const Scaffold(
backgroundColor: AppColors.surface,
body: _MissingModelsState(),
);
}
// ── Normal chat UI ─────────────────────────────────────────────────────
return Scaffold(
backgroundColor: AppColors.surface,
body: Row(
children: [
// --- Side Panel ---
_buildSidePanel(state, controller),
// --- Main Chat Area ---
Expanded(
child: _buildChatArea(state, controller),
),
Expanded(child: _buildChatArea(state, controller)),
],
),
);
@@ -119,7 +135,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
),
child: Column(
children: [
// New Chat button
Padding(
padding: const EdgeInsets.all(UIConstants.spacing12),
child: SizedBox(
@@ -130,7 +145,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
const Divider(height: 1, color: AppColors.border),
// Session list
Expanded(
child: asyncState.when(
data: (data) {
@@ -168,7 +182,10 @@ class _ChatPageState extends ConsumerState<ChatPage> {
error: (_, __) => Center(
child: Text(
'Error loading sessions',
style: TextStyle(color: AppColors.textMuted, fontSize: 13),
style: TextStyle(
color: AppColors.textMuted,
fontSize: 13,
),
),
),
loading: () => const Center(
@@ -198,9 +215,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
return MouseRegion(
onEnter: (_) => setState(() => _hoveredSessionId = session.id),
onExit: (_) => setState(() {
if (_hoveredSessionId == session.id) {
_hoveredSessionId = null;
}
if (_hoveredSessionId == session.id) _hoveredSessionId = null;
}),
child: GestureDetector(
onTap: () => controller.loadSession(session.id),
@@ -220,7 +235,8 @@ class _ChatPageState extends ConsumerState<ChatPage> {
: isHovered
? AppColors.zinc800.withValues(alpha: 0.6)
: Colors.transparent,
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
borderRadius:
BorderRadius.circular(UIConstants.smallBorderRadius),
border: isActive
? Border.all(
color: AppColors.accent.withValues(alpha: 0.3),
@@ -251,7 +267,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
),
),
),
// Delete button appears on hover
AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: isHovered ? 1.0 : 0.0,
@@ -290,21 +305,17 @@ class _ChatPageState extends ConsumerState<ChatPage> {
) {
return Column(
children: [
// Messages
Expanded(
child: asyncState.when(
data: (data) {
if (data.messages.isEmpty) {
return _buildEmptyState();
}
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),
itemCount: data.messages.length + (data.isTyping ? 1 : 0),
itemBuilder: (context, index) {
if (index == data.messages.length) {
return const _TypingIndicator();
@@ -349,8 +360,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
),
),
),
// Input area
_buildInputBar(asyncState, controller),
],
);
@@ -386,10 +395,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
const SizedBox(height: UIConstants.spacing8),
const Text(
'Start a conversation to get personalized advice.',
style: TextStyle(
color: AppColors.textMuted,
fontSize: 13,
),
style: TextStyle(color: AppColors.textMuted, fontSize: 13),
),
],
),
@@ -408,9 +414,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
return Container(
decoration: const BoxDecoration(
color: AppColors.surface,
border: Border(
top: BorderSide(color: AppColors.border, width: 1),
),
border: Border(top: BorderSide(color: AppColors.border, width: 1)),
),
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing16,
@@ -469,7 +473,8 @@ class _ChatPageState extends ConsumerState<ChatPage> {
onTap: isTyping ? null : () => _sendMessage(controller),
child: Icon(
Icons.arrow_upward_rounded,
color: isTyping ? AppColors.textMuted : AppColors.zinc950,
color:
isTyping ? AppColors.textMuted : AppColors.zinc950,
size: 20,
),
),
@@ -481,6 +486,124 @@ class _ChatPageState extends ConsumerState<ChatPage> {
}
}
// =============================================================================
// 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,
),
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,
),
),
],
),
),
),
),
),
);
}
}
// =============================================================================
// New Chat Button
// =============================================================================
@@ -507,7 +630,8 @@ class _NewChatButtonState extends State<_NewChatButton> {
color: _isHovered
? AppColors.zinc700
: AppColors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
borderRadius:
BorderRadius.circular(UIConstants.smallBorderRadius),
border: Border.all(color: AppColors.border, width: 1),
),
child: Material(
@@ -590,8 +714,9 @@ class _MessageBubble extends StatelessWidget {
],
Flexible(
child: Column(
crossAxisAlignment:
isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start,
crossAxisAlignment: isUser
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
Container(
constraints: BoxConstraints(maxWidth: maxWidth),
@@ -606,10 +731,12 @@ class _MessageBubble extends StatelessWidget {
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),
bottomLeft: isUser
? const Radius.circular(16)
: const Radius.circular(4),
bottomRight: isUser
? const Radius.circular(4)
: const Radius.circular(16),
),
border: isUser
? null
@@ -617,7 +744,7 @@ class _MessageBubble extends StatelessWidget {
),
child: SelectableText(
message.content,
style: TextStyle(
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 14,
height: 1.5,
@@ -731,17 +858,12 @@ class _TypingIndicatorState extends State<_TypingIndicator>
return Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(3, (index) {
// Stagger each dot by 0.2 of the animation cycle
final delay = index * 0.2;
final t = (_controller.value - delay) % 1.0;
// Bounce: use a sin curve over the first half, rest at 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,
),
padding: EdgeInsets.only(left: index == 0 ? 0 : 4),
child: Transform.translate(
offset: Offset(0, -bounce.abs()),
child: Container(