2026-03-09 08:14:10 +05:30
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
|
import 'package:real_estate_mobile/core/constants/app_colors.dart';
|
|
|
|
|
import 'package:real_estate_mobile/features/profile/presentation/providers/profile_provider.dart';
|
|
|
|
|
import 'package:real_estate_mobile/features/profile/presentation/widgets/profile_shared_widgets.dart';
|
|
|
|
|
|
|
|
|
|
class NotificationsTab extends ConsumerStatefulWidget {
|
|
|
|
|
const NotificationsTab({super.key});
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
ConsumerState<NotificationsTab> createState() => _NotificationsTabState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _NotificationsTabState extends ConsumerState<NotificationsTab> {
|
|
|
|
|
final Map<String, Map<String, bool>> _notificationPrefs = {
|
|
|
|
|
'newLeadAlerts': {'email': true, 'inApp': true},
|
|
|
|
|
'messageUpdates': {'email': false, 'inApp': true},
|
|
|
|
|
'urgentRequests': {'email': true, 'inApp': true},
|
|
|
|
|
};
|
|
|
|
|
String _digestFrequency = 'instant';
|
|
|
|
|
bool _notificationsLoaded = false;
|
|
|
|
|
bool _isLoadingNotifications = false;
|
|
|
|
|
bool _isSavingNotifications = false;
|
|
|
|
|
|
2026-03-20 02:48:41 +05:30
|
|
|
// Snapshot of original values for cancel/reset
|
|
|
|
|
Map<String, Map<String, bool>> _originalPrefs = {};
|
|
|
|
|
String _originalFrequency = 'instant';
|
|
|
|
|
|
2026-03-20 13:29:28 +05:30
|
|
|
bool get _hasNotificationChanges {
|
|
|
|
|
if (_digestFrequency != _originalFrequency) return true;
|
|
|
|
|
for (final key in _notificationPrefs.keys) {
|
|
|
|
|
final current = _notificationPrefs[key];
|
|
|
|
|
final original = _originalPrefs[key];
|
|
|
|
|
if (current == null || original == null) return true;
|
|
|
|
|
if (current['email'] != original['email'] || current['inApp'] != original['inApp']) return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 08:14:10 +05:30
|
|
|
@override
|
|
|
|
|
void initState() {
|
|
|
|
|
super.initState();
|
|
|
|
|
_loadNotificationPreferences();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _loadNotificationPreferences() async {
|
|
|
|
|
if (_notificationsLoaded) return;
|
|
|
|
|
setState(() => _isLoadingNotifications = true);
|
|
|
|
|
try {
|
|
|
|
|
final role = ref.read(profileProvider).role;
|
|
|
|
|
final repo = ref.read(profileRepositoryProvider);
|
|
|
|
|
final data = await repo.getNotificationPreferences(role);
|
|
|
|
|
final notifications =
|
|
|
|
|
data['notifications'] as Map<String, dynamic>? ?? {};
|
|
|
|
|
setState(() {
|
|
|
|
|
for (final key in ['new_lead_alerts', 'message_updates', 'urgent_requests_tours']) {
|
|
|
|
|
final mapped = key == 'new_lead_alerts'
|
|
|
|
|
? 'newLeadAlerts'
|
|
|
|
|
: key == 'message_updates'
|
|
|
|
|
? 'messageUpdates'
|
|
|
|
|
: 'urgentRequests';
|
|
|
|
|
final vals = notifications[key] as Map<String, dynamic>?;
|
|
|
|
|
if (vals != null) {
|
|
|
|
|
_notificationPrefs[mapped] = {
|
|
|
|
|
'email': vals['email'] as bool? ?? false,
|
|
|
|
|
'inApp': vals['inApp'] as bool? ?? true,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_digestFrequency = data['digestFrequency'] as String? ?? 'instant';
|
|
|
|
|
_isLoadingNotifications = false;
|
|
|
|
|
_notificationsLoaded = true;
|
2026-03-20 02:48:41 +05:30
|
|
|
// Save snapshot for cancel/reset
|
|
|
|
|
_originalPrefs = {
|
|
|
|
|
for (final e in _notificationPrefs.entries)
|
|
|
|
|
e.key: Map<String, bool>.from(e.value),
|
|
|
|
|
};
|
|
|
|
|
_originalFrequency = _digestFrequency;
|
2026-03-09 08:14:10 +05:30
|
|
|
});
|
|
|
|
|
} catch (_) {
|
|
|
|
|
setState(() {
|
|
|
|
|
_isLoadingNotifications = false;
|
|
|
|
|
_notificationsLoaded = true;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
if (_isLoadingNotifications) {
|
|
|
|
|
return const Center(
|
|
|
|
|
child: CircularProgressIndicator(color: AppColors.accentOrange));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ListView(
|
|
|
|
|
padding: const EdgeInsets.fromLTRB(27, 10, 27, 30),
|
|
|
|
|
children: [
|
|
|
|
|
const Text(
|
|
|
|
|
'Notification Preferences',
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
fontFamily: 'Fractul',
|
|
|
|
|
fontSize: 15,
|
|
|
|
|
fontWeight: FontWeight.w700,
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
const Text(
|
|
|
|
|
'Choose what you want to be notified about and how.',
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
fontWeight: FontWeight.w400,
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 20),
|
|
|
|
|
_buildSectionDivider(),
|
|
|
|
|
const SizedBox(height: 19),
|
|
|
|
|
_buildNotificationCategory(
|
|
|
|
|
title: 'New Lead Alerts',
|
|
|
|
|
description: 'Notifications about new leads and inquiries.',
|
|
|
|
|
prefKey: 'newLeadAlerts',
|
|
|
|
|
),
|
|
|
|
|
_buildSectionDivider(),
|
|
|
|
|
const SizedBox(height: 13),
|
|
|
|
|
_buildNotificationCategory(
|
|
|
|
|
title: 'Message Updates',
|
|
|
|
|
description:
|
|
|
|
|
'Notices about new messages from clients or other agents.',
|
|
|
|
|
prefKey: 'messageUpdates',
|
|
|
|
|
),
|
|
|
|
|
_buildSectionDivider(),
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
_buildNotificationCategory(
|
|
|
|
|
title: 'Urgent Requests & Tours',
|
|
|
|
|
description:
|
|
|
|
|
'Immediate notifications for tour requests and time-sensitive items.',
|
|
|
|
|
prefKey: 'urgentRequests',
|
|
|
|
|
),
|
|
|
|
|
_buildSectionDivider(),
|
|
|
|
|
const SizedBox(height: 20),
|
|
|
|
|
const Text(
|
|
|
|
|
'Email Digest Frequency',
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
fontFamily: 'Fractul',
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
fontWeight: FontWeight.w700,
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
const Text(
|
|
|
|
|
'Choose how often you receive non-urgent email summaries',
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
fontWeight: FontWeight.w400,
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
_buildFrequencySelector(),
|
|
|
|
|
const SizedBox(height: 40),
|
|
|
|
|
ProfileActionButtons(
|
|
|
|
|
onSave: _saveNotificationSettings,
|
2026-03-15 22:40:24 +05:30
|
|
|
onCancel: () {
|
2026-03-20 13:29:28 +05:30
|
|
|
if (!_hasNotificationChanges) return;
|
2026-03-20 02:48:41 +05:30
|
|
|
setState(() {
|
|
|
|
|
for (final key in _originalPrefs.keys) {
|
|
|
|
|
_notificationPrefs[key] = Map<String, bool>.from(_originalPrefs[key]!);
|
|
|
|
|
}
|
|
|
|
|
_digestFrequency = _originalFrequency;
|
|
|
|
|
});
|
2026-03-15 22:40:24 +05:30
|
|
|
},
|
2026-03-09 08:14:10 +05:30
|
|
|
isSaving: _isSavingNotifications,
|
2026-03-20 13:29:28 +05:30
|
|
|
hasChanges: _hasNotificationChanges,
|
2026-03-09 08:14:10 +05:30
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _saveNotificationSettings() async {
|
|
|
|
|
setState(() => _isSavingNotifications = true);
|
|
|
|
|
try {
|
|
|
|
|
final role = ref.read(profileProvider).role;
|
|
|
|
|
final repo = ref.read(profileRepositoryProvider);
|
|
|
|
|
await repo.updateNotificationPreferences(role, {
|
|
|
|
|
'notifications': {
|
|
|
|
|
'new_lead_alerts': _notificationPrefs['newLeadAlerts'],
|
|
|
|
|
'message_updates': _notificationPrefs['messageUpdates'],
|
|
|
|
|
'urgent_requests_tours': _notificationPrefs['urgentRequests'],
|
|
|
|
|
},
|
|
|
|
|
'digestFrequency': _digestFrequency,
|
|
|
|
|
});
|
2026-03-20 13:29:28 +05:30
|
|
|
// Update saved snapshot so Cancel won't revert after successful save
|
|
|
|
|
_originalPrefs = {
|
|
|
|
|
for (final e in _notificationPrefs.entries)
|
|
|
|
|
e.key: Map<String, bool>.from(e.value),
|
|
|
|
|
};
|
|
|
|
|
_originalFrequency = _digestFrequency;
|
2026-03-09 08:14:10 +05:30
|
|
|
_showSnackBar('Notification settings saved');
|
|
|
|
|
} catch (e) {
|
|
|
|
|
_showSnackBar('Failed to save: $e', isError: true);
|
|
|
|
|
} finally {
|
|
|
|
|
if (mounted) setState(() => _isSavingNotifications = false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _buildSectionDivider() {
|
|
|
|
|
return Divider(
|
|
|
|
|
color: AppColors.primaryDark.withValues(alpha: 0.15),
|
|
|
|
|
height: 1,
|
|
|
|
|
thickness: 1,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _buildNotificationCategory({
|
|
|
|
|
required String title,
|
|
|
|
|
required String description,
|
|
|
|
|
required String prefKey,
|
|
|
|
|
}) {
|
|
|
|
|
final prefs = _notificationPrefs[prefKey]!;
|
|
|
|
|
return Padding(
|
|
|
|
|
padding: const EdgeInsets.only(bottom: 16),
|
|
|
|
|
child: Row(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Expanded(
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Text(title,
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
fontFamily: 'Fractul',
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
fontWeight: FontWeight.w700,
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
)),
|
|
|
|
|
const SizedBox(height: 4),
|
|
|
|
|
Text(description,
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
fontWeight: FontWeight.w400,
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
)),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: 20),
|
|
|
|
|
Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
_buildCheckboxRow(
|
|
|
|
|
label: 'Email',
|
|
|
|
|
value: prefs['email']!,
|
|
|
|
|
onChanged: (val) {
|
|
|
|
|
setState(() {
|
|
|
|
|
_notificationPrefs[prefKey]!['email'] = val ?? false;
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 6),
|
|
|
|
|
_buildCheckboxRow(
|
|
|
|
|
label: 'In-App',
|
|
|
|
|
value: prefs['inApp']!,
|
|
|
|
|
onChanged: (val) {
|
|
|
|
|
setState(() {
|
|
|
|
|
_notificationPrefs[prefKey]!['inApp'] = val ?? false;
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _buildCheckboxRow({
|
|
|
|
|
required String label,
|
|
|
|
|
required bool value,
|
|
|
|
|
required ValueChanged<bool?> onChanged,
|
|
|
|
|
}) {
|
|
|
|
|
return GestureDetector(
|
|
|
|
|
onTap: () => onChanged(!value),
|
|
|
|
|
behavior: HitTestBehavior.opaque,
|
|
|
|
|
child: Row(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
children: [
|
|
|
|
|
SizedBox(
|
|
|
|
|
width: 18,
|
|
|
|
|
height: 18,
|
|
|
|
|
child: Checkbox(
|
|
|
|
|
value: value,
|
|
|
|
|
onChanged: onChanged,
|
|
|
|
|
activeColor: AppColors.primaryDark,
|
|
|
|
|
checkColor: Colors.white,
|
|
|
|
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
|
|
|
visualDensity: VisualDensity.compact,
|
|
|
|
|
shape: RoundedRectangleBorder(
|
|
|
|
|
borderRadius: BorderRadius.circular(3)),
|
|
|
|
|
side: BorderSide(
|
|
|
|
|
color: AppColors.primaryDark.withValues(alpha: 0.4),
|
|
|
|
|
width: 1,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: 4),
|
|
|
|
|
Text(label,
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
fontWeight: FontWeight.w400,
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
)),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _buildFrequencySelector() {
|
|
|
|
|
const options = ['instant', 'daily', 'weekly', 'off'];
|
|
|
|
|
const labels = ['Instant', 'Daily', 'Weekly', 'Off'];
|
|
|
|
|
|
|
|
|
|
return Container(
|
|
|
|
|
height: 32,
|
|
|
|
|
width: 278,
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
borderRadius: BorderRadius.circular(7),
|
|
|
|
|
border: Border.all(
|
|
|
|
|
color: AppColors.primaryDark.withValues(alpha: 0.15),
|
|
|
|
|
width: 1,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
child: ClipRRect(
|
|
|
|
|
borderRadius: BorderRadius.circular(6),
|
|
|
|
|
child: Row(
|
|
|
|
|
children: List.generate(options.length * 2 - 1, (index) {
|
|
|
|
|
if (index.isOdd) {
|
|
|
|
|
return Container(
|
|
|
|
|
width: 1,
|
|
|
|
|
height: 32,
|
|
|
|
|
color: AppColors.primaryDark.withValues(alpha: 0.15),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
final i = index ~/ 2;
|
|
|
|
|
final isSelected = _digestFrequency == options[i];
|
|
|
|
|
return Expanded(
|
|
|
|
|
child: GestureDetector(
|
|
|
|
|
onTap: () => setState(() => _digestFrequency = options[i]),
|
|
|
|
|
child: Center(
|
|
|
|
|
child: isSelected
|
|
|
|
|
? Container(
|
2026-03-14 17:25:33 +05:30
|
|
|
height: 24,
|
|
|
|
|
margin: const EdgeInsets.symmetric(horizontal: 3),
|
2026-03-09 08:14:10 +05:30
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: AppColors.accentOrange,
|
|
|
|
|
borderRadius: BorderRadius.circular(15),
|
|
|
|
|
),
|
|
|
|
|
alignment: Alignment.center,
|
|
|
|
|
child: Text(labels[i],
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
2026-03-14 17:25:33 +05:30
|
|
|
fontSize: 12,
|
2026-03-09 08:14:10 +05:30
|
|
|
fontWeight: FontWeight.w400,
|
|
|
|
|
color: AppColors.primaryDark,
|
2026-03-14 17:25:33 +05:30
|
|
|
height: 1.0,
|
2026-03-09 08:14:10 +05:30
|
|
|
)),
|
|
|
|
|
)
|
|
|
|
|
: Text(labels[i],
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
2026-03-14 17:25:33 +05:30
|
|
|
fontSize: 12,
|
2026-03-09 08:14:10 +05:30
|
|
|
fontWeight: FontWeight.w400,
|
|
|
|
|
color: AppColors.primaryDark,
|
2026-03-14 17:25:33 +05:30
|
|
|
height: 1.0,
|
2026-03-09 08:14:10 +05:30
|
|
|
)),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _showSnackBar(String message, {bool isError = false}) {
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
SnackBar(
|
|
|
|
|
content: Text(message),
|
|
|
|
|
backgroundColor: isError ? Colors.red : const Color(0xFF638559),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|