Initial commit
This commit is contained in:
109
lib/presentation/chat/chat_controller.dart
Normal file
109
lib/presentation/chat/chat_controller.dart
Normal file
@@ -0,0 +1,109 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:trainhub_flutter/injection.dart';
|
||||
import 'package:trainhub_flutter/domain/repositories/chat_repository.dart';
|
||||
import 'package:trainhub_flutter/presentation/chat/chat_state.dart';
|
||||
|
||||
part 'chat_controller.g.dart';
|
||||
|
||||
@riverpod
|
||||
class ChatController extends _$ChatController {
|
||||
late final ChatRepository _repo;
|
||||
|
||||
@override
|
||||
Future<ChatState> build() async {
|
||||
_repo = getIt<ChatRepository>();
|
||||
final sessions = await _repo.getAllSessions();
|
||||
return ChatState(sessions: sessions);
|
||||
}
|
||||
|
||||
Future<void> createSession() async {
|
||||
final session = await _repo.createSession();
|
||||
final sessions = await _repo.getAllSessions();
|
||||
state = AsyncValue.data(
|
||||
ChatState(sessions: sessions, activeSession: session),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> loadSession(String id) async {
|
||||
final session = await _repo.getSession(id);
|
||||
if (session == null) return;
|
||||
final messages = await _repo.getMessages(id);
|
||||
final current = state.valueOrNull ?? const ChatState();
|
||||
state = AsyncValue.data(
|
||||
current.copyWith(activeSession: session, messages: messages),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteSession(String id) async {
|
||||
await _repo.deleteSession(id);
|
||||
final sessions = await _repo.getAllSessions();
|
||||
final current = state.valueOrNull ?? const ChatState();
|
||||
state = AsyncValue.data(
|
||||
current.copyWith(
|
||||
sessions: sessions,
|
||||
activeSession:
|
||||
current.activeSession?.id == id ? null : current.activeSession,
|
||||
messages: current.activeSession?.id == id ? [] : current.messages,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> sendMessage(String content) async {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
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;
|
||||
}
|
||||
await _repo.addMessage(
|
||||
sessionId: sessionId,
|
||||
role: 'user',
|
||||
content: content,
|
||||
);
|
||||
final messagesAfterUser = await _repo.getMessages(sessionId);
|
||||
state = AsyncValue.data(
|
||||
state.valueOrNull!.copyWith(messages: messagesAfterUser, isTyping: true),
|
||||
);
|
||||
await Future<void>.delayed(const Duration(seconds: 1));
|
||||
final String response = _getMockResponse(content);
|
||||
await _repo.addMessage(
|
||||
sessionId: sessionId,
|
||||
role: 'assistant',
|
||||
content: response,
|
||||
);
|
||||
final messagesAfterAi = await _repo.getMessages(sessionId);
|
||||
if (messagesAfterAi.length <= 2) {
|
||||
final title = content.length > 30
|
||||
? '${content.substring(0, 30)}...'
|
||||
: content;
|
||||
await _repo.updateSessionTitle(sessionId, title);
|
||||
}
|
||||
final sessions = await _repo.getAllSessions();
|
||||
state = AsyncValue.data(
|
||||
state.valueOrNull!.copyWith(
|
||||
messages: messagesAfterAi,
|
||||
isTyping: false,
|
||||
sessions: sessions,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getMockResponse(String input) {
|
||||
final String lower = input.toLowerCase();
|
||||
if (lower.contains('plan') || lower.contains('program')) {
|
||||
return "I can help you design a training plan! What are your goals? Strength, hypertrophy, or endurance?";
|
||||
} else if (lower.contains('squat') || lower.contains('bench')) {
|
||||
return "Compound movements are great. Remember to maintain proper form. For squats, keep your chest up and knees tracking over toes.";
|
||||
} else if (lower.contains('nutrition') || lower.contains('eat')) {
|
||||
return "Nutrition is key. Aim for 1.6-2.2g of protein per kg of bodyweight if you're training hard.";
|
||||
}
|
||||
return "I'm your AI training assistant. How can I help you today?";
|
||||
}
|
||||
}
|
||||
26
lib/presentation/chat/chat_controller.g.dart
Normal file
26
lib/presentation/chat/chat_controller.g.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'chat_controller.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$chatControllerHash() => r'44a3d0e906eaad16f7a9c292fe847b8bd144c835';
|
||||
|
||||
/// See also [ChatController].
|
||||
@ProviderFor(ChatController)
|
||||
final chatControllerProvider =
|
||||
AutoDisposeAsyncNotifierProvider<ChatController, ChatState>.internal(
|
||||
ChatController.new,
|
||||
name: r'chatControllerProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$chatControllerHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$ChatController = AutoDisposeAsyncNotifier<ChatState>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||
766
lib/presentation/chat/chat_page.dart
Normal file
766
lib/presentation/chat/chat_page.dart
Normal file
@@ -0,0 +1,766 @@
|
||||
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: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';
|
||||
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';
|
||||
|
||||
@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.isNotEmpty) {
|
||||
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 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();
|
||||
}
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.surface,
|
||||
body: Row(
|
||||
children: [
|
||||
// --- Side Panel ---
|
||||
_buildSidePanel(state, controller),
|
||||
|
||||
// --- Main Chat Area ---
|
||||
Expanded(
|
||||
child: _buildChatArea(state, controller),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Side Panel
|
||||
// ---------------------------------------------------------------------------
|
||||
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: [
|
||||
// New Chat button
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UIConstants.spacing12),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: _NewChatButton(onPressed: controller.createSession),
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(height: 1, color: AppColors.border),
|
||||
|
||||
// Session list
|
||||
Expanded(
|
||||
child: asyncState.when(
|
||||
data: (data) {
|
||||
if (data.sessions.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const 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];
|
||||
final isActive =
|
||||
session.id == data.activeSession?.id;
|
||||
return _buildSessionTile(
|
||||
session: session,
|
||||
isActive: isActive,
|
||||
controller: controller,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
error: (_, __) => 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),
|
||||
width: 1,
|
||||
)
|
||||
: 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Delete button appears on hover
|
||||
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',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chat Area
|
||||
// ---------------------------------------------------------------------------
|
||||
Widget _buildChatArea(
|
||||
AsyncValue<ChatState> asyncState,
|
||||
ChatController controller,
|
||||
) {
|
||||
return Column(
|
||||
children: [
|
||||
// Messages
|
||||
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) {
|
||||
return const _TypingIndicator();
|
||||
}
|
||||
final msg = data.messages[index];
|
||||
return _MessageBubble(
|
||||
message: msg,
|
||||
formattedTime: _formatTimestamp(msg.createdAt),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
error: (e, _) => Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline_rounded,
|
||||
color: AppColors.destructive,
|
||||
size: 40,
|
||||
),
|
||||
const 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Input area
|
||||
_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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input Bar
|
||||
// ---------------------------------------------------------------------------
|
||||
Widget _buildInputBar(
|
||||
AsyncValue<ChatState> asyncState,
|
||||
ChatController controller,
|
||||
) {
|
||||
final isTyping = asyncState.valueOrNull?.isTyping ?? false;
|
||||
|
||||
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: 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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: 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) {
|
||||
// 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,
|
||||
),
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, -bounce.abs()),
|
||||
child: Container(
|
||||
width: 7,
|
||||
height: 7,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.textMuted,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
15
lib/presentation/chat/chat_state.dart
Normal file
15
lib/presentation/chat/chat_state.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/chat_session.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/chat_message.dart';
|
||||
|
||||
part 'chat_state.freezed.dart';
|
||||
|
||||
@freezed
|
||||
class ChatState with _$ChatState {
|
||||
const factory ChatState({
|
||||
@Default([]) List<ChatSessionEntity> sessions,
|
||||
ChatSessionEntity? activeSession,
|
||||
@Default([]) List<ChatMessageEntity> messages,
|
||||
@Default(false) bool isTyping,
|
||||
}) = _ChatState;
|
||||
}
|
||||
261
lib/presentation/chat/chat_state.freezed.dart
Normal file
261
lib/presentation/chat/chat_state.freezed.dart
Normal file
@@ -0,0 +1,261 @@
|
||||
// coverage:ignore-file
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'chat_state.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
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 _$ChatState {
|
||||
List<ChatSessionEntity> get sessions => throw _privateConstructorUsedError;
|
||||
ChatSessionEntity? get activeSession => throw _privateConstructorUsedError;
|
||||
List<ChatMessageEntity> get messages => throw _privateConstructorUsedError;
|
||||
bool get isTyping => throw _privateConstructorUsedError;
|
||||
|
||||
/// Create a copy of ChatState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
$ChatStateCopyWith<ChatState> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $ChatStateCopyWith<$Res> {
|
||||
factory $ChatStateCopyWith(ChatState value, $Res Function(ChatState) then) =
|
||||
_$ChatStateCopyWithImpl<$Res, ChatState>;
|
||||
@useResult
|
||||
$Res call({
|
||||
List<ChatSessionEntity> sessions,
|
||||
ChatSessionEntity? activeSession,
|
||||
List<ChatMessageEntity> messages,
|
||||
bool isTyping,
|
||||
});
|
||||
|
||||
$ChatSessionEntityCopyWith<$Res>? get activeSession;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$ChatStateCopyWithImpl<$Res, $Val extends ChatState>
|
||||
implements $ChatStateCopyWith<$Res> {
|
||||
_$ChatStateCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
/// Create a copy of ChatState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? sessions = null,
|
||||
Object? activeSession = freezed,
|
||||
Object? messages = null,
|
||||
Object? isTyping = null,
|
||||
}) {
|
||||
return _then(
|
||||
_value.copyWith(
|
||||
sessions: null == sessions
|
||||
? _value.sessions
|
||||
: sessions // ignore: cast_nullable_to_non_nullable
|
||||
as List<ChatSessionEntity>,
|
||||
activeSession: freezed == activeSession
|
||||
? _value.activeSession
|
||||
: activeSession // ignore: cast_nullable_to_non_nullable
|
||||
as ChatSessionEntity?,
|
||||
messages: null == messages
|
||||
? _value.messages
|
||||
: messages // ignore: cast_nullable_to_non_nullable
|
||||
as List<ChatMessageEntity>,
|
||||
isTyping: null == isTyping
|
||||
? _value.isTyping
|
||||
: isTyping // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
)
|
||||
as $Val,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a copy of ChatState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$ChatSessionEntityCopyWith<$Res>? get activeSession {
|
||||
if (_value.activeSession == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $ChatSessionEntityCopyWith<$Res>(_value.activeSession!, (value) {
|
||||
return _then(_value.copyWith(activeSession: value) as $Val);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$ChatStateImplCopyWith<$Res>
|
||||
implements $ChatStateCopyWith<$Res> {
|
||||
factory _$$ChatStateImplCopyWith(
|
||||
_$ChatStateImpl value,
|
||||
$Res Function(_$ChatStateImpl) then,
|
||||
) = __$$ChatStateImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call({
|
||||
List<ChatSessionEntity> sessions,
|
||||
ChatSessionEntity? activeSession,
|
||||
List<ChatMessageEntity> messages,
|
||||
bool isTyping,
|
||||
});
|
||||
|
||||
@override
|
||||
$ChatSessionEntityCopyWith<$Res>? get activeSession;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$ChatStateImplCopyWithImpl<$Res>
|
||||
extends _$ChatStateCopyWithImpl<$Res, _$ChatStateImpl>
|
||||
implements _$$ChatStateImplCopyWith<$Res> {
|
||||
__$$ChatStateImplCopyWithImpl(
|
||||
_$ChatStateImpl _value,
|
||||
$Res Function(_$ChatStateImpl) _then,
|
||||
) : super(_value, _then);
|
||||
|
||||
/// Create a copy of ChatState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? sessions = null,
|
||||
Object? activeSession = freezed,
|
||||
Object? messages = null,
|
||||
Object? isTyping = null,
|
||||
}) {
|
||||
return _then(
|
||||
_$ChatStateImpl(
|
||||
sessions: null == sessions
|
||||
? _value._sessions
|
||||
: sessions // ignore: cast_nullable_to_non_nullable
|
||||
as List<ChatSessionEntity>,
|
||||
activeSession: freezed == activeSession
|
||||
? _value.activeSession
|
||||
: activeSession // ignore: cast_nullable_to_non_nullable
|
||||
as ChatSessionEntity?,
|
||||
messages: null == messages
|
||||
? _value._messages
|
||||
: messages // ignore: cast_nullable_to_non_nullable
|
||||
as List<ChatMessageEntity>,
|
||||
isTyping: null == isTyping
|
||||
? _value.isTyping
|
||||
: isTyping // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
class _$ChatStateImpl implements _ChatState {
|
||||
const _$ChatStateImpl({
|
||||
final List<ChatSessionEntity> sessions = const [],
|
||||
this.activeSession,
|
||||
final List<ChatMessageEntity> messages = const [],
|
||||
this.isTyping = false,
|
||||
}) : _sessions = sessions,
|
||||
_messages = messages;
|
||||
|
||||
final List<ChatSessionEntity> _sessions;
|
||||
@override
|
||||
@JsonKey()
|
||||
List<ChatSessionEntity> get sessions {
|
||||
if (_sessions is EqualUnmodifiableListView) return _sessions;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_sessions);
|
||||
}
|
||||
|
||||
@override
|
||||
final ChatSessionEntity? activeSession;
|
||||
final List<ChatMessageEntity> _messages;
|
||||
@override
|
||||
@JsonKey()
|
||||
List<ChatMessageEntity> get messages {
|
||||
if (_messages is EqualUnmodifiableListView) return _messages;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_messages);
|
||||
}
|
||||
|
||||
@override
|
||||
@JsonKey()
|
||||
final bool isTyping;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ChatState(sessions: $sessions, activeSession: $activeSession, messages: $messages, isTyping: $isTyping)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$ChatStateImpl &&
|
||||
const DeepCollectionEquality().equals(other._sessions, _sessions) &&
|
||||
(identical(other.activeSession, activeSession) ||
|
||||
other.activeSession == activeSession) &&
|
||||
const DeepCollectionEquality().equals(other._messages, _messages) &&
|
||||
(identical(other.isTyping, isTyping) ||
|
||||
other.isTyping == isTyping));
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType,
|
||||
const DeepCollectionEquality().hash(_sessions),
|
||||
activeSession,
|
||||
const DeepCollectionEquality().hash(_messages),
|
||||
isTyping,
|
||||
);
|
||||
|
||||
/// Create a copy of ChatState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$ChatStateImplCopyWith<_$ChatStateImpl> get copyWith =>
|
||||
__$$ChatStateImplCopyWithImpl<_$ChatStateImpl>(this, _$identity);
|
||||
}
|
||||
|
||||
abstract class _ChatState implements ChatState {
|
||||
const factory _ChatState({
|
||||
final List<ChatSessionEntity> sessions,
|
||||
final ChatSessionEntity? activeSession,
|
||||
final List<ChatMessageEntity> messages,
|
||||
final bool isTyping,
|
||||
}) = _$ChatStateImpl;
|
||||
|
||||
@override
|
||||
List<ChatSessionEntity> get sessions;
|
||||
@override
|
||||
ChatSessionEntity? get activeSession;
|
||||
@override
|
||||
List<ChatMessageEntity> get messages;
|
||||
@override
|
||||
bool get isTyping;
|
||||
|
||||
/// Create a copy of ChatState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
_$$ChatStateImplCopyWith<_$ChatStateImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
Reference in New Issue
Block a user