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:
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -317,7 +317,6 @@ class _TopProfessionalsSectionState
|
||||
height: 226,
|
||||
fit: BoxFit.cover,
|
||||
alignment: Alignment.topCenter,
|
||||
placeholder: (_) => _buildImagePlaceholder(index),
|
||||
errorWidget: (_) => _buildImagePlaceholder(index),
|
||||
)
|
||||
: _buildImagePlaceholder(index),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,38 +1128,35 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
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: [
|
||||
_buildDot(0),
|
||||
_BouncingDot(delay: 0),
|
||||
const SizedBox(width: 4),
|
||||
_buildDot(1),
|
||||
_BouncingDot(delay: 150),
|
||||
const SizedBox(width: 4),
|
||||
_buildDot(2),
|
||||
_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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user