452 lines
14 KiB
Dart
452 lines
14 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:real_estate_mobile/core/constants/app_colors.dart';
|
|
import 'package:real_estate_mobile/features/notifications/data/models/notification_model.dart';
|
|
import 'package:real_estate_mobile/features/notifications/presentation/providers/notification_provider.dart';
|
|
|
|
class NotificationsScreen extends ConsumerStatefulWidget {
|
|
const NotificationsScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<NotificationsScreen> createState() =>
|
|
_NotificationsScreenState();
|
|
}
|
|
|
|
class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
|
|
final ScrollController _scrollController = ScrollController();
|
|
String _activeFilter = 'all';
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
Future.microtask(() {
|
|
ref.read(notificationListProvider.notifier).loadNotifications();
|
|
});
|
|
_scrollController.addListener(_onScroll);
|
|
}
|
|
|
|
void _onScroll() {
|
|
if (_scrollController.position.pixels >=
|
|
_scrollController.position.maxScrollExtent - 200) {
|
|
ref.read(notificationListProvider.notifier).loadMore();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_scrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final state = ref.watch(notificationListProvider);
|
|
|
|
return Column(
|
|
children: [
|
|
// ── Header bar matching Figma: "Notifications" with bell icon ──
|
|
Container(
|
|
height: 55,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(7),
|
|
border: Border.all(
|
|
color: AppColors.primaryDark.withValues(alpha: 0.1),
|
|
width: 0.1,
|
|
),
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.notifications,
|
|
color: AppColors.accentOrange,
|
|
size: 22,
|
|
),
|
|
const SizedBox(width: 8),
|
|
const Text(
|
|
'Notifications',
|
|
style: TextStyle(
|
|
fontFamily: 'Fractul',
|
|
fontSize: 17,
|
|
fontWeight: FontWeight.w700,
|
|
color: AppColors.primaryDark,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// ── Filter tabs + Mark all as read ──
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
child: Row(
|
|
children: [
|
|
_buildFilterChip('All', 'all'),
|
|
const SizedBox(width: 8),
|
|
Builder(builder: (context) {
|
|
final unreadCount = ref.watch(unreadCountProvider);
|
|
final label = unreadCount > 0
|
|
? 'Unread ($unreadCount)'
|
|
: 'Unread';
|
|
return _buildFilterChip(label, 'unread');
|
|
}),
|
|
const Spacer(),
|
|
GestureDetector(
|
|
onTap: () {
|
|
ref
|
|
.read(notificationListProvider.notifier)
|
|
.markAllAsRead();
|
|
},
|
|
child: const Text(
|
|
'Mark all as read',
|
|
style: TextStyle(
|
|
fontFamily: 'SourceSerif4',
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w400,
|
|
color: AppColors.accentOrange,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// ── Notification list ──
|
|
Expanded(
|
|
child: _buildContent(state),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildFilterChip(String label, String filter) {
|
|
final isActive = _activeFilter == filter;
|
|
return GestureDetector(
|
|
onTap: () {
|
|
setState(() => _activeFilter = filter);
|
|
ref
|
|
.read(notificationListProvider.notifier)
|
|
.loadNotifications(filter: filter);
|
|
},
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: isActive ? AppColors.primaryDark : Colors.transparent,
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(
|
|
color: isActive
|
|
? AppColors.primaryDark
|
|
: AppColors.primaryDark.withValues(alpha: 0.2),
|
|
),
|
|
),
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontFamily: 'Fractul',
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w500,
|
|
color: isActive ? Colors.white : AppColors.primaryDark,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildContent(NotificationListState state) {
|
|
if (state.isLoading) {
|
|
return const Center(
|
|
child: CircularProgressIndicator(
|
|
color: AppColors.accentOrange,
|
|
strokeWidth: 2,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (state.error != null) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(Icons.error_outline,
|
|
size: 48, color: AppColors.accentOrange),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'Failed to load notifications',
|
|
style: const TextStyle(
|
|
fontFamily: 'Fractul',
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.primaryDark,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextButton(
|
|
onPressed: () {
|
|
ref
|
|
.read(notificationListProvider.notifier)
|
|
.loadNotifications(filter: _activeFilter);
|
|
},
|
|
child: const Text('Retry',
|
|
style: TextStyle(color: AppColors.accentOrange)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
if (state.notifications.isEmpty) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.notifications_off_outlined,
|
|
size: 48,
|
|
color: AppColors.primaryDark.withValues(alpha: 0.3)),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
_activeFilter == 'unread'
|
|
? 'No unread notifications'
|
|
: 'No notifications yet',
|
|
style: TextStyle(
|
|
fontFamily: 'Fractul',
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w500,
|
|
color: AppColors.primaryDark.withValues(alpha: 0.5),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return ListView.builder(
|
|
controller: _scrollController,
|
|
padding: EdgeInsets.zero,
|
|
itemCount: state.notifications.length + (state.isLoadingMore ? 1 : 0),
|
|
itemBuilder: (context, index) {
|
|
if (index == state.notifications.length) {
|
|
return const Padding(
|
|
padding: EdgeInsets.all(16),
|
|
child: Center(
|
|
child: CircularProgressIndicator(
|
|
color: AppColors.accentOrange,
|
|
strokeWidth: 2,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return _buildNotificationTile(state.notifications[index]);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildNotificationTile(AppNotification notification) {
|
|
return GestureDetector(
|
|
onTap: () {
|
|
if (!notification.read) {
|
|
ref
|
|
.read(notificationListProvider.notifier)
|
|
.markAsRead(notification.id);
|
|
}
|
|
_navigateToNotification(notification);
|
|
},
|
|
behavior: HitTestBehavior.opaque,
|
|
child: Column(
|
|
children: [
|
|
Padding(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// ── Avatar with unread dot ──
|
|
SizedBox(
|
|
width: 53,
|
|
height: 52,
|
|
child: Stack(
|
|
children: [
|
|
Container(
|
|
width: 52,
|
|
height: 52,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: _iconBgColor(notification.type),
|
|
),
|
|
child: Center(
|
|
child: Icon(
|
|
_iconForType(notification.type),
|
|
color: AppColors.primaryDark,
|
|
size: 24,
|
|
),
|
|
),
|
|
),
|
|
if (!notification.read)
|
|
Positioned(
|
|
right: 0,
|
|
bottom: 4,
|
|
child: Container(
|
|
width: 8,
|
|
height: 8,
|
|
decoration: const BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: AppColors.accentOrange,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
|
|
// ── Title + Description ──
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
notification.title,
|
|
style: TextStyle(
|
|
fontFamily: 'Fractul',
|
|
fontSize: 14,
|
|
fontWeight:
|
|
notification.read ? FontWeight.w500 : FontWeight.w700,
|
|
color: AppColors.primaryDark,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
_formatDescription(notification.description),
|
|
style: TextStyle(
|
|
fontFamily: 'SourceSerif4',
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w400,
|
|
color: AppColors.primaryDark
|
|
.withValues(alpha: notification.read ? 0.5 : 0.8),
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
|
|
// ── Time ago ──
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 2),
|
|
child: Text(
|
|
_timeAgo(notification.createdAt),
|
|
style: TextStyle(
|
|
fontFamily: 'SourceSerif4',
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w400,
|
|
color:
|
|
AppColors.primaryDark.withValues(alpha: 0.6),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Divider(
|
|
height: 0.1,
|
|
thickness: 0.1,
|
|
color: AppColors.primaryDark.withValues(alpha: 0.1),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _navigateToNotification(AppNotification notification) {
|
|
final data = notification.data;
|
|
final type = data?['type'] ?? notification.type;
|
|
|
|
switch (type) {
|
|
case 'message':
|
|
final conversationId = data?['conversationId'];
|
|
if (conversationId != null) {
|
|
context.push('/messages/chat/$conversationId');
|
|
} else {
|
|
context.go('/messages');
|
|
}
|
|
break;
|
|
case 'connection_request':
|
|
case 'connection':
|
|
context.go('/agent/network');
|
|
break;
|
|
case 'connection_response':
|
|
final status = data?['status'];
|
|
if (status == 'ACCEPTED') {
|
|
context.go('/messages');
|
|
} else {
|
|
context.go('/home');
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
IconData _iconForType(String type) {
|
|
switch (type) {
|
|
case 'connection':
|
|
return Icons.person_outline;
|
|
case 'message':
|
|
return Icons.chat_bubble_outline;
|
|
case 'request':
|
|
return Icons.assignment_outlined;
|
|
case 'update':
|
|
return Icons.notifications_outlined;
|
|
case 'system':
|
|
return Icons.settings_outlined;
|
|
default:
|
|
return Icons.notifications_outlined;
|
|
}
|
|
}
|
|
|
|
Color _iconBgColor(String type) {
|
|
switch (type) {
|
|
case 'connection':
|
|
return const Color(0xFFE8F5E9);
|
|
case 'message':
|
|
return const Color(0xFFE3F2FD);
|
|
case 'request':
|
|
return const Color(0xFFFFF3E0);
|
|
case 'update':
|
|
return const Color(0xFFF3E5F5);
|
|
case 'system':
|
|
return const Color(0xFFF5F5F5);
|
|
default:
|
|
return const Color(0xFFF5F5F5);
|
|
}
|
|
}
|
|
|
|
String _formatDescription(String description) {
|
|
final trimmed = description.trim();
|
|
// Detect raw URLs (image/gif links) and show friendly text instead
|
|
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
|
|
final lower = trimmed.toLowerCase();
|
|
if (lower.contains('.gif')) return 'Sent a GIF';
|
|
if (lower.contains('.png') || lower.contains('.jpg') || lower.contains('.jpeg') || lower.contains('.webp')) return 'Sent an image';
|
|
return 'Sent a link';
|
|
}
|
|
return description;
|
|
}
|
|
|
|
String _timeAgo(DateTime dateTime) {
|
|
final now = DateTime.now();
|
|
final diff = now.difference(dateTime);
|
|
|
|
if (diff.inSeconds < 60) return 'Just now';
|
|
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
|
if (diff.inHours < 24) return '${diff.inHours}hr ago';
|
|
if (diff.inDays < 7) return '${diff.inDays}d ago';
|
|
if (diff.inDays < 30) return '${(diff.inDays / 7).floor()}w ago';
|
|
return '${(diff.inDays / 30).floor()}mo ago';
|
|
}
|
|
}
|