feat: Implement notifications feature, enhance profile settings with new sections, and integrate Firebase configurations for multiple environments.
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:real_estate_mobile/core/constants/app_colors.dart';
|
||||
import 'package:real_estate_mobile/core/widgets/s3_image.dart';
|
||||
import 'package:real_estate_mobile/features/agents/data/models/agent_profile.dart';
|
||||
@@ -81,6 +84,118 @@ class _AgentHomeContent extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
|
||||
bool _isUploadingAvatar = false;
|
||||
bool _isTogglingAvailability = false;
|
||||
File? _localAvatarFile;
|
||||
|
||||
Future<void> _pickAndUploadAvatar() async {
|
||||
final picker = ImagePicker();
|
||||
final picked = await picker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
maxWidth: 1200,
|
||||
maxHeight: 1200,
|
||||
imageQuality: 85,
|
||||
);
|
||||
if (picked == null) return;
|
||||
|
||||
final file = File(picked.path);
|
||||
final fileName = picked.name;
|
||||
final ext = fileName.split('.').last.toLowerCase();
|
||||
|
||||
// Validate file type
|
||||
const allowedTypes = ['jpg', 'jpeg', 'png', 'gif'];
|
||||
if (!allowedTypes.contains(ext)) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Only JPG, PNG, and GIF images are allowed')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (2MB max)
|
||||
final fileSize = await file.length();
|
||||
if (fileSize > 2 * 1024 * 1024) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Image must be smaller than 2MB')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final contentType = ext == 'png'
|
||||
? 'image/png'
|
||||
: ext == 'gif'
|
||||
? 'image/gif'
|
||||
: 'image/jpeg';
|
||||
|
||||
setState(() {
|
||||
_localAvatarFile = file;
|
||||
_isUploadingAvatar = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final repo = ProfileRepository();
|
||||
|
||||
// 1. Get presigned upload URL
|
||||
final uploadData = await repo.getAvatarUploadUrl('AGENT', fileName, contentType);
|
||||
final uploadUrl = uploadData['uploadUrl']!;
|
||||
final key = uploadData['key']!;
|
||||
|
||||
// 2. Upload to S3
|
||||
final fileBytes = await file.readAsBytes();
|
||||
await repo.uploadToS3(uploadUrl, fileBytes, contentType);
|
||||
|
||||
// 3. Update profile with new avatar key
|
||||
await repo.updateProfile('AGENT', {'avatar': key});
|
||||
|
||||
// 4. Refresh the agent detail provider
|
||||
ref.invalidate(agentDetailProvider(widget.agentProfileId));
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Photo updated successfully')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to upload photo: ${e.toString()}')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isUploadingAvatar = false;
|
||||
_localAvatarFile = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleAvailability(bool newValue) async {
|
||||
if (_isTogglingAvailability) return;
|
||||
|
||||
setState(() => _isTogglingAvailability = true);
|
||||
try {
|
||||
final repo = ProfileRepository();
|
||||
await repo.updateProfile('AGENT', {'isAvailable': newValue});
|
||||
// Refresh agent detail to reflect new status
|
||||
ref.invalidate(agentDetailProvider(widget.agentProfileId));
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to update availability: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isTogglingAvailability = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(agentDetailProvider(widget.agentProfileId));
|
||||
@@ -161,37 +276,67 @@ class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
|
||||
child: SizedBox(
|
||||
width: coverWidth,
|
||||
height: 346,
|
||||
child: agent.avatarUrl != null && agent.avatarUrl!.isNotEmpty
|
||||
? S3Image(
|
||||
imageUrl: agent.avatarUrl!,
|
||||
child: _localAvatarFile != null
|
||||
? Image.file(
|
||||
_localAvatarFile!,
|
||||
width: coverWidth,
|
||||
height: 346,
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: Container(
|
||||
color: const Color(0xFFC4D9D4),
|
||||
alignment: Alignment.center,
|
||||
child: Icon(Icons.person,
|
||||
size: 80, color: AppColors.primaryDark),
|
||||
),
|
||||
: agent.avatarUrl != null && agent.avatarUrl!.isNotEmpty
|
||||
? S3Image(
|
||||
imageUrl: agent.avatarUrl!,
|
||||
width: coverWidth,
|
||||
height: 346,
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: Container(
|
||||
color: const Color(0xFFC4D9D4),
|
||||
alignment: Alignment.center,
|
||||
child: Icon(Icons.person,
|
||||
size: 80, color: AppColors.primaryDark),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Loading overlay during upload
|
||||
if (_isUploadingAvatar)
|
||||
Positioned.fill(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Container(
|
||||
color: Colors.black.withValues(alpha: 0.4),
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 3,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Orange edit icon top-right (tabler:edit style)
|
||||
Positioned(
|
||||
top: 12,
|
||||
right: 10,
|
||||
child: Container(
|
||||
width: 28,
|
||||
height: 26,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: SvgPicture.asset(
|
||||
'assets/icons/edit_icon.svg',
|
||||
width: 20,
|
||||
height: 20,
|
||||
top: 8,
|
||||
right: 6,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _isUploadingAvatar ? null : _pickAndUploadAvatar,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Container(
|
||||
width: 28,
|
||||
height: 26,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: SvgPicture.asset(
|
||||
'assets/icons/edit_icon.svg',
|
||||
width: 20,
|
||||
height: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -200,89 +345,104 @@ class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Available / Unavailable status ──
|
||||
// ── Available / Unavailable status toggle (matches web design) ──
|
||||
Widget _buildStatusSection(AgentProfile agent) {
|
||||
final isAvailable = agent.isAvailable;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildStatusRow(
|
||||
isAvailable: true,
|
||||
_buildStatusOption(
|
||||
label: 'Available.',
|
||||
buttonLabel: 'Connect',
|
||||
buttonColor: AppColors.primaryDark,
|
||||
buttonTextColor: Colors.white,
|
||||
isActive: isAvailable,
|
||||
dotColor: const Color(0xFF4CAF50),
|
||||
activeColor: const Color(0xFFE8F5E9),
|
||||
activeBorderColor: const Color(0xFF4CAF50),
|
||||
onTap: isAvailable ? null : () => _toggleAvailability(true),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildStatusRow(
|
||||
isAvailable: false,
|
||||
_buildStatusOption(
|
||||
label: 'Unavailable.',
|
||||
buttonLabel: 'Connect',
|
||||
buttonColor: AppColors.accentOrange,
|
||||
buttonTextColor: AppColors.primaryDark,
|
||||
isActive: !isAvailable,
|
||||
dotColor: const Color(0xFF00293D),
|
||||
activeColor: const Color(0xFFE8EDF2),
|
||||
activeBorderColor: const Color(0xFF00293D),
|
||||
onTap: !isAvailable ? null : () => _toggleAvailability(false),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusRow({
|
||||
required bool isAvailable,
|
||||
Widget _buildStatusOption({
|
||||
required String label,
|
||||
required String buttonLabel,
|
||||
required Color buttonColor,
|
||||
required Color buttonTextColor,
|
||||
required bool isActive,
|
||||
required Color dotColor,
|
||||
required Color activeColor,
|
||||
required Color activeBorderColor,
|
||||
VoidCallback? onTap,
|
||||
}) {
|
||||
return Container(
|
||||
height: 55,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
border: Border.all(color: const Color(0xFFE0E0E0), width: 1),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 20),
|
||||
Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isAvailable
|
||||
? const Color(0xFF4CAF50)
|
||||
: const Color(0xFFD0D0D0),
|
||||
),
|
||||
return GestureDetector(
|
||||
onTap: _isTogglingAvailability ? null : onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
height: 50,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? activeColor : Colors.white,
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
border: Border.all(
|
||||
color: isActive ? activeBorderColor : const Color(0xFFE0E0E0),
|
||||
width: isActive ? 1.5 : 1,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isActive ? dotColor : Colors.white,
|
||||
border: Border.all(
|
||||
color: isActive ? dotColor : const Color(0xFFD0D0D0),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Container(
|
||||
height: 55,
|
||||
width: 164,
|
||||
decoration: BoxDecoration(
|
||||
color: buttonColor,
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
buttonLabel,
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: buttonTextColor,
|
||||
fontWeight: isActive ? FontWeight.w700 : FontWeight.w500,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
if (_isTogglingAvailability && !isActive)
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.accentOrange,
|
||||
),
|
||||
)
|
||||
else if (isActive)
|
||||
Text(
|
||||
'Active',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: activeBorderColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.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';
|
||||
|
||||
@@ -97,7 +98,7 @@ class _ContactScreenState extends State<ContactScreen> {
|
||||
controller: _emailController,
|
||||
hintText: '123@gmail.com',
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
prefixIcon: Icons.email,
|
||||
svgIcon: 'assets/icons/email_orange_icon.svg',
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildLabel('Phone Number'),
|
||||
@@ -106,7 +107,7 @@ class _ContactScreenState extends State<ContactScreen> {
|
||||
controller: _phoneController,
|
||||
hintText: '12345678990',
|
||||
keyboardType: TextInputType.phone,
|
||||
prefixIcon: Icons.call,
|
||||
svgIcon: 'assets/icons/phone_orange_icon.svg',
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildLabel('Message'),
|
||||
@@ -135,7 +136,7 @@ class _ContactScreenState extends State<ContactScreen> {
|
||||
required TextEditingController controller,
|
||||
required String hintText,
|
||||
TextInputType? keyboardType,
|
||||
IconData? prefixIcon,
|
||||
String? svgIcon,
|
||||
}) {
|
||||
final borderSide = BorderSide(
|
||||
color: AppColors.primaryDark.withValues(alpha: 0.1),
|
||||
@@ -173,10 +174,17 @@ class _ContactScreenState extends State<ContactScreen> {
|
||||
enabledBorder: border,
|
||||
focusedBorder: border,
|
||||
disabledBorder: border,
|
||||
prefixIcon: prefixIcon != null
|
||||
? Icon(prefixIcon, size: 22, color: AppColors.primaryDark)
|
||||
prefixIcon: svgIcon != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: SvgPicture.asset(
|
||||
svgIcon,
|
||||
width: 22,
|
||||
height: 22,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
prefixIconConstraints: prefixIcon != null
|
||||
prefixIconConstraints: svgIcon != null
|
||||
? const BoxConstraints(minWidth: 48)
|
||||
: null,
|
||||
),
|
||||
@@ -321,14 +329,14 @@ class _ContactScreenState extends State<ContactScreen> {
|
||||
_buildDetailDivider(),
|
||||
// Email Support
|
||||
_buildContactDetailRow(
|
||||
icon: Icons.email_outlined,
|
||||
svgIcon: 'assets/icons/email_orange_icon.svg',
|
||||
label: 'Email Support',
|
||||
value: '123support@gmail.com',
|
||||
),
|
||||
_buildDetailDivider(),
|
||||
// Phone
|
||||
_buildContactDetailRow(
|
||||
icon: Icons.call_outlined,
|
||||
svgIcon: 'assets/icons/phone_orange_icon.svg',
|
||||
label: 'Phone',
|
||||
value: '1234567890',
|
||||
subtitle: 'Mon-Fri 9am-6pm',
|
||||
@@ -336,29 +344,21 @@ class _ContactScreenState extends State<ContactScreen> {
|
||||
_buildDetailDivider(),
|
||||
// Office
|
||||
_buildContactDetailRow(
|
||||
icon: Icons.location_on_outlined,
|
||||
svgIcon: 'assets/icons/location_orange_icon.svg',
|
||||
label: 'Office',
|
||||
value: '123 Market Street\nNew York CA 234737',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Map placeholder
|
||||
// Map image
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 36),
|
||||
child: Container(
|
||||
height: 166,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: AppColors.primaryDark.withValues(alpha: 0.1),
|
||||
width: 1,
|
||||
),
|
||||
color: const Color(0xFFF5F5F5),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Icon(
|
||||
Icons.map_outlined,
|
||||
size: 48,
|
||||
color: AppColors.primaryDark.withValues(alpha: 0.3),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Image.asset(
|
||||
'assets/images/contact_map.jpg',
|
||||
height: 166,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -367,7 +367,7 @@ class _ContactScreenState extends State<ContactScreen> {
|
||||
GestureDetector(
|
||||
onTap: () {},
|
||||
child: Container(
|
||||
width: 120,
|
||||
width: 139,
|
||||
height: 35,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
@@ -406,7 +406,7 @@ class _ContactScreenState extends State<ContactScreen> {
|
||||
}
|
||||
|
||||
Widget _buildContactDetailRow({
|
||||
required IconData icon,
|
||||
required String svgIcon,
|
||||
required String label,
|
||||
required String value,
|
||||
String? subtitle,
|
||||
@@ -416,10 +416,10 @@ class _ContactScreenState extends State<ContactScreen> {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 22,
|
||||
color: AppColors.primaryDark,
|
||||
SvgPicture.asset(
|
||||
svgIcon,
|
||||
width: 20,
|
||||
height: 20,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
@@ -493,19 +493,13 @@ class _ContactScreenState extends State<ContactScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// Illustration placeholder
|
||||
Container(
|
||||
width: 254,
|
||||
height: 254,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: const Color(0xFFFFF5EB),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Icon(
|
||||
Icons.real_estate_agent,
|
||||
size: 80,
|
||||
color: AppColors.accentOrange.withValues(alpha: 0.6),
|
||||
// Agent illustration
|
||||
ClipOval(
|
||||
child: Image.asset(
|
||||
'assets/images/agent_illustration.png',
|
||||
width: 254,
|
||||
height: 254,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
@@ -514,7 +508,7 @@ class _ContactScreenState extends State<ContactScreen> {
|
||||
onTap: () => context.push('/agents/search'),
|
||||
child: Container(
|
||||
height: 46,
|
||||
width: 200,
|
||||
width: 220,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.accentOrange,
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
@@ -533,8 +527,8 @@ class _ContactScreenState extends State<ContactScreen> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(
|
||||
Icons.arrow_forward,
|
||||
size: 18,
|
||||
Icons.arrow_forward_rounded,
|
||||
size: 16,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -3,12 +3,17 @@ 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/auth/presentation/providers/auth_provider.dart';
|
||||
import 'package:real_estate_mobile/features/notifications/presentation/providers/notification_provider.dart';
|
||||
|
||||
class HomeHeader extends StatelessWidget {
|
||||
class HomeHeader extends ConsumerWidget {
|
||||
const HomeHeader({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final authState = ref.watch(authProvider);
|
||||
final isAuthenticated = authState.status == AuthStatus.authenticated;
|
||||
final unreadCount = isAuthenticated ? ref.watch(unreadCountProvider) : 0;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 23, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
@@ -27,13 +32,54 @@ class HomeHeader extends StatelessWidget {
|
||||
// Right side: notification + hamburger menu
|
||||
Row(
|
||||
children: [
|
||||
// Notification bell
|
||||
// Notification bell with badge
|
||||
GestureDetector(
|
||||
onTap: () {},
|
||||
child: const Icon(
|
||||
Icons.notifications_outlined,
|
||||
color: AppColors.primaryDark,
|
||||
size: 26,
|
||||
onTap: () {
|
||||
if (!isAuthenticated) {
|
||||
context.push('/login');
|
||||
return;
|
||||
}
|
||||
context.go('/notifications');
|
||||
},
|
||||
child: SizedBox(
|
||||
width: 30,
|
||||
height: 30,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
const Center(
|
||||
child: Icon(
|
||||
Icons.notifications_outlined,
|
||||
color: AppColors.primaryDark,
|
||||
size: 26,
|
||||
),
|
||||
),
|
||||
if (unreadCount > 0)
|
||||
Positioned(
|
||||
top: -2,
|
||||
right: -4,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4, vertical: 1),
|
||||
constraints: const BoxConstraints(minWidth: 18),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
child: Text(
|
||||
unreadCount > 99 ? '99+' : '$unreadCount',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
class AppNotification {
|
||||
final String id;
|
||||
final String userId;
|
||||
final String type; // 'connection' | 'message' | 'system' | 'update' | 'request'
|
||||
final String title;
|
||||
final String description;
|
||||
final bool read;
|
||||
final String? actionUrl;
|
||||
final Map<String, String>? data;
|
||||
final DateTime createdAt;
|
||||
|
||||
const AppNotification({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
required this.type,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.read,
|
||||
this.actionUrl,
|
||||
this.data,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory AppNotification.fromJson(Map<String, dynamic> json) {
|
||||
return AppNotification(
|
||||
id: json['id'] as String,
|
||||
userId: json['userId'] as String? ?? '',
|
||||
type: json['type'] as String? ?? 'system',
|
||||
title: json['title'] as String? ?? '',
|
||||
description: json['description'] as String? ?? '',
|
||||
read: json['read'] as bool? ?? false,
|
||||
actionUrl: json['actionUrl'] as String?,
|
||||
data: json['data'] != null
|
||||
? Map<String, String>.from(json['data'] as Map)
|
||||
: null,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
AppNotification copyWith({bool? read}) {
|
||||
return AppNotification(
|
||||
id: id,
|
||||
userId: userId,
|
||||
type: type,
|
||||
title: title,
|
||||
description: description,
|
||||
read: read ?? this.read,
|
||||
actionUrl: actionUrl,
|
||||
data: data,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
103
lib/features/notifications/data/notification_repository.dart
Normal file
103
lib/features/notifications/data/notification_repository.dart
Normal file
@@ -0,0 +1,103 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:real_estate_mobile/core/network/api_client.dart';
|
||||
import 'package:real_estate_mobile/core/network/api_exceptions.dart';
|
||||
import 'package:real_estate_mobile/features/notifications/data/models/notification_model.dart';
|
||||
|
||||
class NotificationRepository {
|
||||
final Dio _dio = ApiClient.instance.dio;
|
||||
|
||||
/// Fetch notifications with optional filter and pagination.
|
||||
/// GET /notifications?filter=all|unread&page=1&limit=20
|
||||
Future<NotificationsResponse> getNotifications({
|
||||
String filter = 'all',
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.get('/notifications', queryParameters: {
|
||||
'filter': filter,
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
});
|
||||
final data = response.data['data'] as Map<String, dynamic>;
|
||||
return NotificationsResponse.fromJson(data);
|
||||
} on DioException catch (e) {
|
||||
if (e.error is ApiException) throw e.error as ApiException;
|
||||
throw ApiException(
|
||||
message: e.message ?? 'Failed to fetch notifications',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get unread notification count.
|
||||
/// GET /notifications/unread-count
|
||||
Future<int> getUnreadCount() async {
|
||||
try {
|
||||
final response = await _dio.get('/notifications/unread-count');
|
||||
final data = response.data['data'] as Map<String, dynamic>;
|
||||
return data['unreadCount'] as int? ?? 0;
|
||||
} on DioException catch (e) {
|
||||
if (e.error is ApiException) throw e.error as ApiException;
|
||||
throw ApiException(
|
||||
message: e.message ?? 'Failed to fetch unread count',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark a single notification as read.
|
||||
/// PATCH /notifications/:id/read
|
||||
Future<void> markAsRead(String notificationId) async {
|
||||
try {
|
||||
await _dio.patch('/notifications/$notificationId/read');
|
||||
} on DioException catch (e) {
|
||||
if (e.error is ApiException) throw e.error as ApiException;
|
||||
throw ApiException(
|
||||
message: e.message ?? 'Failed to mark notification as read',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark all notifications as read.
|
||||
/// PATCH /notifications/read-all
|
||||
Future<void> markAllAsRead() async {
|
||||
try {
|
||||
await _dio.patch('/notifications/read-all');
|
||||
} on DioException catch (e) {
|
||||
if (e.error is ApiException) throw e.error as ApiException;
|
||||
throw ApiException(
|
||||
message: e.message ?? 'Failed to mark all as read',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationsResponse {
|
||||
final List<AppNotification> notifications;
|
||||
final int unreadCount;
|
||||
final int totalPages;
|
||||
final int currentPage;
|
||||
|
||||
const NotificationsResponse({
|
||||
required this.notifications,
|
||||
required this.unreadCount,
|
||||
required this.totalPages,
|
||||
required this.currentPage,
|
||||
});
|
||||
|
||||
factory NotificationsResponse.fromJson(Map<String, dynamic> json) {
|
||||
final list = (json['notifications'] as List<dynamic>?)
|
||||
?.map((e) => AppNotification.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[];
|
||||
return NotificationsResponse(
|
||||
notifications: list,
|
||||
unreadCount: json['unreadCount'] as int? ?? 0,
|
||||
totalPages: json['totalPages'] as int? ?? 1,
|
||||
currentPage: json['currentPage'] as int? ?? 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:real_estate_mobile/features/notifications/data/models/notification_model.dart';
|
||||
import 'package:real_estate_mobile/features/notifications/data/notification_repository.dart';
|
||||
|
||||
/// Unread notification count — polled every 30 seconds.
|
||||
final unreadCountProvider =
|
||||
StateNotifierProvider<UnreadCountNotifier, int>((ref) {
|
||||
return UnreadCountNotifier(NotificationRepository());
|
||||
});
|
||||
|
||||
class UnreadCountNotifier extends StateNotifier<int> {
|
||||
final NotificationRepository _repo;
|
||||
Timer? _timer;
|
||||
|
||||
UnreadCountNotifier(this._repo) : super(0) {
|
||||
refresh();
|
||||
_timer = Timer.periodic(const Duration(seconds: 30), (_) => refresh());
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
try {
|
||||
final count = await _repo.getUnreadCount();
|
||||
if (mounted) state = count;
|
||||
} catch (_) {
|
||||
// Silently fail — badge just won't update
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Notification list state.
|
||||
class NotificationListState {
|
||||
final List<AppNotification> notifications;
|
||||
final bool isLoading;
|
||||
final bool isLoadingMore;
|
||||
final String? error;
|
||||
final String filter; // 'all' | 'unread'
|
||||
final int currentPage;
|
||||
final int totalPages;
|
||||
|
||||
const NotificationListState({
|
||||
this.notifications = const [],
|
||||
this.isLoading = false,
|
||||
this.isLoadingMore = false,
|
||||
this.error,
|
||||
this.filter = 'all',
|
||||
this.currentPage = 1,
|
||||
this.totalPages = 1,
|
||||
});
|
||||
|
||||
NotificationListState copyWith({
|
||||
List<AppNotification>? notifications,
|
||||
bool? isLoading,
|
||||
bool? isLoadingMore,
|
||||
String? error,
|
||||
String? filter,
|
||||
int? currentPage,
|
||||
int? totalPages,
|
||||
}) {
|
||||
return NotificationListState(
|
||||
notifications: notifications ?? this.notifications,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
|
||||
error: error,
|
||||
filter: filter ?? this.filter,
|
||||
currentPage: currentPage ?? this.currentPage,
|
||||
totalPages: totalPages ?? this.totalPages,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final notificationListProvider =
|
||||
StateNotifierProvider<NotificationListNotifier, NotificationListState>(
|
||||
(ref) {
|
||||
return NotificationListNotifier(NotificationRepository(), ref);
|
||||
});
|
||||
|
||||
class NotificationListNotifier extends StateNotifier<NotificationListState> {
|
||||
final NotificationRepository _repo;
|
||||
final Ref _ref;
|
||||
|
||||
NotificationListNotifier(this._repo, this._ref)
|
||||
: super(const NotificationListState());
|
||||
|
||||
Future<void> loadNotifications({String filter = 'all'}) async {
|
||||
state = state.copyWith(isLoading: true, error: null, filter: filter);
|
||||
try {
|
||||
final response =
|
||||
await _repo.getNotifications(filter: filter, page: 1, limit: 20);
|
||||
state = state.copyWith(
|
||||
notifications: response.notifications,
|
||||
isLoading: false,
|
||||
currentPage: response.currentPage,
|
||||
totalPages: response.totalPages,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadMore() async {
|
||||
if (state.isLoadingMore || state.currentPage >= state.totalPages) return;
|
||||
|
||||
state = state.copyWith(isLoadingMore: true);
|
||||
try {
|
||||
final nextPage = state.currentPage + 1;
|
||||
final response = await _repo.getNotifications(
|
||||
filter: state.filter, page: nextPage, limit: 20);
|
||||
state = state.copyWith(
|
||||
notifications: [...state.notifications, ...response.notifications],
|
||||
isLoadingMore: false,
|
||||
currentPage: response.currentPage,
|
||||
totalPages: response.totalPages,
|
||||
);
|
||||
} catch (_) {
|
||||
state = state.copyWith(isLoadingMore: false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> markAsRead(String notificationId) async {
|
||||
try {
|
||||
await _repo.markAsRead(notificationId);
|
||||
state = state.copyWith(
|
||||
notifications: state.notifications.map((n) {
|
||||
if (n.id == notificationId) return n.copyWith(read: true);
|
||||
return n;
|
||||
}).toList(),
|
||||
);
|
||||
_ref.read(unreadCountProvider.notifier).refresh();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> markAllAsRead() async {
|
||||
try {
|
||||
await _repo.markAllAsRead();
|
||||
state = state.copyWith(
|
||||
notifications:
|
||||
state.notifications.map((n) => n.copyWith(read: true)).toList(),
|
||||
);
|
||||
_ref.read(unreadCountProvider.notifier).refresh();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
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/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),
|
||||
_buildFilterChip('Unread', '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);
|
||||
}
|
||||
},
|
||||
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(
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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 _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';
|
||||
}
|
||||
}
|
||||
@@ -48,13 +48,23 @@ class ProfileRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> getAvatarUploadUrl(String role, String fileName) async {
|
||||
/// Get presigned upload URL for avatar.
|
||||
/// Returns { uploadUrl: String, key: String }
|
||||
Future<Map<String, String>> getAvatarUploadUrl(
|
||||
String role, String fileName, String contentType) async {
|
||||
try {
|
||||
final endpoint = role == 'AGENT'
|
||||
? '/upload/avatar-presigned-url'
|
||||
: '/upload/user-avatar-presigned-url';
|
||||
final response = await _dio.post(endpoint, data: {'fileName': fileName});
|
||||
return response.data['data']['url'] as String;
|
||||
final response = await _dio.post(endpoint, data: {
|
||||
'filename': fileName,
|
||||
'contentType': contentType,
|
||||
});
|
||||
final data = response.data['data'] as Map<String, dynamic>;
|
||||
return {
|
||||
'uploadUrl': data['uploadUrl'] as String,
|
||||
'key': data['key'] as String,
|
||||
};
|
||||
} on DioException catch (e) {
|
||||
if (e.error is ApiException) throw e.error as ApiException;
|
||||
throw ApiException(
|
||||
@@ -63,4 +73,257 @@ class ProfileRepository {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload file bytes directly to S3 presigned URL.
|
||||
Future<void> uploadToS3(
|
||||
String uploadUrl, List<int> fileBytes, String contentType) async {
|
||||
try {
|
||||
await Dio().put(
|
||||
uploadUrl,
|
||||
data: Stream.fromIterable(fileBytes.map((e) => [e])),
|
||||
options: Options(
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': fileBytes.length,
|
||||
},
|
||||
),
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw ApiException(
|
||||
message: e.message ?? 'Failed to upload file',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete avatar from S3: DELETE /upload/{encodedKey}
|
||||
Future<void> deleteAvatar(String key) async {
|
||||
try {
|
||||
final encodedKey = Uri.encodeComponent(key);
|
||||
await _dio.delete('/upload/$encodedKey');
|
||||
} on DioException catch (_) {
|
||||
// S3 delete failure is non-critical
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch privacy preferences.
|
||||
/// Agent: GET /agents/preferences/privacy
|
||||
/// User: GET /users/preferences/privacy
|
||||
Future<Map<String, dynamic>> getPrivacyPreferences(String role) async {
|
||||
try {
|
||||
final endpoint = role == 'AGENT'
|
||||
? '/agents/preferences/privacy'
|
||||
: '/users/preferences/privacy';
|
||||
final response = await _dio.get(endpoint);
|
||||
return response.data['data'] as Map<String, dynamic>;
|
||||
} on DioException catch (e) {
|
||||
if (e.error is ApiException) throw e.error as ApiException;
|
||||
throw ApiException(
|
||||
message: e.message ?? 'Failed to fetch privacy preferences',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update privacy preferences.
|
||||
/// Agent: PUT /agents/preferences/privacy
|
||||
/// User: PUT /users/preferences/privacy
|
||||
Future<Map<String, dynamic>> updatePrivacyPreferences(
|
||||
String role,
|
||||
Map<String, dynamic> data,
|
||||
) async {
|
||||
try {
|
||||
final endpoint = role == 'AGENT'
|
||||
? '/agents/preferences/privacy'
|
||||
: '/users/preferences/privacy';
|
||||
final response = await _dio.put(endpoint, data: data);
|
||||
return response.data['data'] as Map<String, dynamic>;
|
||||
} on DioException catch (e) {
|
||||
if (e.error is ApiException) throw e.error as ApiException;
|
||||
throw ApiException(
|
||||
message: e.message ?? 'Failed to update privacy preferences',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete account.
|
||||
/// Agent: DELETE /agents/account
|
||||
/// User: DELETE /users/account
|
||||
Future<void> deleteAccount(String role) async {
|
||||
try {
|
||||
final endpoint =
|
||||
role == 'AGENT' ? '/agents/account' : '/users/account';
|
||||
await _dio.delete(endpoint);
|
||||
} on DioException catch (e) {
|
||||
if (e.error is ApiException) throw e.error as ApiException;
|
||||
throw ApiException(
|
||||
message: e.message ?? 'Failed to delete account',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Change password: POST /auth/change-password
|
||||
Future<void> changePassword(
|
||||
String currentPassword, String newPassword) async {
|
||||
try {
|
||||
await _dio.post('/auth/change-password', data: {
|
||||
'currentPassword': currentPassword,
|
||||
'newPassword': newPassword,
|
||||
});
|
||||
} on DioException catch (e) {
|
||||
if (e.error is ApiException) throw e.error as ApiException;
|
||||
throw ApiException(
|
||||
message: e.response?.data?['message'] ??
|
||||
e.message ??
|
||||
'Failed to change password',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch notification preferences.
|
||||
/// Agent: GET /agents/preferences/notifications
|
||||
/// User: GET /users/preferences/notifications
|
||||
Future<Map<String, dynamic>> getNotificationPreferences(String role) async {
|
||||
try {
|
||||
final endpoint = role == 'AGENT'
|
||||
? '/agents/preferences/notifications'
|
||||
: '/users/preferences/notifications';
|
||||
final response = await _dio.get(endpoint);
|
||||
return response.data['data'] as Map<String, dynamic>;
|
||||
} on DioException catch (e) {
|
||||
if (e.error is ApiException) throw e.error as ApiException;
|
||||
throw ApiException(
|
||||
message: e.message ?? 'Failed to fetch notification preferences',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update notification preferences.
|
||||
/// Agent: PUT /agents/preferences/notifications
|
||||
/// User: PUT /users/preferences/notifications
|
||||
Future<Map<String, dynamic>> updateNotificationPreferences(
|
||||
String role,
|
||||
Map<String, dynamic> data,
|
||||
) async {
|
||||
try {
|
||||
final endpoint = role == 'AGENT'
|
||||
? '/agents/preferences/notifications'
|
||||
: '/users/preferences/notifications';
|
||||
final response = await _dio.put(endpoint, data: data);
|
||||
return response.data['data'] as Map<String, dynamic>;
|
||||
} on DioException catch (e) {
|
||||
if (e.error is ApiException) throw e.error as ApiException;
|
||||
throw ApiException(
|
||||
message: e.message ?? 'Failed to update notification preferences',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate testimonial link: POST /testimonials/generate-link
|
||||
Future<String> generateTestimonialLink() async {
|
||||
try {
|
||||
final response = await _dio.post('/testimonials/generate-link');
|
||||
return response.data['data']['token'] as String;
|
||||
} on DioException catch (e) {
|
||||
if (e.error is ApiException) throw e.error as ApiException;
|
||||
throw ApiException(
|
||||
message: e.message ?? 'Failed to generate testimonial link',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get agent's testimonials: GET /testimonials/my?sort=latest|oldest
|
||||
Future<List<Map<String, dynamic>>> getMyTestimonials(
|
||||
{String sort = 'latest'}) async {
|
||||
try {
|
||||
final response =
|
||||
await _dio.get('/testimonials/my', queryParameters: {'sort': sort});
|
||||
final list = response.data['data'] as List<dynamic>;
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
} on DioException catch (e) {
|
||||
if (e.error is ApiException) throw e.error as ApiException;
|
||||
throw ApiException(
|
||||
message: e.message ?? 'Failed to fetch testimonials',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current subscription: GET /stripe/subscription
|
||||
Future<Map<String, dynamic>?> getSubscription() async {
|
||||
try {
|
||||
final response = await _dio.get('/stripe/subscription');
|
||||
return response.data['data'] as Map<String, dynamic>?;
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == 404) return null;
|
||||
if (e.error is ApiException) throw e.error as ApiException;
|
||||
throw ApiException(
|
||||
message: e.message ?? 'Failed to fetch subscription',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Create Stripe checkout session: POST /stripe/create-checkout-session
|
||||
Future<String> createCheckoutSession(String planId) async {
|
||||
try {
|
||||
final response = await _dio.post('/stripe/create-checkout-session',
|
||||
data: {'planId': planId});
|
||||
return response.data['data']['url'] as String;
|
||||
} on DioException catch (e) {
|
||||
if (e.error is ApiException) throw e.error as ApiException;
|
||||
throw ApiException(
|
||||
message: e.message ?? 'Failed to create checkout session',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Create Stripe billing portal: POST /stripe/create-portal-session
|
||||
Future<String> createPortalSession() async {
|
||||
try {
|
||||
final response = await _dio.post('/stripe/create-portal-session');
|
||||
return response.data['data']['url'] as String;
|
||||
} on DioException catch (e) {
|
||||
if (e.error is ApiException) throw e.error as ApiException;
|
||||
throw ApiException(
|
||||
message: e.message ?? 'Failed to create billing portal',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel subscription: POST /stripe/cancel-subscription
|
||||
Future<void> cancelSubscription() async {
|
||||
try {
|
||||
await _dio.post('/stripe/cancel-subscription');
|
||||
} on DioException catch (e) {
|
||||
if (e.error is ApiException) throw e.error as ApiException;
|
||||
throw ApiException(
|
||||
message: e.message ?? 'Failed to cancel subscription',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get available plans: GET /stripe/plans
|
||||
Future<List<Map<String, dynamic>>> getStripePlans() async {
|
||||
try {
|
||||
final response = await _dio.get('/stripe/plans');
|
||||
final list = response.data['data'] as List<dynamic>;
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
} on DioException catch (e) {
|
||||
if (e.error is ApiException) throw e.error as ApiException;
|
||||
throw ApiException(
|
||||
message: e.message ?? 'Failed to fetch plans',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user