feat: Implement push notification suppression for active chats, prevent duplicate self-messages, update the chat typing indicator, and add back buttons to static screens.

This commit is contained in:
pradeepkumar
2026-03-18 17:24:32 +05:30
parent cde137e07b
commit a4aa9bfe25
8 changed files with 196 additions and 35 deletions

View File

@@ -29,6 +29,10 @@ class PushNotificationService {
/// Callback when user taps a notification — set by the app to navigate.
void Function(String? actionUrl)? onNotificationTap;
/// The conversation ID currently being viewed — suppress message push
/// notifications for this conversation to avoid self-notifications.
String? activeConversationId;
/// Initialize Firebase Messaging and local notifications.
Future<void> initialize() async {
if (_initialized) return;
@@ -159,6 +163,14 @@ class PushNotificationService {
final notification = message.notification;
if (notification == null) return;
// Suppress message push notifications when user is viewing that conversation
final msgType = message.data['type'] as String?;
final convId = message.data['conversationId'] as String?;
if (msgType == 'message' && convId != null && convId == activeConversationId) {
debugPrint('[FCM] Suppressed notification — user is viewing conversation $convId');
return;
}
// Show local notification on Android (iOS handles it natively)
if (Platform.isAndroid) {
_localNotifications?.show(

View File

@@ -15,7 +15,8 @@ int _currentTabIndex(BuildContext context) {
return 1;
}
if (location.startsWith('/profile')) return 3;
return 0; // home and everything else
if (location == '/home') return 0;
return -1; // other pages (about, contact, faq, etc.) — no tab highlighted
}
class AppBottomNavBar extends ConsumerWidget {

View File

@@ -314,7 +314,36 @@ class AboutScreen extends ConsumerWidget {
physics: const AlwaysScrollableScrollPhysics(),
child: Column(
children: [
const SizedBox(height: 18),
// Back button
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Align(
alignment: Alignment.centerLeft,
child: GestureDetector(
onTap: () {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
} else {
context.go('/home');
}
},
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: AppColors.primaryDark.withValues(alpha: 0.08),
shape: BoxShape.circle,
),
child: const Icon(
Icons.arrow_back_ios_new_rounded,
color: AppColors.primaryDark,
size: 18,
),
),
),
),
),
const SizedBox(height: 10),
_buildHeroTitle(state.hero.headline),
const SizedBox(height: 16),
_buildHeroSubtitle(state.hero.description),

View File

@@ -31,7 +31,36 @@ class _ContactScreenState extends State<ContactScreen> {
return ListView(
padding: EdgeInsets.zero,
children: [
const SizedBox(height: 18),
// Back button
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Align(
alignment: Alignment.centerLeft,
child: GestureDetector(
onTap: () {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
} else {
context.go('/home');
}
},
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: AppColors.primaryDark.withValues(alpha: 0.08),
shape: BoxShape.circle,
),
child: const Icon(
Icons.arrow_back_ios_new_rounded,
color: AppColors.primaryDark,
size: 18,
),
),
),
),
),
const SizedBox(height: 10),
_buildTitle(),
const SizedBox(height: 10),
_buildDescription(),

View File

@@ -188,8 +188,38 @@ class FaqScreen extends ConsumerWidget {
}
return ListView(
padding: const EdgeInsets.fromLTRB(0, 20, 0, 30),
padding: const EdgeInsets.fromLTRB(0, 8, 0, 30),
children: [
// Back button
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
child: Align(
alignment: Alignment.centerLeft,
child: GestureDetector(
onTap: () {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
} else {
context.go('/home');
}
},
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: AppColors.primaryDark.withValues(alpha: 0.08),
shape: BoxShape.circle,
),
child: const Icon(
Icons.arrow_back_ios_new_rounded,
color: AppColors.primaryDark,
size: 18,
),
),
),
),
),
const SizedBox(height: 12),
// Title
const Padding(
padding: EdgeInsets.symmetric(horizontal: 24),

View File

@@ -317,7 +317,6 @@ class _TopProfessionalsSectionState
height: 226,
fit: BoxFit.cover,
alignment: Alignment.topCenter,
placeholder: (_) => _buildImagePlaceholder(index),
errorWidget: (_) => _buildImagePlaceholder(index),
)
: _buildImagePlaceholder(index),

View File

@@ -100,6 +100,10 @@ class MessagingNotifier extends StateNotifier<MessagingState> {
// Avoid duplicates (broadcast may arrive after ack for sender's own message)
if (currentMessages.any((m) => m.id == message.id)) return;
// Ignore own messages — the sender path handles these via sendMessage()
final isMine = message.senderId == _currentUserId;
if (isMine) return;
currentMessages.insert(0, message);
final updatedMessages =
Map<String, List<ChatMessage>>.from(state.messages)

View File

@@ -9,6 +9,7 @@ import 'package:real_estate_mobile/core/utils/image_url_resolver.dart';
import 'package:real_estate_mobile/core/widgets/s3_image.dart';
import 'package:real_estate_mobile/features/auth/presentation/providers/auth_provider.dart';
import 'package:real_estate_mobile/features/messaging/data/models/messaging_models.dart';
import 'package:real_estate_mobile/core/services/push_notification_service.dart';
import 'package:real_estate_mobile/features/messaging/data/socket_service.dart';
import 'package:real_estate_mobile/features/messaging/presentation/providers/messaging_provider.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -40,6 +41,8 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
notifier.setActiveConversation(widget.conversationId);
notifier.loadMessages(widget.conversationId);
notifier.markAsRead(widget.conversationId);
// Suppress push notifications for this conversation while viewing
PushNotificationService().activeConversationId = widget.conversationId;
// Join socket room for real-time updates
if (_socket.isConnected) {
_socket.joinConversation(widget.conversationId);
@@ -65,6 +68,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
}
_socket.leaveConversation(widget.conversationId);
ref.read(messagingProvider.notifier).setActiveConversation(null);
PushNotificationService().activeConversationId = null;
_messageController.dispose();
_scrollController.dispose();
_messageFocusNode.dispose();
@@ -1124,41 +1128,38 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
return Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildDot(0),
const SizedBox(width: 4),
_buildDot(1),
const SizedBox(width: 4),
_buildDot(2),
],
padding: const EdgeInsets.only(left: 24, bottom: 8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: AppColors.primaryDark.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(15),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_BouncingDot(delay: 0),
const SizedBox(width: 4),
_BouncingDot(delay: 150),
const SizedBox(width: 4),
_BouncingDot(delay: 300),
const SizedBox(width: 8),
Text(
_formatTime(DateTime.now().toIso8601String()),
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 12,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark.withValues(alpha: 0.5),
),
),
],
),
),
),
);
}
Widget _buildDot(int index) {
return TweenAnimationBuilder<double>(
tween: Tween(begin: 0.3, end: 1.0),
duration: Duration(milliseconds: 600 + (index * 200)),
builder: (context, value, child) {
return Opacity(
opacity: value,
child: Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: AppColors.subtleText,
shape: BoxShape.circle,
),
),
);
},
);
}
// ============================================================
// IMAGE FULL SCREEN VIEWER
// ============================================================
@@ -1477,3 +1478,59 @@ class _AvatarFallback extends StatelessWidget {
);
}
}
/// Bouncing dot animation matching web's animate-bounce with staggered delay.
class _BouncingDot extends StatefulWidget {
final int delay;
const _BouncingDot({required this.delay});
@override
State<_BouncingDot> createState() => _BouncingDotState();
}
class _BouncingDotState extends State<_BouncingDot>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 600),
);
_animation = Tween<double>(begin: 0, end: -6).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
Future.delayed(Duration(milliseconds: widget.delay), () {
if (mounted) _controller.repeat(reverse: true);
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Transform.translate(
offset: Offset(0, _animation.value),
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: AppColors.primaryDark.withValues(alpha: 0.5),
shape: BoxShape.circle,
),
),
);
},
);
}
}