1070 lines
34 KiB
Dart
1070 lines
34 KiB
Dart
|
|
import 'package:flutter/material.dart';
|
||
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
|
|
import 'package:flutter_svg/flutter_svg.dart';
|
||
|
|
import 'package:go_router/go_router.dart';
|
||
|
|
import 'package:real_estate_mobile/core/constants/app_colors.dart';
|
||
|
|
import 'package:real_estate_mobile/features/auth/presentation/providers/auth_provider.dart';
|
||
|
|
import 'package:real_estate_mobile/features/home/presentation/widgets/home_header.dart';
|
||
|
|
import 'package:real_estate_mobile/features/profile/presentation/providers/profile_provider.dart';
|
||
|
|
|
||
|
|
class ProfileSettingsScreen extends ConsumerStatefulWidget {
|
||
|
|
const ProfileSettingsScreen({super.key});
|
||
|
|
|
||
|
|
@override
|
||
|
|
ConsumerState<ProfileSettingsScreen> createState() =>
|
||
|
|
_ProfileSettingsScreenState();
|
||
|
|
}
|
||
|
|
|
||
|
|
class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
|
||
|
|
int _selectedTab = 0;
|
||
|
|
|
||
|
|
// Profile form controllers
|
||
|
|
late TextEditingController _fullNameController;
|
||
|
|
late TextEditingController _titleController;
|
||
|
|
late TextEditingController _emailController;
|
||
|
|
late TextEditingController _phoneController;
|
||
|
|
late TextEditingController _locationController;
|
||
|
|
|
||
|
|
// Notification preferences
|
||
|
|
final Map<String, Map<String, bool>> _notificationPrefs = {
|
||
|
|
'newLeadAlerts': {'email': true, 'inApp': true},
|
||
|
|
'messageUpdates': {'email': true, 'inApp': true},
|
||
|
|
'urgentRequests': {'email': true, 'inApp': true},
|
||
|
|
};
|
||
|
|
String _digestFrequency = 'instant';
|
||
|
|
|
||
|
|
bool _controllersInitialized = false;
|
||
|
|
|
||
|
|
@override
|
||
|
|
void initState() {
|
||
|
|
super.initState();
|
||
|
|
_fullNameController = TextEditingController();
|
||
|
|
_titleController = TextEditingController();
|
||
|
|
_emailController = TextEditingController();
|
||
|
|
_phoneController = TextEditingController();
|
||
|
|
_locationController = TextEditingController();
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
void dispose() {
|
||
|
|
_fullNameController.dispose();
|
||
|
|
_titleController.dispose();
|
||
|
|
_emailController.dispose();
|
||
|
|
_phoneController.dispose();
|
||
|
|
_locationController.dispose();
|
||
|
|
super.dispose();
|
||
|
|
}
|
||
|
|
|
||
|
|
void _initControllersFromProfile(ProfileState profileState) {
|
||
|
|
if (_controllersInitialized) return;
|
||
|
|
_controllersInitialized = true;
|
||
|
|
|
||
|
|
final profile = profileState.profile;
|
||
|
|
final isAgent = profileState.isAgent;
|
||
|
|
|
||
|
|
final firstName = profile['firstName'] as String? ?? '';
|
||
|
|
final lastName = profile['lastName'] as String? ?? '';
|
||
|
|
_fullNameController.text = '$firstName $lastName'.trim();
|
||
|
|
_emailController.text = profile['email'] as String? ??
|
||
|
|
(profile['user'] is Map
|
||
|
|
? (profile['user'] as Map)['email'] as String? ?? ''
|
||
|
|
: '');
|
||
|
|
_phoneController.text = profile['phone'] as String? ?? '';
|
||
|
|
|
||
|
|
if (isAgent) {
|
||
|
|
// Agent: headline from agentType.name or bio
|
||
|
|
final agentType = profile['agentType'] as Map<String, dynamic>?;
|
||
|
|
_titleController.text =
|
||
|
|
agentType?['name'] as String? ?? profile['bio'] as String? ?? '';
|
||
|
|
// Agent: serviceAreas for location
|
||
|
|
final serviceAreas = profile['serviceAreas'];
|
||
|
|
if (serviceAreas is List && serviceAreas.isNotEmpty) {
|
||
|
|
_locationController.text = serviceAreas.first.toString();
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
// User: headline
|
||
|
|
_titleController.text = profile['headline'] as String? ?? '';
|
||
|
|
// User: city/state/country
|
||
|
|
final city = profile['city'] as String? ?? '';
|
||
|
|
final stateName = profile['state'] as String? ?? '';
|
||
|
|
final country = profile['country'] as String? ?? '';
|
||
|
|
final parts = [city, stateName, country].where((s) => s.isNotEmpty);
|
||
|
|
_locationController.text = parts.join(', ');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
final profileState = ref.watch(profileProvider);
|
||
|
|
|
||
|
|
if (!profileState.isLoading && profileState.profile.isNotEmpty) {
|
||
|
|
_initControllersFromProfile(profileState);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Show success/error snackbars
|
||
|
|
ref.listen<ProfileState>(profileProvider, (prev, next) {
|
||
|
|
if (next.successMessage != null && prev?.successMessage == null) {
|
||
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
||
|
|
SnackBar(
|
||
|
|
content: Text(next.successMessage!),
|
||
|
|
backgroundColor: const Color(0xFF638559),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if (next.errorMessage != null && prev?.errorMessage == null) {
|
||
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
||
|
|
SnackBar(
|
||
|
|
content: Text(next.errorMessage!),
|
||
|
|
backgroundColor: Colors.red,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
return Scaffold(
|
||
|
|
backgroundColor: Colors.white,
|
||
|
|
body: Column(
|
||
|
|
children: [
|
||
|
|
SafeArea(
|
||
|
|
bottom: false,
|
||
|
|
child: const HomeHeader(),
|
||
|
|
),
|
||
|
|
Expanded(
|
||
|
|
child: profileState.isLoading
|
||
|
|
? const Center(
|
||
|
|
child: CircularProgressIndicator(
|
||
|
|
color: AppColors.accentOrange,
|
||
|
|
),
|
||
|
|
)
|
||
|
|
: Column(
|
||
|
|
children: [
|
||
|
|
const SizedBox(height: 16),
|
||
|
|
_buildTabBar(),
|
||
|
|
_buildProgressBar(),
|
||
|
|
Expanded(
|
||
|
|
child: _selectedTab == 0
|
||
|
|
? _buildProfileSettingsTab(profileState)
|
||
|
|
: _selectedTab == 1
|
||
|
|
? _buildNotificationsTab()
|
||
|
|
: _buildPrivacyTab(),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
bottomNavigationBar: _buildBottomNavBar(),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Tab Bar ──
|
||
|
|
|
||
|
|
Widget _buildTabBar() {
|
||
|
|
return SizedBox(
|
||
|
|
height: 46,
|
||
|
|
child: ListView(
|
||
|
|
scrollDirection: Axis.horizontal,
|
||
|
|
padding: const EdgeInsets.symmetric(horizontal: 7),
|
||
|
|
children: [
|
||
|
|
_buildTab(0, 'Profile Settings', Icons.person),
|
||
|
|
const SizedBox(width: 6),
|
||
|
|
_buildTab(1, 'Notifications', Icons.notifications),
|
||
|
|
const SizedBox(width: 6),
|
||
|
|
_buildTab(2, 'Privacy', Icons.person),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildTab(int index, String label, IconData icon) {
|
||
|
|
final isActive = _selectedTab == index;
|
||
|
|
return GestureDetector(
|
||
|
|
onTap: () => setState(() => _selectedTab = index),
|
||
|
|
child: Container(
|
||
|
|
width: 162,
|
||
|
|
height: 46,
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: isActive ? AppColors.accentOrange : Colors.transparent,
|
||
|
|
borderRadius: BorderRadius.circular(15),
|
||
|
|
border: Border.all(
|
||
|
|
color: isActive
|
||
|
|
? AppColors.accentOrange
|
||
|
|
: AppColors.primaryDark.withValues(alpha: 0.2),
|
||
|
|
width: 0.1,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
child: Row(
|
||
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
||
|
|
children: [
|
||
|
|
Icon(icon, size: 24, color: AppColors.primaryDark),
|
||
|
|
const SizedBox(width: 8),
|
||
|
|
Text(
|
||
|
|
label,
|
||
|
|
style: const TextStyle(
|
||
|
|
fontFamily: 'SourceSerif4',
|
||
|
|
fontSize: 14,
|
||
|
|
fontWeight: FontWeight.w400,
|
||
|
|
color: AppColors.primaryDark,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildProgressBar() {
|
||
|
|
final progress = (_selectedTab + 1) / 3;
|
||
|
|
return Padding(
|
||
|
|
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 16),
|
||
|
|
child: Container(
|
||
|
|
height: 5,
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: const Color(0xFFD9D9D9),
|
||
|
|
borderRadius: BorderRadius.circular(15),
|
||
|
|
),
|
||
|
|
child: Align(
|
||
|
|
alignment: Alignment.centerLeft,
|
||
|
|
child: FractionallySizedBox(
|
||
|
|
widthFactor: progress,
|
||
|
|
child: Container(
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: AppColors.primaryDark.withValues(alpha: 0.75),
|
||
|
|
borderRadius: BorderRadius.circular(15),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Profile Settings Tab ──
|
||
|
|
|
||
|
|
Widget _buildProfileSettingsTab(ProfileState profileState) {
|
||
|
|
final profile = profileState.profile;
|
||
|
|
final isAgent = profileState.isAgent;
|
||
|
|
|
||
|
|
// Get avatar URL — agent avatar might be an S3 key or full URL
|
||
|
|
String? avatarUrl = profile['avatar'] as String?;
|
||
|
|
if (avatarUrl == null || avatarUrl.isEmpty) {
|
||
|
|
// Try nested user object (agent profile structure)
|
||
|
|
final user = profile['user'] as Map<String, dynamic>?;
|
||
|
|
avatarUrl = user?['avatar'] as String?;
|
||
|
|
}
|
||
|
|
|
||
|
|
final descriptionText = isAgent
|
||
|
|
? 'This information will be displayed on your public agent page.'
|
||
|
|
: 'This information will be displayed on your profile.';
|
||
|
|
|
||
|
|
return ListView(
|
||
|
|
padding: const EdgeInsets.fromLTRB(31, 10, 31, 30),
|
||
|
|
children: [
|
||
|
|
// Section title
|
||
|
|
const Text(
|
||
|
|
'Public Profile',
|
||
|
|
style: TextStyle(
|
||
|
|
fontFamily: 'Fractul',
|
||
|
|
fontSize: 15,
|
||
|
|
fontWeight: FontWeight.w700,
|
||
|
|
color: AppColors.primaryDark,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(height: 8),
|
||
|
|
Text(
|
||
|
|
descriptionText,
|
||
|
|
style: const TextStyle(
|
||
|
|
fontFamily: 'SourceSerif4',
|
||
|
|
fontSize: 14,
|
||
|
|
fontWeight: FontWeight.w400,
|
||
|
|
color: AppColors.primaryDark,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(height: 39),
|
||
|
|
|
||
|
|
// Avatar
|
||
|
|
Center(
|
||
|
|
child: Column(
|
||
|
|
children: [
|
||
|
|
Container(
|
||
|
|
width: 71,
|
||
|
|
height: 69,
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
shape: BoxShape.circle,
|
||
|
|
border: Border.all(
|
||
|
|
color: AppColors.accentOrange,
|
||
|
|
width: 2,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
child: ClipOval(
|
||
|
|
child: avatarUrl != null && avatarUrl.isNotEmpty
|
||
|
|
? Image.network(
|
||
|
|
avatarUrl,
|
||
|
|
fit: BoxFit.cover,
|
||
|
|
errorBuilder: (_, __, ___) => const Icon(
|
||
|
|
Icons.person,
|
||
|
|
size: 36,
|
||
|
|
color: AppColors.primaryDark,
|
||
|
|
),
|
||
|
|
)
|
||
|
|
: const Icon(
|
||
|
|
Icons.person,
|
||
|
|
size: 36,
|
||
|
|
color: AppColors.primaryDark,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(height: 10),
|
||
|
|
Row(
|
||
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
||
|
|
children: [
|
||
|
|
GestureDetector(
|
||
|
|
onTap: _pickAvatar,
|
||
|
|
child: Container(
|
||
|
|
width: 79,
|
||
|
|
height: 24,
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: AppColors.accentOrange,
|
||
|
|
borderRadius: BorderRadius.circular(7),
|
||
|
|
),
|
||
|
|
alignment: Alignment.center,
|
||
|
|
child: const Text(
|
||
|
|
'Upload Now',
|
||
|
|
style: TextStyle(
|
||
|
|
fontFamily: 'SourceSerif4',
|
||
|
|
fontSize: 11,
|
||
|
|
fontWeight: FontWeight.w400,
|
||
|
|
color: AppColors.primaryDark,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(width: 6),
|
||
|
|
GestureDetector(
|
||
|
|
onTap: () {
|
||
|
|
ref
|
||
|
|
.read(profileProvider.notifier)
|
||
|
|
.updateProfile({'avatar': ''});
|
||
|
|
},
|
||
|
|
child: Container(
|
||
|
|
width: 79,
|
||
|
|
height: 24,
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
borderRadius: BorderRadius.circular(7),
|
||
|
|
border: Border.all(
|
||
|
|
color: AppColors.primaryDark,
|
||
|
|
width: 0.1,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
alignment: Alignment.center,
|
||
|
|
child: const Text(
|
||
|
|
'Delete',
|
||
|
|
style: TextStyle(
|
||
|
|
fontFamily: 'SourceSerif4',
|
||
|
|
fontSize: 11,
|
||
|
|
fontWeight: FontWeight.w400,
|
||
|
|
color: AppColors.primaryDark,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(height: 49),
|
||
|
|
|
||
|
|
// Form fields
|
||
|
|
_buildFormField('Full Name', _fullNameController),
|
||
|
|
const SizedBox(height: 24),
|
||
|
|
_buildFormField(
|
||
|
|
isAgent ? 'Career / Agent Type' : 'Professional Title',
|
||
|
|
_titleController,
|
||
|
|
enabled: !isAgent, // Agent type is read-only
|
||
|
|
),
|
||
|
|
const SizedBox(height: 24),
|
||
|
|
_buildFormField('Email Address', _emailController, enabled: false),
|
||
|
|
const SizedBox(height: 24),
|
||
|
|
_buildFormField('Phone Number', _phoneController,
|
||
|
|
keyboardType: TextInputType.phone),
|
||
|
|
const SizedBox(height: 24),
|
||
|
|
_buildFormField(
|
||
|
|
isAgent ? 'Service Areas' : 'Location',
|
||
|
|
_locationController,
|
||
|
|
prefixIcon: Icons.location_on,
|
||
|
|
),
|
||
|
|
const SizedBox(height: 30),
|
||
|
|
|
||
|
|
// Action buttons
|
||
|
|
_buildActionButtons(onSave: _saveProfileSettings),
|
||
|
|
],
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildFormField(
|
||
|
|
String label,
|
||
|
|
TextEditingController controller, {
|
||
|
|
bool enabled = true,
|
||
|
|
TextInputType? keyboardType,
|
||
|
|
IconData? prefixIcon,
|
||
|
|
}) {
|
||
|
|
final borderSide = BorderSide(
|
||
|
|
color: AppColors.primaryDark.withValues(alpha: 0.1),
|
||
|
|
width: 1,
|
||
|
|
);
|
||
|
|
final border = OutlineInputBorder(
|
||
|
|
borderRadius: BorderRadius.circular(7),
|
||
|
|
borderSide: borderSide,
|
||
|
|
);
|
||
|
|
|
||
|
|
return Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
Text(
|
||
|
|
label,
|
||
|
|
style: const TextStyle(
|
||
|
|
fontFamily: 'Fractul',
|
||
|
|
fontSize: 14,
|
||
|
|
fontWeight: FontWeight.w400,
|
||
|
|
color: AppColors.primaryDark,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(height: 14),
|
||
|
|
SizedBox(
|
||
|
|
height: 38,
|
||
|
|
child: TextField(
|
||
|
|
controller: controller,
|
||
|
|
enabled: enabled,
|
||
|
|
keyboardType: keyboardType,
|
||
|
|
style: const TextStyle(
|
||
|
|
fontFamily: 'SourceSerif4',
|
||
|
|
fontSize: 14,
|
||
|
|
fontWeight: FontWeight.w400,
|
||
|
|
color: AppColors.primaryDark,
|
||
|
|
),
|
||
|
|
decoration: InputDecoration(
|
||
|
|
contentPadding: const EdgeInsets.symmetric(
|
||
|
|
horizontal: 26,
|
||
|
|
vertical: 8,
|
||
|
|
),
|
||
|
|
border: border,
|
||
|
|
enabledBorder: border,
|
||
|
|
focusedBorder: border,
|
||
|
|
disabledBorder: border,
|
||
|
|
prefixIcon: prefixIcon != null
|
||
|
|
? Icon(prefixIcon, size: 18, color: AppColors.accentOrange)
|
||
|
|
: null,
|
||
|
|
prefixIconConstraints: prefixIcon != null
|
||
|
|
? const BoxConstraints(minWidth: 40)
|
||
|
|
: null,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
void _pickAvatar() {
|
||
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
||
|
|
const SnackBar(content: Text('Avatar upload coming soon')),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
void _saveProfileSettings() {
|
||
|
|
final profileState = ref.read(profileProvider);
|
||
|
|
final isAgent = profileState.isAgent;
|
||
|
|
|
||
|
|
final nameParts = _fullNameController.text.trim().split(RegExp(r'\s+'));
|
||
|
|
final firstName = nameParts.first;
|
||
|
|
final lastName =
|
||
|
|
nameParts.length > 1 ? nameParts.sublist(1).join(' ') : '';
|
||
|
|
|
||
|
|
final data = <String, dynamic>{
|
||
|
|
'firstName': firstName,
|
||
|
|
'lastName': lastName,
|
||
|
|
'phone': _phoneController.text.trim(),
|
||
|
|
};
|
||
|
|
|
||
|
|
if (isAgent) {
|
||
|
|
// Agent: serviceAreas as array
|
||
|
|
final location = _locationController.text.trim();
|
||
|
|
if (location.isNotEmpty) {
|
||
|
|
data['serviceAreas'] = [location];
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
// User: city/state/country + headline
|
||
|
|
data['headline'] = _titleController.text.trim();
|
||
|
|
final locationParts = _locationController.text
|
||
|
|
.split(',')
|
||
|
|
.map((s) => s.trim())
|
||
|
|
.where((s) => s.isNotEmpty)
|
||
|
|
.toList();
|
||
|
|
if (locationParts.isNotEmpty) data['city'] = locationParts[0];
|
||
|
|
if (locationParts.length > 1) data['state'] = locationParts[1];
|
||
|
|
if (locationParts.length > 2) data['country'] = locationParts[2];
|
||
|
|
}
|
||
|
|
|
||
|
|
ref.read(profileProvider.notifier).updateProfile(data);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Notifications Tab ──
|
||
|
|
|
||
|
|
Widget _buildNotificationsTab() {
|
||
|
|
return ListView(
|
||
|
|
padding: const EdgeInsets.fromLTRB(27, 10, 27, 30),
|
||
|
|
children: [
|
||
|
|
// Title — Figma: Fractul Bold 15px, y=263
|
||
|
|
const Text(
|
||
|
|
'Notification Preferences',
|
||
|
|
style: TextStyle(
|
||
|
|
fontFamily: 'Fractul',
|
||
|
|
fontSize: 15,
|
||
|
|
fontWeight: FontWeight.w700,
|
||
|
|
color: AppColors.primaryDark,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(height: 8),
|
||
|
|
// Description — Figma: SourceSerif4 Regular 14px, y=291
|
||
|
|
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),
|
||
|
|
|
||
|
|
// Divider — Figma: y=322
|
||
|
|
_buildSectionDivider(),
|
||
|
|
const SizedBox(height: 19),
|
||
|
|
|
||
|
|
// New Lead Alerts — Figma: title y=341
|
||
|
|
_buildNotificationCategory(
|
||
|
|
title: 'New Lead Alerts',
|
||
|
|
description: 'Choose what you want to be notified about and how.',
|
||
|
|
prefKey: 'newLeadAlerts',
|
||
|
|
),
|
||
|
|
|
||
|
|
// Divider — Figma: y=416
|
||
|
|
_buildSectionDivider(),
|
||
|
|
const SizedBox(height: 13),
|
||
|
|
|
||
|
|
// Message Updates — Figma: title y=429
|
||
|
|
_buildNotificationCategory(
|
||
|
|
title: 'Message Updates',
|
||
|
|
description:
|
||
|
|
'Notices about new messages from clients or other agents.',
|
||
|
|
prefKey: 'messageUpdates',
|
||
|
|
),
|
||
|
|
|
||
|
|
// Divider — Figma: y=511
|
||
|
|
_buildSectionDivider(),
|
||
|
|
const SizedBox(height: 16),
|
||
|
|
|
||
|
|
// Urgent Requests & Tours — Figma: title y=527
|
||
|
|
_buildNotificationCategory(
|
||
|
|
title: 'Urgent Requests & Tours',
|
||
|
|
description:
|
||
|
|
'Immediate notifications for tour requests and time-sensitive items.',
|
||
|
|
prefKey: 'urgentRequests',
|
||
|
|
),
|
||
|
|
|
||
|
|
// Divider — Figma: y=621
|
||
|
|
_buildSectionDivider(),
|
||
|
|
const SizedBox(height: 20),
|
||
|
|
|
||
|
|
// Email Digest Frequency — Figma: Fractul Bold 14px, y=641
|
||
|
|
const Text(
|
||
|
|
'Email Digest Frequency',
|
||
|
|
style: TextStyle(
|
||
|
|
fontFamily: 'Fractul',
|
||
|
|
fontSize: 14,
|
||
|
|
fontWeight: FontWeight.w700,
|
||
|
|
color: AppColors.primaryDark,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(height: 8),
|
||
|
|
// Description — Figma: y=667
|
||
|
|
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),
|
||
|
|
|
||
|
|
// Frequency selector — Figma: y=731
|
||
|
|
_buildFrequencySelector(),
|
||
|
|
const SizedBox(height: 40),
|
||
|
|
|
||
|
|
// Action buttons — Figma: y=879, rounded-[15px]
|
||
|
|
_buildNotificationActionButtons(),
|
||
|
|
],
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
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: [
|
||
|
|
// Left: title + description (max width ~220 from Figma)
|
||
|
|
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),
|
||
|
|
// Right: checkbox labels — Figma: x=321, Email/In-App stacked ~26px apart
|
||
|
|
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,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Figma: h=32, w=278, rounded-[7px], with vertical dividers and inset orange pill
|
||
|
|
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) {
|
||
|
|
// Odd indices = vertical dividers
|
||
|
|
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(
|
||
|
|
height: 22,
|
||
|
|
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: AppColors.accentOrange,
|
||
|
|
borderRadius: BorderRadius.circular(15),
|
||
|
|
),
|
||
|
|
alignment: Alignment.center,
|
||
|
|
child: Text(
|
||
|
|
labels[i],
|
||
|
|
style: const TextStyle(
|
||
|
|
fontFamily: 'SourceSerif4',
|
||
|
|
fontSize: 14,
|
||
|
|
fontWeight: FontWeight.w400,
|
||
|
|
color: AppColors.primaryDark,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
)
|
||
|
|
: Text(
|
||
|
|
labels[i],
|
||
|
|
style: const TextStyle(
|
||
|
|
fontFamily: 'SourceSerif4',
|
||
|
|
fontSize: 14,
|
||
|
|
fontWeight: FontWeight.w400,
|
||
|
|
color: AppColors.primaryDark,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Notification tab buttons — Figma: rounded-[15px] (different from profile tab)
|
||
|
|
Widget _buildNotificationActionButtons() {
|
||
|
|
final isSaving = ref.watch(profileProvider).isSaving;
|
||
|
|
|
||
|
|
return Container(
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
borderRadius: BorderRadius.circular(15),
|
||
|
|
border: Border.all(
|
||
|
|
color: AppColors.primaryDark.withValues(alpha: 0.15),
|
||
|
|
width: 1,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20),
|
||
|
|
child: Row(
|
||
|
|
children: [
|
||
|
|
Expanded(
|
||
|
|
child: GestureDetector(
|
||
|
|
onTap: () => context.pop(),
|
||
|
|
child: Container(
|
||
|
|
height: 31,
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
borderRadius: BorderRadius.circular(15),
|
||
|
|
border: Border.all(
|
||
|
|
color: AppColors.primaryDark.withValues(alpha: 0.15),
|
||
|
|
width: 1,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
alignment: Alignment.center,
|
||
|
|
child: const Text(
|
||
|
|
'Cancel',
|
||
|
|
style: TextStyle(
|
||
|
|
fontFamily: 'SourceSerif4',
|
||
|
|
fontSize: 14,
|
||
|
|
fontWeight: FontWeight.w400,
|
||
|
|
color: AppColors.primaryDark,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(width: 10),
|
||
|
|
Expanded(
|
||
|
|
child: GestureDetector(
|
||
|
|
onTap: isSaving ? null : _saveNotificationSettings,
|
||
|
|
child: Container(
|
||
|
|
height: 31,
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: AppColors.accentOrange,
|
||
|
|
borderRadius: BorderRadius.circular(15),
|
||
|
|
),
|
||
|
|
alignment: Alignment.center,
|
||
|
|
child: isSaving
|
||
|
|
? const SizedBox(
|
||
|
|
width: 16,
|
||
|
|
height: 16,
|
||
|
|
child: CircularProgressIndicator(
|
||
|
|
strokeWidth: 2,
|
||
|
|
color: AppColors.primaryDark,
|
||
|
|
),
|
||
|
|
)
|
||
|
|
: const Text(
|
||
|
|
'Save Changes',
|
||
|
|
style: TextStyle(
|
||
|
|
fontFamily: 'SourceSerif4',
|
||
|
|
fontSize: 14,
|
||
|
|
fontWeight: FontWeight.w400,
|
||
|
|
color: AppColors.primaryDark,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
void _saveNotificationSettings() {
|
||
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
||
|
|
const SnackBar(
|
||
|
|
content: Text('Notification settings saved'),
|
||
|
|
backgroundColor: Color(0xFF638559),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Privacy Tab ──
|
||
|
|
|
||
|
|
Widget _buildPrivacyTab() {
|
||
|
|
return const Center(
|
||
|
|
child: Padding(
|
||
|
|
padding: EdgeInsets.all(24),
|
||
|
|
child: Text(
|
||
|
|
'Privacy settings coming soon',
|
||
|
|
style: TextStyle(
|
||
|
|
fontFamily: 'SourceSerif4',
|
||
|
|
fontSize: 16,
|
||
|
|
fontWeight: FontWeight.w400,
|
||
|
|
color: AppColors.primaryDark,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Shared Action Buttons ──
|
||
|
|
|
||
|
|
Widget _buildActionButtons({required VoidCallback onSave}) {
|
||
|
|
final isSaving = ref.watch(profileProvider).isSaving;
|
||
|
|
|
||
|
|
return Container(
|
||
|
|
height: 71,
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
borderRadius: BorderRadius.circular(15),
|
||
|
|
border: Border.all(
|
||
|
|
color: AppColors.primaryDark.withValues(alpha: 0.1),
|
||
|
|
width: 1,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
child: Row(
|
||
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
||
|
|
children: [
|
||
|
|
GestureDetector(
|
||
|
|
onTap: () => context.pop(),
|
||
|
|
child: Container(
|
||
|
|
width: 143,
|
||
|
|
height: 31,
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
borderRadius: BorderRadius.circular(7),
|
||
|
|
border: Border.all(
|
||
|
|
color: AppColors.primaryDark.withValues(alpha: 0.1),
|
||
|
|
width: 1,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
alignment: Alignment.center,
|
||
|
|
child: const Text(
|
||
|
|
'Cancel',
|
||
|
|
style: TextStyle(
|
||
|
|
fontFamily: 'Fractul',
|
||
|
|
fontSize: 14,
|
||
|
|
fontWeight: FontWeight.w400,
|
||
|
|
color: AppColors.primaryDark,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(width: 10),
|
||
|
|
GestureDetector(
|
||
|
|
onTap: isSaving ? null : onSave,
|
||
|
|
child: Container(
|
||
|
|
width: 143,
|
||
|
|
height: 31,
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: AppColors.accentOrange,
|
||
|
|
borderRadius: BorderRadius.circular(7),
|
||
|
|
),
|
||
|
|
alignment: Alignment.center,
|
||
|
|
child: isSaving
|
||
|
|
? const SizedBox(
|
||
|
|
width: 16,
|
||
|
|
height: 16,
|
||
|
|
child: CircularProgressIndicator(
|
||
|
|
strokeWidth: 2,
|
||
|
|
color: AppColors.primaryDark,
|
||
|
|
),
|
||
|
|
)
|
||
|
|
: const Text(
|
||
|
|
'Save Changes',
|
||
|
|
style: TextStyle(
|
||
|
|
fontFamily: 'Fractul',
|
||
|
|
fontSize: 14,
|
||
|
|
fontWeight: FontWeight.w400,
|
||
|
|
color: AppColors.primaryDark,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Bottom Navigation Bar ──
|
||
|
|
|
||
|
|
Widget _buildBottomNavBar() {
|
||
|
|
return Container(
|
||
|
|
decoration: const BoxDecoration(
|
||
|
|
color: Colors.white,
|
||
|
|
border: Border(
|
||
|
|
top: BorderSide(color: Color(0xFFE8E8E8), width: 1),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
child: SafeArea(
|
||
|
|
top: false,
|
||
|
|
child: Padding(
|
||
|
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||
|
|
child: Row(
|
||
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
|
|
children: [
|
||
|
|
_buildNavItem(
|
||
|
|
svgPath: 'assets/icons/nav_home_icon.svg',
|
||
|
|
fallbackIcon: Icons.home,
|
||
|
|
isSelected: false,
|
||
|
|
onTap: () => context.go('/home'),
|
||
|
|
),
|
||
|
|
_buildNavItem(
|
||
|
|
svgPath: 'assets/icons/nav_list_icon.svg',
|
||
|
|
fallbackIcon: Icons.list,
|
||
|
|
isSelected: false,
|
||
|
|
onTap: () => context.push('/agents/search'),
|
||
|
|
),
|
||
|
|
_buildNavItem(
|
||
|
|
svgPath: 'assets/icons/nav_messages_icon.svg',
|
||
|
|
fallbackIcon: Icons.chat_bubble,
|
||
|
|
isSelected: false,
|
||
|
|
onTap: () {
|
||
|
|
final authState = ref.read(authProvider);
|
||
|
|
if (authState.status != AuthStatus.authenticated) {
|
||
|
|
context.push('/login');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
context.go('/messages');
|
||
|
|
},
|
||
|
|
),
|
||
|
|
_buildNavItem(
|
||
|
|
svgPath: 'assets/icons/nav_profile_icon.svg',
|
||
|
|
fallbackIcon: Icons.person_outline,
|
||
|
|
isSelected: true,
|
||
|
|
onTap: () {},
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildNavItem({
|
||
|
|
required String svgPath,
|
||
|
|
required IconData fallbackIcon,
|
||
|
|
required bool isSelected,
|
||
|
|
required VoidCallback onTap,
|
||
|
|
}) {
|
||
|
|
return GestureDetector(
|
||
|
|
onTap: onTap,
|
||
|
|
behavior: HitTestBehavior.opaque,
|
||
|
|
child: Padding(
|
||
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||
|
|
child: SvgPicture.asset(
|
||
|
|
svgPath,
|
||
|
|
width: 24,
|
||
|
|
height: 24,
|
||
|
|
colorFilter: isSelected
|
||
|
|
? const ColorFilter.mode(AppColors.primaryDark, BlendMode.srcIn)
|
||
|
|
: const ColorFilter.mode(
|
||
|
|
AppColors.accentOrange, BlendMode.srcIn),
|
||
|
|
placeholderBuilder: (_) => Icon(
|
||
|
|
fallbackIcon,
|
||
|
|
size: 24,
|
||
|
|
color: isSelected ? AppColors.primaryDark : AppColors.accentOrange,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|