feat: Implement support chat functionality, add 2FA verification screen and routing, and update dependencies.
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
class SupportChat {
|
||||
final String id;
|
||||
final String userId;
|
||||
final String status; // OPEN, CLOSED
|
||||
final String? lastMessageAt;
|
||||
final String? lastMessageText;
|
||||
final int userUnreadCount;
|
||||
final int adminUnreadCount;
|
||||
final String createdAt;
|
||||
final String updatedAt;
|
||||
|
||||
const SupportChat({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
required this.status,
|
||||
this.lastMessageAt,
|
||||
this.lastMessageText,
|
||||
required this.userUnreadCount,
|
||||
required this.adminUnreadCount,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
factory SupportChat.fromJson(Map<String, dynamic> json) {
|
||||
return SupportChat(
|
||||
id: json['id'] as String,
|
||||
userId: json['userId'] as String,
|
||||
status: json['status'] as String? ?? 'OPEN',
|
||||
lastMessageAt: json['lastMessageAt'] as String?,
|
||||
lastMessageText: json['lastMessageText'] as String?,
|
||||
userUnreadCount: json['userUnreadCount'] as int? ?? 0,
|
||||
adminUnreadCount: json['adminUnreadCount'] as int? ?? 0,
|
||||
createdAt: json['createdAt'] as String,
|
||||
updatedAt: json['updatedAt'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
bool get isClosed => status == 'CLOSED';
|
||||
}
|
||||
|
||||
class SupportMessage {
|
||||
final String id;
|
||||
final String chatId;
|
||||
final String senderId;
|
||||
final String senderRole;
|
||||
final String content;
|
||||
final String createdAt;
|
||||
|
||||
const SupportMessage({
|
||||
required this.id,
|
||||
required this.chatId,
|
||||
required this.senderId,
|
||||
required this.senderRole,
|
||||
required this.content,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory SupportMessage.fromJson(Map<String, dynamic> json) {
|
||||
return SupportMessage(
|
||||
id: json['id'] as String,
|
||||
chatId: json['chatId'] as String,
|
||||
senderId: json['senderId'] as String,
|
||||
senderRole: json['senderRole'] as String? ?? 'USER',
|
||||
content: json['content'] as String,
|
||||
createdAt: json['createdAt'] as String,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SupportPagination {
|
||||
final int page;
|
||||
final int limit;
|
||||
final int total;
|
||||
final int pages;
|
||||
|
||||
const SupportPagination({
|
||||
required this.page,
|
||||
required this.limit,
|
||||
required this.total,
|
||||
required this.pages,
|
||||
});
|
||||
|
||||
factory SupportPagination.fromJson(Map<String, dynamic> json) {
|
||||
return SupportPagination(
|
||||
page: json['page'] as int? ?? 1,
|
||||
limit: json['limit'] as int? ?? 50,
|
||||
total: json['total'] as int? ?? 0,
|
||||
pages: json['pages'] as int? ?? 1,
|
||||
);
|
||||
}
|
||||
|
||||
bool get hasMore => page < pages;
|
||||
}
|
||||
98
lib/features/support_chat/data/support_chat_repository.dart
Normal file
98
lib/features/support_chat/data/support_chat_repository.dart
Normal file
@@ -0,0 +1,98 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:real_estate_mobile/core/constants/api_constants.dart';
|
||||
import 'package:real_estate_mobile/core/network/api_client.dart';
|
||||
import 'package:real_estate_mobile/core/network/api_exceptions.dart';
|
||||
import 'package:real_estate_mobile/features/support_chat/data/models/support_chat_models.dart';
|
||||
|
||||
final _log = Logger(printer: PrettyPrinter(methodCount: 0));
|
||||
|
||||
class SupportChatRepository {
|
||||
final Dio _dio = ApiClient.instance.dio;
|
||||
|
||||
/// POST /support-chat — get or create a support chat
|
||||
Future<SupportChat> getOrCreateChat() async {
|
||||
try {
|
||||
final response = await _dio.post(ApiConstants.supportChat);
|
||||
return SupportChat.fromJson(
|
||||
response.data['data'] as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
if (e.error is ApiException) throw e.error as ApiException;
|
||||
throw ApiException(
|
||||
message: e.message ?? 'Failed to create support chat',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /support-chat/my — get user's existing chat
|
||||
Future<SupportChat> getMyChat() async {
|
||||
try {
|
||||
final response = await _dio.get('${ApiConstants.supportChat}/my');
|
||||
return SupportChat.fromJson(
|
||||
response.data['data'] as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
if (e.error is ApiException) throw e.error as ApiException;
|
||||
throw ApiException(
|
||||
message: e.message ?? 'Failed to fetch support chat',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /support-chat/:chatId/messages
|
||||
Future<({List<SupportMessage> messages, SupportPagination pagination})>
|
||||
getMessages(String chatId, {int page = 1, int limit = 50}) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'${ApiConstants.supportChat}/$chatId/messages',
|
||||
queryParameters: {'page': page, 'limit': limit},
|
||||
);
|
||||
final data = response.data['data'] as Map<String, dynamic>;
|
||||
final messages = (data['messages'] as List<dynamic>)
|
||||
.map((e) => SupportMessage.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
final pagination = SupportPagination.fromJson(
|
||||
data['pagination'] as Map<String, dynamic>);
|
||||
return (messages: messages, pagination: pagination);
|
||||
} on DioException catch (e) {
|
||||
if (e.error is ApiException) throw e.error as ApiException;
|
||||
throw ApiException(
|
||||
message: e.message ?? 'Failed to fetch messages',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /support-chat/:chatId/messages
|
||||
Future<SupportMessage> sendMessage(String chatId, String content) async {
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
'${ApiConstants.supportChat}/$chatId/messages',
|
||||
data: {'content': content},
|
||||
);
|
||||
return SupportMessage.fromJson(
|
||||
response.data['data'] as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
if (e.error is ApiException) throw e.error as ApiException;
|
||||
throw ApiException(
|
||||
message: e.message ?? 'Failed to send message',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// PATCH /support-chat/:chatId/read
|
||||
Future<void> markAsRead(String chatId) async {
|
||||
try {
|
||||
await _dio.patch('${ApiConstants.supportChat}/$chatId/read');
|
||||
} on DioException catch (e) {
|
||||
_log.w('Failed to mark support chat as read: ${e.message}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final supportChatRepositoryProvider = Provider<SupportChatRepository>((ref) {
|
||||
return SupportChatRepository();
|
||||
});
|
||||
@@ -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