feat: Implement support chat functionality, add 2FA verification screen and routing, and update dependencies.
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:real_estate_mobile/features/support_chat/data/models/support_chat_models.dart';
|
||||
import 'package:real_estate_mobile/features/support_chat/data/support_chat_repository.dart';
|
||||
|
||||
class SupportChatState {
|
||||
final SupportChat? chat;
|
||||
final List<SupportMessage> messages;
|
||||
final bool isLoading;
|
||||
final bool isSending;
|
||||
final bool isLoadingMore;
|
||||
final bool hasMore;
|
||||
final int currentPage;
|
||||
final bool isTyping;
|
||||
final String? error;
|
||||
|
||||
const SupportChatState({
|
||||
this.chat,
|
||||
this.messages = const [],
|
||||
this.isLoading = true,
|
||||
this.isSending = false,
|
||||
this.isLoadingMore = false,
|
||||
this.hasMore = false,
|
||||
this.currentPage = 1,
|
||||
this.isTyping = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
SupportChatState copyWith({
|
||||
SupportChat? chat,
|
||||
List<SupportMessage>? messages,
|
||||
bool? isLoading,
|
||||
bool? isSending,
|
||||
bool? isLoadingMore,
|
||||
bool? hasMore,
|
||||
int? currentPage,
|
||||
bool? isTyping,
|
||||
String? error,
|
||||
}) {
|
||||
return SupportChatState(
|
||||
chat: chat ?? this.chat,
|
||||
messages: messages ?? this.messages,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
isSending: isSending ?? this.isSending,
|
||||
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
|
||||
hasMore: hasMore ?? this.hasMore,
|
||||
currentPage: currentPage ?? this.currentPage,
|
||||
isTyping: isTyping ?? this.isTyping,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SupportChatNotifier extends StateNotifier<SupportChatState> {
|
||||
final SupportChatRepository _repo;
|
||||
|
||||
SupportChatNotifier(this._repo) : super(const SupportChatState());
|
||||
|
||||
Future<void> initChat() async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
try {
|
||||
final chat = await _repo.getOrCreateChat();
|
||||
final result = await _repo.getMessages(chat.id);
|
||||
await _repo.markAsRead(chat.id);
|
||||
|
||||
state = state.copyWith(
|
||||
chat: chat,
|
||||
messages: result.messages,
|
||||
hasMore: result.pagination.hasMore,
|
||||
currentPage: result.pagination.page,
|
||||
isLoading: false,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: 'Failed to load support chat. Please try again.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sendMessage(String content) async {
|
||||
final chat = state.chat;
|
||||
if (chat == null || content.trim().isEmpty) return;
|
||||
|
||||
state = state.copyWith(isSending: true);
|
||||
try {
|
||||
final message = await _repo.sendMessage(chat.id, content.trim());
|
||||
state = state.copyWith(
|
||||
messages: [...state.messages, message],
|
||||
isSending: false,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(isSending: false);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadMore() async {
|
||||
final chat = state.chat;
|
||||
if (chat == null || !state.hasMore || state.isLoadingMore) return;
|
||||
|
||||
state = state.copyWith(isLoadingMore: true);
|
||||
try {
|
||||
final nextPage = state.currentPage + 1;
|
||||
final result = await _repo.getMessages(chat.id, page: nextPage);
|
||||
state = state.copyWith(
|
||||
messages: [...result.messages, ...state.messages],
|
||||
currentPage: nextPage,
|
||||
hasMore: result.pagination.hasMore,
|
||||
isLoadingMore: false,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(isLoadingMore: false);
|
||||
}
|
||||
}
|
||||
|
||||
void addIncomingMessage(SupportMessage message) {
|
||||
if (state.chat == null || message.chatId != state.chat!.id) return;
|
||||
if (state.messages.any((m) => m.id == message.id)) return;
|
||||
|
||||
state = state.copyWith(messages: [...state.messages, message]);
|
||||
_repo.markAsRead(state.chat!.id);
|
||||
}
|
||||
|
||||
void setTyping(bool typing) {
|
||||
state = state.copyWith(isTyping: typing);
|
||||
}
|
||||
}
|
||||
|
||||
final supportChatProvider =
|
||||
StateNotifierProvider<SupportChatNotifier, SupportChatState>((ref) {
|
||||
final repo = ref.watch(supportChatRepositoryProvider);
|
||||
return SupportChatNotifier(repo);
|
||||
});
|
||||
@@ -0,0 +1,667 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:real_estate_mobile/core/constants/app_colors.dart';
|
||||
import 'package:real_estate_mobile/features/auth/presentation/providers/auth_provider.dart';
|
||||
import 'package:real_estate_mobile/features/messaging/data/socket_service.dart';
|
||||
import 'package:real_estate_mobile/features/support_chat/data/models/support_chat_models.dart';
|
||||
import 'package:real_estate_mobile/features/support_chat/presentation/providers/support_chat_provider.dart';
|
||||
|
||||
class SupportChatScreen extends ConsumerStatefulWidget {
|
||||
const SupportChatScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SupportChatScreen> createState() => _SupportChatScreenState();
|
||||
}
|
||||
|
||||
class _SupportChatScreenState extends ConsumerState<SupportChatScreen> {
|
||||
final TextEditingController _inputController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
Timer? _typingTimeout;
|
||||
StreamSubscription? _messageSub;
|
||||
StreamSubscription? _typingStartSub;
|
||||
StreamSubscription? _typingStopSub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(supportChatProvider.notifier).initChat();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_inputController.dispose();
|
||||
_scrollController.dispose();
|
||||
_typingTimeout?.cancel();
|
||||
_messageSub?.cancel();
|
||||
_typingStartSub?.cancel();
|
||||
_typingStopSub?.cancel();
|
||||
_leaveRoom();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _setupSocketListeners(String chatId, String currentUserId) {
|
||||
final socket = SocketService();
|
||||
|
||||
// Connect socket if needed
|
||||
if (!socket.isConnected) {
|
||||
socket.connect();
|
||||
}
|
||||
|
||||
// Join support chat room
|
||||
if (socket.isConnected) {
|
||||
socket.emitRaw('support_join', {'chatId': chatId});
|
||||
}
|
||||
|
||||
// Listen for new support messages
|
||||
_messageSub?.cancel();
|
||||
_messageSub = socket.onSupportMessage.listen((message) {
|
||||
ref.read(supportChatProvider.notifier).addIncomingMessage(message);
|
||||
_scrollToBottom();
|
||||
});
|
||||
|
||||
_typingStartSub?.cancel();
|
||||
_typingStartSub = socket.onSupportTypingStart.listen((data) {
|
||||
if (data['userId'] != currentUserId) {
|
||||
ref.read(supportChatProvider.notifier).setTyping(true);
|
||||
}
|
||||
});
|
||||
|
||||
_typingStopSub?.cancel();
|
||||
_typingStopSub = socket.onSupportTypingStop.listen((data) {
|
||||
if (data['userId'] != currentUserId) {
|
||||
ref.read(supportChatProvider.notifier).setTyping(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _leaveRoom() {
|
||||
final chatId = ref.read(supportChatProvider).chat?.id;
|
||||
if (chatId != null) {
|
||||
final socket = SocketService();
|
||||
socket.emitRaw('support_leave', {'chatId': chatId});
|
||||
}
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _handleSend() async {
|
||||
final text = _inputController.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
|
||||
_inputController.clear();
|
||||
|
||||
// Stop typing
|
||||
final chatId = ref.read(supportChatProvider).chat?.id;
|
||||
if (chatId != null) {
|
||||
SocketService().emitRaw('support_typing_stop', {'chatId': chatId});
|
||||
}
|
||||
|
||||
try {
|
||||
await ref.read(supportChatProvider.notifier).sendMessage(text);
|
||||
_scrollToBottom();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Failed to send message'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
_inputController.text = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _handleInputChanged(String value) {
|
||||
final chatId = ref.read(supportChatProvider).chat?.id;
|
||||
if (chatId == null) return;
|
||||
|
||||
final socket = SocketService();
|
||||
if (!socket.isConnected) return;
|
||||
|
||||
socket.emitRaw('support_typing_start', {'chatId': chatId});
|
||||
|
||||
_typingTimeout?.cancel();
|
||||
_typingTimeout = Timer(const Duration(seconds: 3), () {
|
||||
socket.emitRaw('support_typing_stop', {'chatId': chatId});
|
||||
});
|
||||
}
|
||||
|
||||
String _formatTime(String dateStr) {
|
||||
final date = DateTime.parse(dateStr).toLocal();
|
||||
final h = date.hour;
|
||||
final m = date.minute.toString().padLeft(2, '0');
|
||||
final period = h >= 12 ? 'PM' : 'AM';
|
||||
final hour12 = h == 0 ? 12 : (h > 12 ? h - 12 : h);
|
||||
return '$hour12:$m $period';
|
||||
}
|
||||
|
||||
String _formatDate(String dateStr) {
|
||||
final date = DateTime.parse(dateStr).toLocal();
|
||||
final now = DateTime.now();
|
||||
final yesterday = now.subtract(const Duration(days: 1));
|
||||
|
||||
if (date.year == now.year &&
|
||||
date.month == now.month &&
|
||||
date.day == now.day) {
|
||||
return 'Today';
|
||||
}
|
||||
if (date.year == yesterday.year &&
|
||||
date.month == yesterday.month &&
|
||||
date.day == yesterday.day) {
|
||||
return 'Yesterday';
|
||||
}
|
||||
|
||||
const months = [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
|
||||
];
|
||||
return '${months[date.month - 1]} ${date.day}, ${date.year}';
|
||||
}
|
||||
|
||||
List<_DateGroup> _groupByDate(List<SupportMessage> messages) {
|
||||
final groups = <_DateGroup>[];
|
||||
for (final msg in messages) {
|
||||
final dateStr = _formatDate(msg.createdAt);
|
||||
if (groups.isNotEmpty && groups.last.date == dateStr) {
|
||||
groups.last.messages.add(msg);
|
||||
} else {
|
||||
groups.add(_DateGroup(date: dateStr, messages: [msg]));
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(supportChatProvider);
|
||||
final authState = ref.watch(authProvider);
|
||||
final currentUserId = authState.user?.id ?? '';
|
||||
|
||||
// Setup socket listeners once chat is loaded
|
||||
ref.listen<SupportChatState>(supportChatProvider, (prev, next) {
|
||||
if (prev?.chat == null && next.chat != null) {
|
||||
_setupSocketListeners(next.chat!.id, currentUserId);
|
||||
_scrollToBottom();
|
||||
}
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// Chat header
|
||||
_buildHeader(context, state.chat),
|
||||
|
||||
// Content
|
||||
Expanded(
|
||||
child: state.isLoading
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: AppColors.accentOrange,
|
||||
),
|
||||
)
|
||||
: state.error != null
|
||||
? _buildError(state.error!)
|
||||
: _buildMessageArea(state, currentUserId),
|
||||
),
|
||||
|
||||
// Input area
|
||||
if (!state.isLoading && state.error == null)
|
||||
_buildInputArea(state),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context, SupportChat? chat) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.primaryDark,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(0)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Back button
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: const Icon(
|
||||
Icons.arrow_back_ios,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Chat icon
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.support_agent,
|
||||
color: Colors.white,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Title + subtitle
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Support Chat',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
chat?.isClosed == true
|
||||
? 'This chat has been closed'
|
||||
: 'We typically reply within a few minutes',
|
||||
style: TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildError(String error) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
error,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 16,
|
||||
color: AppColors.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
GestureDetector(
|
||||
onTap: () =>
|
||||
ref.read(supportChatProvider.notifier).initChat(),
|
||||
child: const Text(
|
||||
'Try Again',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.accentOrange,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessageArea(SupportChatState state, String currentUserId) {
|
||||
final groups = _groupByDate(state.messages);
|
||||
|
||||
return Container(
|
||||
color: const Color(0xFFF9F9F9),
|
||||
child: state.messages.isEmpty
|
||||
? _buildEmptyState()
|
||||
: ListView(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
children: [
|
||||
// Load more
|
||||
if (state.hasMore)
|
||||
Center(
|
||||
child: GestureDetector(
|
||||
onTap: () =>
|
||||
ref.read(supportChatProvider.notifier).loadMore(),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
state.isLoadingMore
|
||||
? 'Loading...'
|
||||
: 'Load older messages',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 13,
|
||||
color: Color(0xFF5BA4A4),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Message groups
|
||||
for (final group in groups) ...[
|
||||
_buildDateSeparator(group.date),
|
||||
for (final msg in group.messages)
|
||||
_buildMessageBubble(msg, msg.senderId == currentUserId),
|
||||
],
|
||||
|
||||
// Typing indicator
|
||||
if (state.isTyping) _buildTypingIndicator(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
size: 48,
|
||||
color: AppColors.primaryDark.withValues(alpha: 0.3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Welcome to Support Chat',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primaryDark.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Send a message to start chatting\nwith our support team',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 14,
|
||||
color: AppColors.primaryDark.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDateSeparator(String date) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Divider(
|
||||
color: AppColors.primaryDark.withValues(alpha: 0.1),
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(
|
||||
date,
|
||||
style: TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 12,
|
||||
color: AppColors.primaryDark.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Divider(
|
||||
color: AppColors.primaryDark.withValues(alpha: 0.1),
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessageBubble(SupportMessage msg, bool isOwn) {
|
||||
return Align(
|
||||
alignment: isOwn ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.75,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isOwn ? AppColors.primaryDark : Colors.white,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(15),
|
||||
topRight: const Radius.circular(15),
|
||||
bottomLeft: Radius.circular(isOwn ? 15 : 4),
|
||||
bottomRight: Radius.circular(isOwn ? 4 : 15),
|
||||
),
|
||||
border: isOwn
|
||||
? null
|
||||
: Border.all(
|
||||
color: AppColors.primaryDark.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isOwn)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
'Support Team',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.accentOrange,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
msg.content,
|
||||
style: TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 14,
|
||||
color: isOwn ? Colors.white : AppColors.primaryDark,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: Text(
|
||||
_formatTime(msg.createdAt),
|
||||
style: TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 11,
|
||||
color: isOwn
|
||||
? Colors.white.withValues(alpha: 0.5)
|
||||
: AppColors.primaryDark.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTypingIndicator() {
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(15),
|
||||
topRight: Radius.circular(15),
|
||||
bottomRight: Radius.circular(15),
|
||||
bottomLeft: Radius.circular(4),
|
||||
),
|
||||
border: Border.all(
|
||||
color: AppColors.primaryDark.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(3, (i) {
|
||||
return TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0, end: 1),
|
||||
duration: Duration(milliseconds: 600 + i * 150),
|
||||
builder: (context, value, child) {
|
||||
return Container(
|
||||
margin: EdgeInsets.only(right: i < 2 ? 4 : 0),
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColors.primaryDark.withValues(alpha: 0.3),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInputArea(SupportChatState state) {
|
||||
if (state.chat?.isClosed == true) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: AppColors.primaryDark.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'This chat has been closed. Start a new chat from the FAQ page.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 14,
|
||||
color: AppColors.primaryDark.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: AppColors.primaryDark.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Text input
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F5F5),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _inputController,
|
||||
onChanged: _handleInputChanged,
|
||||
onSubmitted: (_) => _handleSend(),
|
||||
textInputAction: TextInputAction.send,
|
||||
enabled: !state.isSending,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 14,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Type your message...',
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 14,
|
||||
color: AppColors.primaryDark.withValues(alpha: 0.4),
|
||||
),
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
// Send button
|
||||
GestureDetector(
|
||||
onTap: state.isSending ? null : _handleSend,
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.accentOrange,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: state.isSending
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
Icons.send,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DateGroup {
|
||||
final String date;
|
||||
final List<SupportMessage> messages;
|
||||
|
||||
_DateGroup({required this.date, required this.messages});
|
||||
}
|
||||
Reference in New Issue
Block a user