Files
mobile-app/lib/features/messaging/presentation/screens/conversations_screen.dart

625 lines
20 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:shimmer/shimmer.dart';
import 'package:real_estate_mobile/core/constants/app_colors.dart';
import 'package:real_estate_mobile/core/widgets/s3_image.dart';
import 'package:real_estate_mobile/features/messaging/data/models/messaging_models.dart';
import 'package:real_estate_mobile/features/messaging/presentation/providers/messaging_provider.dart';
class ConversationsScreen extends ConsumerStatefulWidget {
const ConversationsScreen({super.key});
@override
ConsumerState<ConversationsScreen> createState() =>
_ConversationsScreenState();
}
class _ConversationsScreenState extends ConsumerState<ConversationsScreen> {
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
bool _isSearchActive = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(messagingProvider.notifier).loadConversations();
});
_searchController.addListener(() {
setState(() {
_searchQuery = _searchController.text.trim().toLowerCase();
});
});
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
List<Conversation> _filteredConversations(List<Conversation> conversations) {
if (_searchQuery.isEmpty) return conversations;
return conversations
.where((c) => c.otherParty.name.toLowerCase().contains(_searchQuery))
.toList();
}
@override
Widget build(BuildContext context) {
final state = ref.watch(messagingProvider);
final filtered = _filteredConversations(state.conversations);
return Column(
children: [
_buildHeader(),
Expanded(child: _buildConversationList(state, filtered)),
],
);
}
// -- Header with back arrow, search, icons --
Widget _buildHeader() {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: Row(
children: [
// Back arrow (outside the search box)
GestureDetector(
onTap: () {
if (_isSearchActive) {
setState(() {
_isSearchActive = false;
_searchController.clear();
});
} else {
context.go('/home');
}
},
behavior: HitTestBehavior.opaque,
child: const Padding(
padding: EdgeInsets.only(right: 10),
child: Icon(
Icons.arrow_back_ios_new,
size: 16,
color: AppColors.primaryDark,
),
),
),
// Search box with border
Expanded(
child: GestureDetector(
onTap: () {
if (!_isSearchActive) {
setState(() => _isSearchActive = true);
}
},
child: SizedBox(
height: 44,
child: _isSearchActive
? TextField(
controller: _searchController,
autofocus: true,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.primaryDark,
),
decoration: InputDecoration(
hintText: 'Search Messages',
hintStyle: const TextStyle(
fontFamily: 'Fractul',
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.hintText,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 18,
vertical: 12,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(22),
borderSide: BorderSide(
color: AppColors.primaryDark
.withValues(alpha: 0.25),
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(22),
borderSide: BorderSide(
color: AppColors.primaryDark
.withValues(alpha: 0.25),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(22),
borderSide: BorderSide(
color: AppColors.primaryDark
.withValues(alpha: 0.4),
),
),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.close, size: 18),
color: AppColors.hintText,
onPressed: () {
_searchController.clear();
setState(() {});
},
)
: null,
),
)
: Container(
alignment: Alignment.centerLeft,
decoration: BoxDecoration(
border: Border.all(
color: AppColors.primaryDark
.withValues(alpha: 0.25),
),
borderRadius: BorderRadius.circular(22),
),
padding:
const EdgeInsets.symmetric(horizontal: 18),
child: const Text(
'Search Messages',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.primaryDark,
),
),
),
),
),
),
const SizedBox(width: 10),
// Search icon (outside the search box)
GestureDetector(
onTap: () {
setState(() {
if (_isSearchActive) {
_isSearchActive = false;
_searchController.clear();
} else {
_isSearchActive = true;
}
});
},
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(
_isSearchActive ? Icons.close : Icons.search,
size: 22,
color: AppColors.primaryDark,
),
),
),
const SizedBox(width: 8),
// Three dots menu (outside the search box)
GestureDetector(
onTap: () {},
behavior: HitTestBehavior.opaque,
child: const Padding(
padding: EdgeInsets.all(4),
child: Icon(
Icons.more_horiz,
size: 22,
color: AppColors.primaryDark,
),
),
),
],
),
);
}
// -- Shimmer Loading State --
Widget _buildShimmerLoading() {
return Shimmer.fromColors(
baseColor: const Color(0xFFE8E8E8),
highlightColor: const Color(0xFFF5F5F5),
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
physics: const NeverScrollableScrollPhysics(),
itemCount: 6,
itemBuilder: (_, index) => Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 130,
height: 14,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 8),
Container(
width: double.infinity,
height: 12,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
const SizedBox(width: 8),
Container(
width: 55,
height: 12,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
),
);
}
// -- Conversation List --
Widget _buildConversationList(
MessagingState state, List<Conversation> filtered) {
if (state.isLoadingConversations) {
return _buildShimmerLoading();
}
if (filtered.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.chat_bubble_outline_rounded,
color: AppColors.hintText,
size: 56,
),
const SizedBox(height: 16),
Text(
_searchQuery.isNotEmpty
? 'No conversations found'
: 'No messages yet',
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 8),
Text(
_searchQuery.isNotEmpty
? 'Try a different search term'
: 'Start a conversation with a professional',
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.subtleText,
),
),
if (state.error != null) ...[
const SizedBox(height: 20),
TextButton.icon(
onPressed: () =>
ref.read(messagingProvider.notifier).loadConversations(),
icon: const Icon(
Icons.refresh,
color: AppColors.accentOrange,
size: 18,
),
label: const Text(
'Retry',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.accentOrange,
),
),
),
],
],
),
);
}
return RefreshIndicator(
color: AppColors.accentOrange,
onRefresh: () =>
ref.read(messagingProvider.notifier).loadConversations(),
child: ListView.separated(
padding: EdgeInsets.zero,
itemCount: filtered.length,
separatorBuilder: (context, index) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Divider(
color: AppColors.primaryDark.withValues(alpha: 0.1),
height: 1,
thickness: 1,
),
),
itemBuilder: (context, index) {
return _ConversationTile(
conversation: filtered[index],
onTap: () =>
context.go('/messages/chat/${filtered[index].id}'),
);
},
),
);
}
}
// -- Conversation Tile Widget --
class _ConversationTile extends StatelessWidget {
final Conversation conversation;
final VoidCallback onTap;
const _ConversationTile({
required this.conversation,
required this.onTap,
});
static bool _isGifUrl(String text) {
final lower = text.trim().toLowerCase();
return lower.endsWith('.gif') ||
lower.contains('giphy.com/') ||
lower.contains('giphy.gif') ||
lower.contains('tenor.com/');
}
static String _formatPreview(String text) {
if (text.isEmpty) return text;
if (_isGifUrl(text)) return 'GIF';
return text;
}
bool _hasAvatar(String? avatarKey) {
return avatarKey != null && avatarKey.isNotEmpty;
}
@override
Widget build(BuildContext context) {
final otherParty = conversation.otherParty;
final hasAvatarImage = _hasAvatar(otherParty.avatar);
final lastMessage = _formatPreview(conversation.lastMessageText ?? '');
final timestamp = _formatTimestamp(conversation.lastMessageAt);
final unread = conversation.unreadCount;
final firstLetter = otherParty.name.isNotEmpty
? otherParty.name[0].toUpperCase()
: '?';
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Avatar with online indicator
Stack(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: AppColors.primaryDark.withValues(alpha: 0.15),
width: 1.5,
),
),
child: ClipOval(
child: hasAvatarImage
? S3Image(
imageUrl: otherParty.avatar!,
width: 56,
height: 56,
fit: BoxFit.cover,
errorWidget: (_) => _AvatarFallback(
letter: firstLetter,
),
)
: _AvatarFallback(letter: firstLetter),
),
),
if (otherParty.isOnline)
Positioned(
right: 1,
bottom: 1,
child: Container(
width: 14,
height: 14,
decoration: BoxDecoration(
color: const Color(0xFF4CAF50),
shape: BoxShape.circle,
border: Border.all(
color: Colors.white,
width: 2.5,
),
),
),
),
],
),
const SizedBox(width: 14),
// Name and last message
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Name row with optional unread badge
Row(
children: [
Expanded(
child: Text(
otherParty.name,
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight:
unread > 0 ? FontWeight.w700 : FontWeight.w600,
color: AppColors.primaryDark,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (unread > 0) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: AppColors.accentOrange,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'$unread',
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 11,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
),
],
],
),
const SizedBox(height: 4),
Text(
lastMessage,
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 13,
fontWeight: FontWeight.w400,
color: unread > 0
? AppColors.primaryDark
: AppColors.hintText,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 10),
// Timestamp
Text(
timestamp,
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 13,
fontWeight: FontWeight.w400,
color: unread > 0
? AppColors.primaryDark
: AppColors.hintText,
),
),
],
),
),
);
}
}
// -- Avatar Fallback --
class _AvatarFallback extends StatelessWidget {
final String letter;
const _AvatarFallback({required this.letter});
@override
Widget build(BuildContext context) {
return Container(
width: 56,
height: 56,
decoration: const BoxDecoration(
color: Color(0xFFE8E8E8),
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Text(
letter,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 22,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
);
}
}
// -- Timestamp Formatting --
String _formatTimestamp(String? isoTimestamp) {
if (isoTimestamp == null || isoTimestamp.isEmpty) return '';
final dateTime = DateTime.tryParse(isoTimestamp);
if (dateTime == null) return '';
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final messageDate = DateTime(dateTime.year, dateTime.month, dateTime.day);
final localTime = dateTime.toLocal();
final diff = now.difference(dateTime);
// "Now" for messages within the last 2 minutes
if (diff.inMinutes < 2) {
return 'Now';
}
// Same day: show relative time
if (messageDate == today) {
if (diff.inHours >= 1) {
return '${diff.inHours} hour${diff.inHours > 1 ? 's' : ''} Ago';
}
return '${diff.inMinutes} min Ago';
}
// Within last 30 days: show "X Days Ago"
final daysDiff = today.difference(messageDate).inDays;
if (daysDiff == 1) {
return 'Yesterday';
}
if (daysDiff <= 30) {
return '$daysDiff Days Ago';
}
// Older: show month and day
const months = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
];
return '${months[localTime.month - 1]} ${localTime.day}';
}