feat: Implement support chat functionality, add 2FA verification screen and routing, and update dependencies.

This commit is contained in:
pradeepkumar
2026-03-11 13:26:16 +05:30
parent 4780eb9a63
commit 6928697c5c
17 changed files with 1660 additions and 20 deletions

View File

@@ -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);
});

View File

@@ -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});
}