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.
|
/// Callback when user taps a notification — set by the app to navigate.
|
||||||
void Function(String? actionUrl)? onNotificationTap;
|
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.
|
/// Initialize Firebase Messaging and local notifications.
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
if (_initialized) return;
|
if (_initialized) return;
|
||||||
@@ -159,6 +163,14 @@ class PushNotificationService {
|
|||||||
final notification = message.notification;
|
final notification = message.notification;
|
||||||
if (notification == null) return;
|
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)
|
// Show local notification on Android (iOS handles it natively)
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
_localNotifications?.show(
|
_localNotifications?.show(
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ int _currentTabIndex(BuildContext context) {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
if (location.startsWith('/profile')) return 3;
|
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 {
|
class AppBottomNavBar extends ConsumerWidget {
|
||||||
|
|||||||
@@ -314,7 +314,36 @@ class AboutScreen extends ConsumerWidget {
|
|||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
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),
|
_buildHeroTitle(state.hero.headline),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildHeroSubtitle(state.hero.description),
|
_buildHeroSubtitle(state.hero.description),
|
||||||
|
|||||||
@@ -31,7 +31,36 @@ class _ContactScreenState extends State<ContactScreen> {
|
|||||||
return ListView(
|
return ListView(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
children: [
|
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(),
|
_buildTitle(),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
_buildDescription(),
|
_buildDescription(),
|
||||||
|
|||||||
@@ -188,8 +188,38 @@ class FaqScreen extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(0, 20, 0, 30),
|
padding: const EdgeInsets.fromLTRB(0, 8, 0, 30),
|
||||||
children: [
|
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
|
// Title
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 24),
|
padding: EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
|||||||
@@ -317,7 +317,6 @@ class _TopProfessionalsSectionState
|
|||||||
height: 226,
|
height: 226,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
alignment: Alignment.topCenter,
|
alignment: Alignment.topCenter,
|
||||||
placeholder: (_) => _buildImagePlaceholder(index),
|
|
||||||
errorWidget: (_) => _buildImagePlaceholder(index),
|
errorWidget: (_) => _buildImagePlaceholder(index),
|
||||||
)
|
)
|
||||||
: _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)
|
// Avoid duplicates (broadcast may arrive after ack for sender's own message)
|
||||||
if (currentMessages.any((m) => m.id == message.id)) return;
|
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);
|
currentMessages.insert(0, message);
|
||||||
final updatedMessages =
|
final updatedMessages =
|
||||||
Map<String, List<ChatMessage>>.from(state.messages)
|
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/core/widgets/s3_image.dart';
|
||||||
import 'package:real_estate_mobile/features/auth/presentation/providers/auth_provider.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/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/data/socket_service.dart';
|
||||||
import 'package:real_estate_mobile/features/messaging/presentation/providers/messaging_provider.dart';
|
import 'package:real_estate_mobile/features/messaging/presentation/providers/messaging_provider.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
@@ -40,6 +41,8 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
|||||||
notifier.setActiveConversation(widget.conversationId);
|
notifier.setActiveConversation(widget.conversationId);
|
||||||
notifier.loadMessages(widget.conversationId);
|
notifier.loadMessages(widget.conversationId);
|
||||||
notifier.markAsRead(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
|
// Join socket room for real-time updates
|
||||||
if (_socket.isConnected) {
|
if (_socket.isConnected) {
|
||||||
_socket.joinConversation(widget.conversationId);
|
_socket.joinConversation(widget.conversationId);
|
||||||
@@ -65,6 +68,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
|||||||
}
|
}
|
||||||
_socket.leaveConversation(widget.conversationId);
|
_socket.leaveConversation(widget.conversationId);
|
||||||
ref.read(messagingProvider.notifier).setActiveConversation(null);
|
ref.read(messagingProvider.notifier).setActiveConversation(null);
|
||||||
|
PushNotificationService().activeConversationId = null;
|
||||||
_messageController.dispose();
|
_messageController.dispose();
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
_messageFocusNode.dispose();
|
_messageFocusNode.dispose();
|
||||||
@@ -1124,41 +1128,38 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
|||||||
return Align(
|
return Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
padding: const EdgeInsets.only(left: 24, bottom: 8),
|
||||||
child: Row(
|
child: Container(
|
||||||
mainAxisSize: MainAxisSize.min,
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
children: [
|
decoration: BoxDecoration(
|
||||||
_buildDot(0),
|
color: AppColors.primaryDark.withValues(alpha: 0.1),
|
||||||
const SizedBox(width: 4),
|
borderRadius: BorderRadius.circular(15),
|
||||||
_buildDot(1),
|
),
|
||||||
const SizedBox(width: 4),
|
child: Row(
|
||||||
_buildDot(2),
|
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
|
// 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user