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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user