feat: add new profile settings tabs for testimonials, billing, password, notifications, and privacy.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
682
lib/features/profile/presentation/widgets/billing_tab.dart
Normal file
682
lib/features/profile/presentation/widgets/billing_tab.dart
Normal file
@@ -0,0 +1,682 @@
|
|||||||
|
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:intl/intl.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import 'package:real_estate_mobile/core/constants/app_colors.dart';
|
||||||
|
import 'package:real_estate_mobile/features/profile/presentation/providers/profile_provider.dart';
|
||||||
|
import 'package:real_estate_mobile/features/profile/presentation/screens/stripe_checkout_screen.dart';
|
||||||
|
|
||||||
|
class BillingTab extends ConsumerStatefulWidget {
|
||||||
|
const BillingTab({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<BillingTab> createState() => _BillingTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BillingTabState extends ConsumerState<BillingTab> {
|
||||||
|
Map<String, dynamic>? _subscription;
|
||||||
|
bool _isLoadingSubscription = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadSubscription();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadSubscription() async {
|
||||||
|
if (_isLoadingSubscription) return;
|
||||||
|
setState(() => _isLoadingSubscription = true);
|
||||||
|
try {
|
||||||
|
final repo = ref.read(profileRepositoryProvider);
|
||||||
|
final sub = await repo.getSubscription();
|
||||||
|
setState(() {
|
||||||
|
_subscription = sub;
|
||||||
|
_isLoadingSubscription = false;
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
setState(() => _isLoadingSubscription = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_isLoadingSubscription) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(color: AppColors.accentOrange));
|
||||||
|
}
|
||||||
|
|
||||||
|
final hasActive = _subscription != null &&
|
||||||
|
_subscription!['status'] == 'ACTIVE';
|
||||||
|
final plan = _subscription?['plan'] as Map<String, dynamic>?;
|
||||||
|
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 10, 24, 30),
|
||||||
|
children: [
|
||||||
|
const Center(
|
||||||
|
child: Text(
|
||||||
|
'Subscription & Payments',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 25,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
const Center(
|
||||||
|
child: Text(
|
||||||
|
'Manage Your billing cycles , upgrade your\nplan , or boost individual listings',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'SourceSerif4',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
if (hasActive) ...[
|
||||||
|
const Text(
|
||||||
|
'Current Plan',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildActiveSubscriptionCard(plan),
|
||||||
|
] else ...[
|
||||||
|
const Text(
|
||||||
|
'Annual Membership Plans',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildPlanCard(),
|
||||||
|
],
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildNeedHelpSection(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildActiveSubscriptionCard(Map<String, dynamic>? plan) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.primaryDark.withValues(alpha: 0.5),
|
||||||
|
width: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.accentOrange,
|
||||||
|
borderRadius: BorderRadius.circular(15),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
plan?['name'] as String? ?? 'Annual Subscription',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'SourceSerif4',
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.check_circle,
|
||||||
|
color: Color(0xFF4CAF50), size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text(
|
||||||
|
'Active',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF4CAF50),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (_subscription?['currentPeriodEnd'] != null)
|
||||||
|
Text(
|
||||||
|
'Renews ${_formatDate(_subscription!['currentPeriodEnd'] as String)}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'SourceSerif4',
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppColors.primaryDark.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (plan?['amount'] != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
RichText(
|
||||||
|
text: TextSpan(
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: '\$${plan!['amount']}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 35,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const TextSpan(
|
||||||
|
text: ' / ',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: plan['interval'] as String? ?? 'Year',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primaryDark.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
SizedBox(
|
||||||
|
width: 155,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _openBillingPortal,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.accentOrange,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(15)),
|
||||||
|
),
|
||||||
|
child: const Text('Manage Subscription',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.white)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Text(
|
||||||
|
'Include Features',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
..._buildPlanFeatures(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Center(
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: _cancelSubscription,
|
||||||
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
|
child: const Text('Cancel Subscription',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPlanCard() {
|
||||||
|
return Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 20, 20, 20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.primaryDark.withValues(alpha: 0.5),
|
||||||
|
width: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.accentOrange,
|
||||||
|
borderRadius: BorderRadius.circular(15),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'Annual Subscription',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'SourceSerif4',
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Text(
|
||||||
|
'Professional Annual Plan',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text(
|
||||||
|
'Complete solutions for real estate\nagents and lenders to grow their\nbusiness.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'SourceSerif4',
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
RichText(
|
||||||
|
text: TextSpan(
|
||||||
|
children: [
|
||||||
|
const TextSpan(
|
||||||
|
text: '\$499',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 35,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const TextSpan(
|
||||||
|
text: ' / ',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: 'Year',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.black.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 155,
|
||||||
|
height: 43,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _startCheckout,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.accentOrange,
|
||||||
|
side: const BorderSide(color: AppColors.accentOrange),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(15)),
|
||||||
|
),
|
||||||
|
child: const Text('Get Premium Access',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.white)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(70),
|
||||||
|
child: Image.asset(
|
||||||
|
'assets/icons/wallet_illustration.jpg',
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
|
'Include Features',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
..._buildPlanFeatures(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: const BorderRadius.only(
|
||||||
|
topRight: Radius.circular(10),
|
||||||
|
),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 62,
|
||||||
|
height: 134,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
CustomPaint(
|
||||||
|
size: const Size(62, 134),
|
||||||
|
painter: _RibbonPainter(),
|
||||||
|
),
|
||||||
|
// White dot at top of ribbon
|
||||||
|
Positioned(
|
||||||
|
top: 5,
|
||||||
|
left: 10,
|
||||||
|
child: Container(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// "Best value" text centered on the diagonal
|
||||||
|
Positioned.fill(
|
||||||
|
child: Center(
|
||||||
|
child: Transform.rotate(
|
||||||
|
angle: 1.17, // ~67 degrees
|
||||||
|
child: const Text(
|
||||||
|
'Best value',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNeedHelpSection() {
|
||||||
|
return Container(
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 30, 20, 0),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(15),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.primaryDark.withValues(alpha: 0.7),
|
||||||
|
width: 0.7,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Need Help ?',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
const Text(
|
||||||
|
'Our billing experts are here to help you\nwith any questions\n about your plan.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'SourceSerif4',
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () async {
|
||||||
|
final uri = Uri.parse('mailto:support@re-quest.co');
|
||||||
|
if (await canLaunchUrl(uri)) {
|
||||||
|
await launchUrl(uri);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'support@example.com',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'SourceSerif4',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.accentOrange,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
decorationColor: AppColors.accentOrange,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Transform.rotate(
|
||||||
|
angle: 0.72,
|
||||||
|
child: const Icon(Icons.arrow_upward,
|
||||||
|
size: 16, color: AppColors.accentOrange),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(57),
|
||||||
|
child: Image.asset(
|
||||||
|
'assets/icons/need_help_illustration.jpg',
|
||||||
|
width: 261,
|
||||||
|
height: 261,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildPlanFeatures() {
|
||||||
|
const features = [
|
||||||
|
('Agent -Co-Marketing', true),
|
||||||
|
('Priority 24/7 Support', false),
|
||||||
|
('Whitelabel Client Portals', false),
|
||||||
|
('Advanced CRM Tools & Analytics', false),
|
||||||
|
];
|
||||||
|
return features
|
||||||
|
.map((f) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 20),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SvgPicture.asset(
|
||||||
|
f.$2
|
||||||
|
? 'assets/icons/diamond_icon.svg'
|
||||||
|
: 'assets/icons/feature_check_icon.svg',
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(f.$1,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'SourceSerif4',
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.primaryDark)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openBillingPortal() async {
|
||||||
|
try {
|
||||||
|
final repo = ref.read(profileRepositoryProvider);
|
||||||
|
final url = await repo.createPortalSession();
|
||||||
|
if (!mounted) return;
|
||||||
|
await Navigator.of(context).push<Map<String, dynamic>?>(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => StripeCheckoutScreen(
|
||||||
|
url: url,
|
||||||
|
title: 'Manage Subscription',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
_loadSubscription();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) _showSnackBar('Failed to open billing portal: $e', isError: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _startCheckout() async {
|
||||||
|
try {
|
||||||
|
final repo = ref.read(profileRepositoryProvider);
|
||||||
|
final plans = await repo.getStripePlans();
|
||||||
|
if (plans.isEmpty) {
|
||||||
|
_showSnackBar('No plans available', isError: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final planId = plans.first['id'] as String;
|
||||||
|
final url = await repo.createCheckoutSession(planId);
|
||||||
|
if (!mounted) return;
|
||||||
|
final result = await Navigator.of(context).push<Map<String, dynamic>?>(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => StripeCheckoutScreen(
|
||||||
|
url: url,
|
||||||
|
title: 'Checkout',
|
||||||
|
successUrlPattern: 'confirmation',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (result != null && result['success'] == true && mounted) {
|
||||||
|
final sessionId = result['sessionId'] as String?;
|
||||||
|
context.go('/payment-success${sessionId != null ? '?session_id=$sessionId' : ''}');
|
||||||
|
} else if (mounted) {
|
||||||
|
_loadSubscription();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) _showSnackBar('Failed to start checkout: $e', isError: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _cancelSubscription() async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Cancel Subscription',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primaryDark)),
|
||||||
|
content: const Text(
|
||||||
|
'Are you sure you want to cancel your subscription? You will lose access to premium features at the end of the billing period.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'SourceSerif4',
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.primaryDark),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: const Text('Keep Subscription')),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
|
child: const Text('Yes, Cancel'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final repo = ref.read(profileRepositoryProvider);
|
||||||
|
await repo.cancelSubscription();
|
||||||
|
_showSnackBar('Subscription cancelled');
|
||||||
|
_loadSubscription();
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) _showSnackBar('Failed to cancel: $e', isError: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDate(String dateStr) {
|
||||||
|
try {
|
||||||
|
final date = DateTime.parse(dateStr);
|
||||||
|
return DateFormat('MMM d, y').format(date);
|
||||||
|
} catch (_) {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showSnackBar(String message, {bool isError = false}) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(message),
|
||||||
|
backgroundColor: isError ? Colors.red : const Color(0xFF638559),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RibbonPainter extends CustomPainter {
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final paint = Paint()..color = AppColors.accentOrange;
|
||||||
|
final w = size.width;
|
||||||
|
final h = size.height;
|
||||||
|
final path = Path()
|
||||||
|
..moveTo(w * (28.5 / 62), 0)
|
||||||
|
..lineTo(0, 0)
|
||||||
|
..lineTo(w, h)
|
||||||
|
..lineTo(w, h * (73.99 / 134))
|
||||||
|
..close();
|
||||||
|
canvas.drawPath(path, paint);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:real_estate_mobile/core/constants/app_colors.dart';
|
||||||
|
import 'package:real_estate_mobile/features/profile/presentation/providers/profile_provider.dart';
|
||||||
|
import 'package:real_estate_mobile/features/profile/presentation/widgets/profile_shared_widgets.dart';
|
||||||
|
|
||||||
|
class ChangePasswordTab extends ConsumerStatefulWidget {
|
||||||
|
const ChangePasswordTab({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ChangePasswordTab> createState() => _ChangePasswordTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChangePasswordTabState extends ConsumerState<ChangePasswordTab> {
|
||||||
|
final _currentPasswordController = TextEditingController();
|
||||||
|
final _newPasswordController = TextEditingController();
|
||||||
|
final _confirmPasswordController = TextEditingController();
|
||||||
|
bool _showCurrentPassword = false;
|
||||||
|
bool _showNewPassword = false;
|
||||||
|
bool _showConfirmPassword = false;
|
||||||
|
bool _isChangingPassword = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_currentPasswordController.dispose();
|
||||||
|
_newPasswordController.dispose();
|
||||||
|
_confirmPasswordController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(27, 10, 27, 30),
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Password & Security',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text(
|
||||||
|
'Update your password to keep your account secure.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'SourceSerif4',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 30),
|
||||||
|
|
||||||
|
_buildPasswordField(
|
||||||
|
label: 'Current Password',
|
||||||
|
controller: _currentPasswordController,
|
||||||
|
obscure: !_showCurrentPassword,
|
||||||
|
onToggle: () =>
|
||||||
|
setState(() => _showCurrentPassword = !_showCurrentPassword),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildPasswordField(
|
||||||
|
label: 'New Password',
|
||||||
|
controller: _newPasswordController,
|
||||||
|
obscure: !_showNewPassword,
|
||||||
|
onToggle: () =>
|
||||||
|
setState(() => _showNewPassword = !_showNewPassword),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Min 8 characters, 1 uppercase, 1 lowercase, 1 number',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'SourceSerif4',
|
||||||
|
fontSize: 11,
|
||||||
|
color: AppColors.primaryDark.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildPasswordField(
|
||||||
|
label: 'Confirm New Password',
|
||||||
|
controller: _confirmPasswordController,
|
||||||
|
obscure: !_showConfirmPassword,
|
||||||
|
onToggle: () =>
|
||||||
|
setState(() => _showConfirmPassword = !_showConfirmPassword),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 40),
|
||||||
|
ProfileActionButtons(
|
||||||
|
onSave: _changePassword,
|
||||||
|
onCancel: () => context.pop(),
|
||||||
|
isSaving: _isChangingPassword,
|
||||||
|
saveLabel: 'Update Password',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPasswordField({
|
||||||
|
required String label,
|
||||||
|
required TextEditingController controller,
|
||||||
|
required bool obscure,
|
||||||
|
required VoidCallback onToggle,
|
||||||
|
}) {
|
||||||
|
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,
|
||||||
|
obscureText: obscure,
|
||||||
|
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,
|
||||||
|
suffixIcon: GestureDetector(
|
||||||
|
onTap: onToggle,
|
||||||
|
child: Icon(
|
||||||
|
obscure ? Icons.visibility_off_outlined : Icons.visibility_outlined,
|
||||||
|
size: 18,
|
||||||
|
color: AppColors.primaryDark.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
suffixIconConstraints: const BoxConstraints(minWidth: 40),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _changePassword() async {
|
||||||
|
final current = _currentPasswordController.text.trim();
|
||||||
|
final newPw = _newPasswordController.text.trim();
|
||||||
|
final confirm = _confirmPasswordController.text.trim();
|
||||||
|
|
||||||
|
if (current.isEmpty || newPw.isEmpty || confirm.isEmpty) {
|
||||||
|
_showSnackBar('Please fill in all fields', isError: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final passwordRegex = RegExp(r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$');
|
||||||
|
if (!passwordRegex.hasMatch(newPw)) {
|
||||||
|
_showSnackBar(
|
||||||
|
'Password must be at least 8 characters with uppercase, lowercase, and number',
|
||||||
|
isError: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPw != confirm) {
|
||||||
|
_showSnackBar('Passwords do not match', isError: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _isChangingPassword = true);
|
||||||
|
try {
|
||||||
|
final repo = ref.read(profileRepositoryProvider);
|
||||||
|
await repo.changePassword(current, newPw);
|
||||||
|
_currentPasswordController.clear();
|
||||||
|
_newPasswordController.clear();
|
||||||
|
_confirmPasswordController.clear();
|
||||||
|
_showSnackBar('Password changed successfully');
|
||||||
|
} catch (e) {
|
||||||
|
_showSnackBar('$e', isError: true);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isChangingPassword = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showSnackBar(String message, {bool isError = false}) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(message),
|
||||||
|
backgroundColor: isError ? Colors.red : const Color(0xFF638559),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
360
lib/features/profile/presentation/widgets/notifications_tab.dart
Normal file
360
lib/features/profile/presentation/widgets/notifications_tab.dart
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:real_estate_mobile/core/constants/app_colors.dart';
|
||||||
|
import 'package:real_estate_mobile/features/profile/presentation/providers/profile_provider.dart';
|
||||||
|
import 'package:real_estate_mobile/features/profile/presentation/widgets/profile_shared_widgets.dart';
|
||||||
|
|
||||||
|
class NotificationsTab extends ConsumerStatefulWidget {
|
||||||
|
const NotificationsTab({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<NotificationsTab> createState() => _NotificationsTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NotificationsTabState extends ConsumerState<NotificationsTab> {
|
||||||
|
final Map<String, Map<String, bool>> _notificationPrefs = {
|
||||||
|
'newLeadAlerts': {'email': true, 'inApp': true},
|
||||||
|
'messageUpdates': {'email': false, 'inApp': true},
|
||||||
|
'urgentRequests': {'email': true, 'inApp': true},
|
||||||
|
};
|
||||||
|
String _digestFrequency = 'instant';
|
||||||
|
bool _notificationsLoaded = false;
|
||||||
|
bool _isLoadingNotifications = false;
|
||||||
|
bool _isSavingNotifications = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadNotificationPreferences();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadNotificationPreferences() async {
|
||||||
|
if (_notificationsLoaded) return;
|
||||||
|
setState(() => _isLoadingNotifications = true);
|
||||||
|
try {
|
||||||
|
final role = ref.read(profileProvider).role;
|
||||||
|
final repo = ref.read(profileRepositoryProvider);
|
||||||
|
final data = await repo.getNotificationPreferences(role);
|
||||||
|
final notifications =
|
||||||
|
data['notifications'] as Map<String, dynamic>? ?? {};
|
||||||
|
setState(() {
|
||||||
|
for (final key in ['new_lead_alerts', 'message_updates', 'urgent_requests_tours']) {
|
||||||
|
final mapped = key == 'new_lead_alerts'
|
||||||
|
? 'newLeadAlerts'
|
||||||
|
: key == 'message_updates'
|
||||||
|
? 'messageUpdates'
|
||||||
|
: 'urgentRequests';
|
||||||
|
final vals = notifications[key] as Map<String, dynamic>?;
|
||||||
|
if (vals != null) {
|
||||||
|
_notificationPrefs[mapped] = {
|
||||||
|
'email': vals['email'] as bool? ?? false,
|
||||||
|
'inApp': vals['inApp'] as bool? ?? true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_digestFrequency = data['digestFrequency'] as String? ?? 'instant';
|
||||||
|
_isLoadingNotifications = false;
|
||||||
|
_notificationsLoaded = true;
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
setState(() {
|
||||||
|
_isLoadingNotifications = false;
|
||||||
|
_notificationsLoaded = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_isLoadingNotifications) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(color: AppColors.accentOrange));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(27, 10, 27, 30),
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Notification Preferences',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text(
|
||||||
|
'Choose what you want to be notified about and how.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'SourceSerif4',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildSectionDivider(),
|
||||||
|
const SizedBox(height: 19),
|
||||||
|
_buildNotificationCategory(
|
||||||
|
title: 'New Lead Alerts',
|
||||||
|
description: 'Notifications about new leads and inquiries.',
|
||||||
|
prefKey: 'newLeadAlerts',
|
||||||
|
),
|
||||||
|
_buildSectionDivider(),
|
||||||
|
const SizedBox(height: 13),
|
||||||
|
_buildNotificationCategory(
|
||||||
|
title: 'Message Updates',
|
||||||
|
description:
|
||||||
|
'Notices about new messages from clients or other agents.',
|
||||||
|
prefKey: 'messageUpdates',
|
||||||
|
),
|
||||||
|
_buildSectionDivider(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildNotificationCategory(
|
||||||
|
title: 'Urgent Requests & Tours',
|
||||||
|
description:
|
||||||
|
'Immediate notifications for tour requests and time-sensitive items.',
|
||||||
|
prefKey: 'urgentRequests',
|
||||||
|
),
|
||||||
|
_buildSectionDivider(),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
const Text(
|
||||||
|
'Email Digest Frequency',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text(
|
||||||
|
'Choose how often you receive non-urgent email summaries',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'SourceSerif4',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildFrequencySelector(),
|
||||||
|
const SizedBox(height: 40),
|
||||||
|
ProfileActionButtons(
|
||||||
|
onSave: _saveNotificationSettings,
|
||||||
|
onCancel: () => context.pop(),
|
||||||
|
isSaving: _isSavingNotifications,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveNotificationSettings() async {
|
||||||
|
setState(() => _isSavingNotifications = true);
|
||||||
|
try {
|
||||||
|
final role = ref.read(profileProvider).role;
|
||||||
|
final repo = ref.read(profileRepositoryProvider);
|
||||||
|
await repo.updateNotificationPreferences(role, {
|
||||||
|
'notifications': {
|
||||||
|
'new_lead_alerts': _notificationPrefs['newLeadAlerts'],
|
||||||
|
'message_updates': _notificationPrefs['messageUpdates'],
|
||||||
|
'urgent_requests_tours': _notificationPrefs['urgentRequests'],
|
||||||
|
},
|
||||||
|
'digestFrequency': _digestFrequency,
|
||||||
|
});
|
||||||
|
_showSnackBar('Notification settings saved');
|
||||||
|
} catch (e) {
|
||||||
|
_showSnackBar('Failed to save: $e', isError: true);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isSavingNotifications = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSectionDivider() {
|
||||||
|
return Divider(
|
||||||
|
color: AppColors.primaryDark.withValues(alpha: 0.15),
|
||||||
|
height: 1,
|
||||||
|
thickness: 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNotificationCategory({
|
||||||
|
required String title,
|
||||||
|
required String description,
|
||||||
|
required String prefKey,
|
||||||
|
}) {
|
||||||
|
final prefs = _notificationPrefs[prefKey]!;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(description,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'SourceSerif4',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 20),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildCheckboxRow(
|
||||||
|
label: 'Email',
|
||||||
|
value: prefs['email']!,
|
||||||
|
onChanged: (val) {
|
||||||
|
setState(() {
|
||||||
|
_notificationPrefs[prefKey]!['email'] = val ?? false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_buildCheckboxRow(
|
||||||
|
label: 'In-App',
|
||||||
|
value: prefs['inApp']!,
|
||||||
|
onChanged: (val) {
|
||||||
|
setState(() {
|
||||||
|
_notificationPrefs[prefKey]!['inApp'] = val ?? false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCheckboxRow({
|
||||||
|
required String label,
|
||||||
|
required bool value,
|
||||||
|
required ValueChanged<bool?> onChanged,
|
||||||
|
}) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => onChanged(!value),
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: Checkbox(
|
||||||
|
value: value,
|
||||||
|
onChanged: onChanged,
|
||||||
|
activeColor: AppColors.primaryDark,
|
||||||
|
checkColor: Colors.white,
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(3)),
|
||||||
|
side: BorderSide(
|
||||||
|
color: AppColors.primaryDark.withValues(alpha: 0.4),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'SourceSerif4',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFrequencySelector() {
|
||||||
|
const options = ['instant', 'daily', 'weekly', 'off'];
|
||||||
|
const labels = ['Instant', 'Daily', 'Weekly', 'Off'];
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
height: 32,
|
||||||
|
width: 278,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(7),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.primaryDark.withValues(alpha: 0.15),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: Row(
|
||||||
|
children: List.generate(options.length * 2 - 1, (index) {
|
||||||
|
if (index.isOdd) {
|
||||||
|
return Container(
|
||||||
|
width: 1,
|
||||||
|
height: 32,
|
||||||
|
color: AppColors.primaryDark.withValues(alpha: 0.15),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final i = index ~/ 2;
|
||||||
|
final isSelected = _digestFrequency == options[i];
|
||||||
|
return Expanded(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => setState(() => _digestFrequency = options[i]),
|
||||||
|
child: Center(
|
||||||
|
child: isSelected
|
||||||
|
? Container(
|
||||||
|
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,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showSnackBar(String message, {bool isError = false}) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(message),
|
||||||
|
backgroundColor: isError ? Colors.red : const Color(0xFF638559),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
301
lib/features/profile/presentation/widgets/privacy_tab.dart
Normal file
301
lib/features/profile/presentation/widgets/privacy_tab.dart
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:real_estate_mobile/core/constants/app_colors.dart';
|
||||||
|
import 'package:real_estate_mobile/features/auth/presentation/providers/auth_provider.dart';
|
||||||
|
import 'package:real_estate_mobile/features/profile/presentation/providers/profile_provider.dart';
|
||||||
|
import 'package:real_estate_mobile/features/profile/presentation/widgets/profile_shared_widgets.dart';
|
||||||
|
|
||||||
|
class PrivacyTab extends ConsumerStatefulWidget {
|
||||||
|
const PrivacyTab({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<PrivacyTab> createState() => _PrivacyTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PrivacyTabState extends ConsumerState<PrivacyTab> {
|
||||||
|
String _profileVisibility = 'public';
|
||||||
|
String _contactInfo = 'connections';
|
||||||
|
String _activityStatus = 'public';
|
||||||
|
bool _shareAnalytics = false;
|
||||||
|
bool _personalizedAds = false;
|
||||||
|
bool _thirdPartySharing = false;
|
||||||
|
bool _isLoadingPrivacy = false;
|
||||||
|
bool _isSavingPrivacy = false;
|
||||||
|
bool _privacyLoaded = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadPrivacyPreferences();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadPrivacyPreferences() async {
|
||||||
|
if (_privacyLoaded) return;
|
||||||
|
setState(() => _isLoadingPrivacy = true);
|
||||||
|
try {
|
||||||
|
final role = ref.read(profileProvider).role;
|
||||||
|
final repo = ref.read(profileRepositoryProvider);
|
||||||
|
final data = await repo.getPrivacyPreferences(role);
|
||||||
|
final privacy = data['privacySettings'] as Map<String, dynamic>? ?? {};
|
||||||
|
final dataSettings =
|
||||||
|
data['dataSettings'] as Map<String, dynamic>? ?? {};
|
||||||
|
setState(() {
|
||||||
|
_profileVisibility =
|
||||||
|
privacy['profile_visibility'] as String? ?? 'public';
|
||||||
|
_contactInfo = privacy['contact_info'] as String? ?? 'connections';
|
||||||
|
_activityStatus = privacy['activity_status'] as String? ?? 'public';
|
||||||
|
_shareAnalytics = dataSettings['shareAnalytics'] as bool? ?? false;
|
||||||
|
_personalizedAds = dataSettings['personalizedAds'] as bool? ?? false;
|
||||||
|
_thirdPartySharing =
|
||||||
|
dataSettings['thirdPartySharing'] as bool? ?? false;
|
||||||
|
_isLoadingPrivacy = false;
|
||||||
|
_privacyLoaded = true;
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
setState(() => _isLoadingPrivacy = false);
|
||||||
|
_privacyLoaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_isLoadingPrivacy) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(color: AppColors.accentOrange));
|
||||||
|
}
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ProfileSectionCard(
|
||||||
|
title: 'Privacy Settings',
|
||||||
|
subtitle: 'Control your privacy and visibility preferences',
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
ProfileDropdownRow(
|
||||||
|
label: 'Profile Visibility',
|
||||||
|
description: 'Control who can see your profile',
|
||||||
|
value: _profileVisibility,
|
||||||
|
options: const {
|
||||||
|
'public': 'Public',
|
||||||
|
'connections': 'Connections Only',
|
||||||
|
'private': 'Private',
|
||||||
|
},
|
||||||
|
onChanged: (v) =>
|
||||||
|
setState(() => _profileVisibility = v ?? _profileVisibility),
|
||||||
|
),
|
||||||
|
const Divider(height: 24, color: Color(0xFFE8E8E8)),
|
||||||
|
ProfileDropdownRow(
|
||||||
|
label: 'Contact Information',
|
||||||
|
description: 'Control who can see your contact details',
|
||||||
|
value: _contactInfo,
|
||||||
|
options: const {
|
||||||
|
'public': 'Public',
|
||||||
|
'connections': 'Connections Only',
|
||||||
|
'private': 'Hidden',
|
||||||
|
},
|
||||||
|
onChanged: (v) =>
|
||||||
|
setState(() => _contactInfo = v ?? _contactInfo),
|
||||||
|
),
|
||||||
|
const Divider(height: 24, color: Color(0xFFE8E8E8)),
|
||||||
|
ProfileDropdownRow(
|
||||||
|
label: 'Activity Status',
|
||||||
|
description: 'Show when you are active on the platform',
|
||||||
|
value: _activityStatus,
|
||||||
|
options: const {
|
||||||
|
'public': 'Everyone',
|
||||||
|
'connections': 'Connections Only',
|
||||||
|
'private': 'No One',
|
||||||
|
},
|
||||||
|
onChanged: (v) =>
|
||||||
|
setState(() => _activityStatus = v ?? _activityStatus),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
ProfileSectionCard(
|
||||||
|
title: 'Data & Personalization',
|
||||||
|
subtitle: 'Manage how your data is used',
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
ProfileToggleRow(
|
||||||
|
label: 'Share Analytics',
|
||||||
|
description:
|
||||||
|
'Help improve our service by sharing usage data',
|
||||||
|
value: _shareAnalytics,
|
||||||
|
onChanged: (v) => setState(() => _shareAnalytics = v),
|
||||||
|
),
|
||||||
|
const Divider(height: 24, color: Color(0xFFE8E8E8)),
|
||||||
|
ProfileToggleRow(
|
||||||
|
label: 'Personalized Ads',
|
||||||
|
description: 'See ads tailored to your interests',
|
||||||
|
value: _personalizedAds,
|
||||||
|
onChanged: (v) => setState(() => _personalizedAds = v),
|
||||||
|
),
|
||||||
|
const Divider(height: 24, color: Color(0xFFE8E8E8)),
|
||||||
|
ProfileToggleRow(
|
||||||
|
label: 'Third-Party Data Sharing',
|
||||||
|
description:
|
||||||
|
'Allow sharing data with third-party partners',
|
||||||
|
value: _thirdPartySharing,
|
||||||
|
onChanged: (v) => setState(() => _thirdPartySharing = v),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
ProfileActionButtons(
|
||||||
|
onSave: _savePrivacyPreferences,
|
||||||
|
onCancel: () => context.pop(),
|
||||||
|
isSaving: _isSavingPrivacy),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ProfileSectionCard(
|
||||||
|
title: 'Danger Zone',
|
||||||
|
subtitle: 'Irreversible and destructive actions',
|
||||||
|
borderColor: Colors.red.withValues(alpha: 0.3),
|
||||||
|
titleColor: Colors.red,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Once you delete your account, there is no going back. All your data will be permanently removed.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'SourceSerif4',
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppColors.hintText),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: _deleteAccount,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: Colors.red,
|
||||||
|
side: const BorderSide(color: Colors.red, width: 1.5),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(15)),
|
||||||
|
),
|
||||||
|
child: const Text('Delete Account',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _savePrivacyPreferences() async {
|
||||||
|
setState(() => _isSavingPrivacy = true);
|
||||||
|
try {
|
||||||
|
final role = ref.read(profileProvider).role;
|
||||||
|
final repo = ref.read(profileRepositoryProvider);
|
||||||
|
await repo.updatePrivacyPreferences(role, {
|
||||||
|
'privacySettings': {
|
||||||
|
'profile_visibility': _profileVisibility,
|
||||||
|
'contact_info': _contactInfo,
|
||||||
|
'activity_status': _activityStatus,
|
||||||
|
},
|
||||||
|
'dataSettings': {
|
||||||
|
'shareAnalytics': _shareAnalytics,
|
||||||
|
'personalizedAds': _personalizedAds,
|
||||||
|
'thirdPartySharing': _thirdPartySharing,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
_showSnackBar('Privacy settings saved');
|
||||||
|
} catch (e) {
|
||||||
|
_showSnackBar('Failed to save: $e', isError: true);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isSavingPrivacy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteAccount() async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Delete Account',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primaryDark)),
|
||||||
|
content: const Text(
|
||||||
|
'Are you sure you want to delete your account? This action cannot be undone and all your data will be permanently removed.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'SourceSerif4',
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.primaryDark),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: const Text('Cancel')),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
|
child: const Text('Delete')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
|
||||||
|
final doubleConfirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Final Confirmation',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.red)),
|
||||||
|
content: const Text(
|
||||||
|
'This will permanently delete your account, profile, and all associated data. Are you absolutely sure?',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'SourceSerif4',
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.primaryDark),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: const Text('Cancel')),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: Colors.white, backgroundColor: Colors.red),
|
||||||
|
child: const Text('Yes, Delete My Account'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (doubleConfirmed != true || !mounted) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final role = ref.read(profileProvider).role;
|
||||||
|
final repo = ref.read(profileRepositoryProvider);
|
||||||
|
await repo.deleteAccount(role);
|
||||||
|
if (mounted) ref.read(authProvider.notifier).logout();
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) _showSnackBar('Failed to delete account: $e', isError: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showSnackBar(String message, {bool isError = false}) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(message),
|
||||||
|
backgroundColor: isError ? Colors.red : const Color(0xFF638559),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.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/profile/data/profile_repository.dart';
|
||||||
|
import 'package:real_estate_mobile/features/profile/presentation/providers/profile_provider.dart';
|
||||||
|
import 'package:real_estate_mobile/features/profile/presentation/widgets/profile_shared_widgets.dart';
|
||||||
|
|
||||||
|
class ProfileSettingsTab extends ConsumerStatefulWidget {
|
||||||
|
const ProfileSettingsTab({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ProfileSettingsTab> createState() => _ProfileSettingsTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProfileSettingsTabState extends ConsumerState<ProfileSettingsTab> {
|
||||||
|
late TextEditingController _fullNameController;
|
||||||
|
late TextEditingController _titleController;
|
||||||
|
late TextEditingController _emailController;
|
||||||
|
late TextEditingController _phoneController;
|
||||||
|
late TextEditingController _locationController;
|
||||||
|
|
||||||
|
bool _controllersInitialized = false;
|
||||||
|
bool _isUploadingAvatar = false;
|
||||||
|
File? _localAvatarFile;
|
||||||
|
|
||||||
|
@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) {
|
||||||
|
final agentType = profile['agentType'] as Map<String, dynamic>?;
|
||||||
|
_titleController.text =
|
||||||
|
agentType?['name'] as String? ?? profile['bio'] as String? ?? '';
|
||||||
|
final serviceAreas = profile['serviceAreas'];
|
||||||
|
if (serviceAreas is List && serviceAreas.isNotEmpty) {
|
||||||
|
_locationController.text = serviceAreas.first.toString();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_titleController.text = profile['headline'] as String? ?? '';
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
final profile = profileState.profile;
|
||||||
|
final isAgent = profileState.isAgent;
|
||||||
|
|
||||||
|
String? avatarUrl = profile['avatar'] as String?;
|
||||||
|
if (avatarUrl == null || avatarUrl.isEmpty) {
|
||||||
|
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: [
|
||||||
|
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: _isUploadingAvatar
|
||||||
|
? const Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: AppColors.accentOrange,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: _localAvatarFile != null
|
||||||
|
? Image.file(_localAvatarFile!,
|
||||||
|
width: 71, height: 69, fit: BoxFit.cover)
|
||||||
|
: avatarUrl != null && avatarUrl.isNotEmpty
|
||||||
|
? S3Image(
|
||||||
|
imageUrl: avatarUrl,
|
||||||
|
width: 71,
|
||||||
|
height: 69,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
)
|
||||||
|
: const Icon(Icons.person,
|
||||||
|
size: 36, color: AppColors.primaryDark),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _isUploadingAvatar ? null : _pickAvatar,
|
||||||
|
child: Container(
|
||||||
|
width: 95,
|
||||||
|
height: 24,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.accentOrange,
|
||||||
|
borderRadius: BorderRadius.circular(7),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: _isUploadingAvatar
|
||||||
|
? const SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Text('Upload Now',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'SourceSerif4',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _isUploadingAvatar ? null : _deleteAvatar,
|
||||||
|
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),
|
||||||
|
|
||||||
|
ProfileFormField(label: 'Full Name', controller: _fullNameController),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ProfileFormField(
|
||||||
|
label: isAgent ? 'Career / Agent Type' : 'Professional Title',
|
||||||
|
controller: _titleController,
|
||||||
|
enabled: !isAgent,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ProfileFormField(
|
||||||
|
label: 'Email Address',
|
||||||
|
controller: _emailController,
|
||||||
|
enabled: false),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ProfileFormField(
|
||||||
|
label: 'Phone Number',
|
||||||
|
controller: _phoneController,
|
||||||
|
keyboardType: TextInputType.phone),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ProfileFormField(
|
||||||
|
label: isAgent ? 'Service Areas' : 'Location',
|
||||||
|
controller: _locationController,
|
||||||
|
prefixIcon: Icons.location_on,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 30),
|
||||||
|
ProfileActionButtons(
|
||||||
|
onSave: _saveProfileSettings,
|
||||||
|
onCancel: () => context.pop(),
|
||||||
|
isSaving: profileState.isSaving,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickAvatar() async {
|
||||||
|
final picker = ImagePicker();
|
||||||
|
final picked = await picker.pickImage(
|
||||||
|
source: ImageSource.gallery,
|
||||||
|
maxWidth: 800,
|
||||||
|
maxHeight: 800,
|
||||||
|
imageQuality: 85,
|
||||||
|
);
|
||||||
|
if (picked == null) return;
|
||||||
|
|
||||||
|
final ext = picked.path.split('.').last.toLowerCase();
|
||||||
|
if (!['jpg', 'jpeg', 'png', 'gif'].contains(ext)) {
|
||||||
|
if (mounted) _showSnackBar('Please select a JPEG, PNG, or GIF image', isError: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final fileBytes = await picked.readAsBytes();
|
||||||
|
if (fileBytes.length > 2 * 1024 * 1024) {
|
||||||
|
if (mounted) _showSnackBar('Image must be less than 2MB', isError: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_localAvatarFile = File(picked.path);
|
||||||
|
_isUploadingAvatar = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final profileState = ref.read(profileProvider);
|
||||||
|
final role = profileState.isAgent ? 'AGENT' : 'USER';
|
||||||
|
final contentType = 'image/$ext';
|
||||||
|
final fileName = picked.name;
|
||||||
|
final repo = ProfileRepository();
|
||||||
|
|
||||||
|
final uploadData =
|
||||||
|
await repo.getAvatarUploadUrl(role, fileName, contentType);
|
||||||
|
final uploadUrl = uploadData['uploadUrl']!;
|
||||||
|
final key = uploadData['key']!;
|
||||||
|
|
||||||
|
await repo.uploadToS3(uploadUrl, fileBytes, contentType);
|
||||||
|
|
||||||
|
await ref
|
||||||
|
.read(profileProvider.notifier)
|
||||||
|
.updateProfile({'avatar': key});
|
||||||
|
|
||||||
|
if (mounted) _showSnackBar('Avatar updated successfully');
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) _showSnackBar('Failed to upload avatar: $e', isError: true);
|
||||||
|
setState(() => _localAvatarFile = null);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isUploadingAvatar = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteAvatar() async {
|
||||||
|
final profileState = ref.read(profileProvider);
|
||||||
|
final currentAvatar = profileState.profile['avatar'] as String?;
|
||||||
|
|
||||||
|
if (currentAvatar != null && currentAvatar.isNotEmpty) {
|
||||||
|
final repo = ProfileRepository();
|
||||||
|
await repo.deleteAvatar(currentAvatar);
|
||||||
|
}
|
||||||
|
|
||||||
|
await ref.read(profileProvider.notifier).updateProfile({'avatar': null});
|
||||||
|
|
||||||
|
setState(() => _localAvatarFile = null);
|
||||||
|
if (mounted) _showSnackBar('Avatar deleted');
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
final location = _locationController.text.trim();
|
||||||
|
if (location.isNotEmpty) data['serviceAreas'] = [location];
|
||||||
|
} else {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showSnackBar(String message, {bool isError = false}) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(message),
|
||||||
|
backgroundColor: isError ? Colors.red : const Color(0xFF638559),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:real_estate_mobile/core/constants/app_colors.dart';
|
||||||
|
|
||||||
|
class ProfileFormField extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final TextEditingController controller;
|
||||||
|
final bool enabled;
|
||||||
|
final TextInputType? keyboardType;
|
||||||
|
final IconData? prefixIcon;
|
||||||
|
|
||||||
|
const ProfileFormField({
|
||||||
|
super.key,
|
||||||
|
required this.label,
|
||||||
|
required this.controller,
|
||||||
|
this.enabled = true,
|
||||||
|
this.keyboardType,
|
||||||
|
this.prefixIcon,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProfileActionButtons extends StatelessWidget {
|
||||||
|
final VoidCallback onSave;
|
||||||
|
final VoidCallback onCancel;
|
||||||
|
final bool isSaving;
|
||||||
|
final String saveLabel;
|
||||||
|
|
||||||
|
const ProfileActionButtons({
|
||||||
|
super.key,
|
||||||
|
required this.onSave,
|
||||||
|
required this.onCancel,
|
||||||
|
this.isSaving = false,
|
||||||
|
this.saveLabel = 'Save Changes',
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
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: onCancel,
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
: Text(saveLabel,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProfileSectionCard extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final Widget child;
|
||||||
|
final Color borderColor;
|
||||||
|
final Color titleColor;
|
||||||
|
|
||||||
|
const ProfileSectionCard({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.child,
|
||||||
|
this.borderColor = const Color(0xFFE8E8E8),
|
||||||
|
this.titleColor = AppColors.primaryDark,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(15),
|
||||||
|
border: Border.all(color: borderColor, width: 1),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: titleColor,
|
||||||
|
)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(subtitle,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'SourceSerif4',
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppColors.hintText,
|
||||||
|
)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
child,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProfileDropdownRow extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String description;
|
||||||
|
final String value;
|
||||||
|
final Map<String, String> options;
|
||||||
|
final ValueChanged<String?> onChanged;
|
||||||
|
|
||||||
|
const ProfileDropdownRow({
|
||||||
|
super.key,
|
||||||
|
required this.label,
|
||||||
|
required this.description,
|
||||||
|
required this.value,
|
||||||
|
required this.options,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
)),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(description,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'SourceSerif4',
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.hintText,
|
||||||
|
)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
height: 42,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.primaryDark.withValues(alpha: 0.15)),
|
||||||
|
),
|
||||||
|
child: DropdownButtonHideUnderline(
|
||||||
|
child: DropdownButton<String>(
|
||||||
|
value: value,
|
||||||
|
isExpanded: true,
|
||||||
|
icon: const Icon(Icons.keyboard_arrow_down,
|
||||||
|
color: AppColors.primaryDark, size: 20),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
items: options.entries
|
||||||
|
.map((e) =>
|
||||||
|
DropdownMenuItem(value: e.key, child: Text(e.value)))
|
||||||
|
.toList(),
|
||||||
|
onChanged: onChanged,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProfileToggleRow extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String description;
|
||||||
|
final bool value;
|
||||||
|
final ValueChanged<bool> onChanged;
|
||||||
|
|
||||||
|
const ProfileToggleRow({
|
||||||
|
super.key,
|
||||||
|
required this.label,
|
||||||
|
required this.description,
|
||||||
|
required this.value,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
)),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(description,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'SourceSerif4',
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.hintText,
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => onChanged(!value),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
width: 44,
|
||||||
|
height: 24,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
color: value
|
||||||
|
? AppColors.accentOrange
|
||||||
|
: AppColors.primaryDark.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
child: AnimatedAlign(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
alignment:
|
||||||
|
value ? Alignment.centerRight : Alignment.centerLeft,
|
||||||
|
child: Container(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.white, shape: BoxShape.circle),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
391
lib/features/profile/presentation/widgets/testimonials_tab.dart
Normal file
391
lib/features/profile/presentation/widgets/testimonials_tab.dart
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:real_estate_mobile/core/constants/app_colors.dart';
|
||||||
|
import 'package:real_estate_mobile/features/profile/presentation/providers/profile_provider.dart';
|
||||||
|
|
||||||
|
class TestimonialsTab extends ConsumerStatefulWidget {
|
||||||
|
const TestimonialsTab({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<TestimonialsTab> createState() => _TestimonialsTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TestimonialsTabState extends ConsumerState<TestimonialsTab> {
|
||||||
|
List<Map<String, dynamic>> _testimonials = [];
|
||||||
|
bool _isLoadingTestimonials = false;
|
||||||
|
String _testimonialSort = 'latest';
|
||||||
|
String? _testimonialLink;
|
||||||
|
bool _isGeneratingLink = false;
|
||||||
|
bool _linkCopied = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadTestimonials();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadTestimonials() async {
|
||||||
|
if (_isLoadingTestimonials) return;
|
||||||
|
setState(() => _isLoadingTestimonials = true);
|
||||||
|
try {
|
||||||
|
final repo = ref.read(profileRepositoryProvider);
|
||||||
|
final data = await repo.getMyTestimonials(sort: _testimonialSort);
|
||||||
|
setState(() {
|
||||||
|
_testimonials = data;
|
||||||
|
_isLoadingTestimonials = false;
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
setState(() => _isLoadingTestimonials = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _generateTestimonialLink() async {
|
||||||
|
setState(() => _isGeneratingLink = true);
|
||||||
|
try {
|
||||||
|
final repo = ref.read(profileRepositoryProvider);
|
||||||
|
final token = await repo.generateTestimonialLink();
|
||||||
|
setState(() {
|
||||||
|
_testimonialLink = 'https://re-quest.co/testimonials/$token';
|
||||||
|
_isGeneratingLink = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setState(() => _isGeneratingLink = false);
|
||||||
|
if (mounted) _showSnackBar('Failed to generate link: $e', isError: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 10, 24, 30),
|
||||||
|
children: [
|
||||||
|
const Center(
|
||||||
|
child: Text(
|
||||||
|
'Add Testimonials',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 25,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
const Center(
|
||||||
|
child: Text(
|
||||||
|
'What people are saying about you',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'SourceSerif4',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
const Center(
|
||||||
|
child: Text(
|
||||||
|
'Create Testimonials',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 25,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 43,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _isGeneratingLink ? null : _generateTestimonialLink,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.accentOrange,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(15)),
|
||||||
|
),
|
||||||
|
child: _isGeneratingLink
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2, color: Colors.white))
|
||||||
|
: const Text('Generate Link',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.white)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
if (_testimonialLink != null)
|
||||||
|
Container(
|
||||||
|
height: 43,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(15),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.primaryDark.withValues(alpha: 0.1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Text(
|
||||||
|
_testimonialLink!,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w300,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
width: 1,
|
||||||
|
height: 43,
|
||||||
|
color: AppColors.primaryDark.withValues(alpha: 0.1),
|
||||||
|
),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
Clipboard.setData(
|
||||||
|
ClipboardData(text: _testimonialLink!));
|
||||||
|
setState(() => _linkCopied = true);
|
||||||
|
Future.delayed(const Duration(seconds: 2), () {
|
||||||
|
if (mounted) setState(() => _linkCopied = false);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_linkCopied ? Icons.check : Icons.copy_outlined,
|
||||||
|
size: 18,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
_linkCopied ? 'Copied!' : 'Copy',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'SourceSerif4',
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 30),
|
||||||
|
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Reviews',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_testimonialSort =
|
||||||
|
_testimonialSort == 'latest' ? 'oldest' : 'latest';
|
||||||
|
});
|
||||||
|
_loadTestimonials();
|
||||||
|
},
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Sort By : ${_testimonialSort == 'latest' ? 'Latest' : 'Oldest'}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
const Icon(Icons.arrow_downward,
|
||||||
|
size: 14, color: Colors.black),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
if (_isLoadingTestimonials)
|
||||||
|
const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(40),
|
||||||
|
child: CircularProgressIndicator(color: AppColors.accentOrange),
|
||||||
|
))
|
||||||
|
else if (_testimonials.isEmpty)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFD9D9D9).withValues(alpha: 0.25),
|
||||||
|
borderRadius: BorderRadius.circular(7),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.rate_review_outlined,
|
||||||
|
size: 48,
|
||||||
|
color: AppColors.primaryDark.withValues(alpha: 0.3)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text(
|
||||||
|
'No testimonials yet',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'Share your testimonial link with clients to start collecting reviews.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'SourceSerif4',
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.primaryDark.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFD9D9D9).withValues(alpha: 0.25),
|
||||||
|
borderRadius: BorderRadius.circular(7),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: _testimonials
|
||||||
|
.map((t) => _buildTestimonialCard(t))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTestimonialCard(Map<String, dynamic> testimonial) {
|
||||||
|
final rating = (testimonial['rating'] as num?)?.toInt() ?? 5;
|
||||||
|
final text = testimonial['text'] as String? ?? '';
|
||||||
|
final authorName = testimonial['authorName'] as String? ?? '';
|
||||||
|
final authorRole = testimonial['authorRole'] as String? ?? '';
|
||||||
|
final createdAt = testimonial['createdAt'] as String?;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(7),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: List.generate(
|
||||||
|
5,
|
||||||
|
(i) => Icon(
|
||||||
|
i < rating ? Icons.star : Icons.star_border,
|
||||||
|
size: 16,
|
||||||
|
color: i < rating
|
||||||
|
? AppColors.accentOrange
|
||||||
|
: AppColors.primaryDark.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'SourceSerif4',
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.black,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
RichText(
|
||||||
|
text: TextSpan(
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: authorName,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (authorRole.isNotEmpty || createdAt != null)
|
||||||
|
TextSpan(
|
||||||
|
text: ' , ${authorRole.isNotEmpty ? authorRole : ''}${createdAt != null ? ' · ${_formatRelativeTime(createdAt)}' : ''}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w300,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatRelativeTime(String dateStr) {
|
||||||
|
try {
|
||||||
|
final date = DateTime.parse(dateStr);
|
||||||
|
final now = DateTime.now();
|
||||||
|
final diff = now.difference(date);
|
||||||
|
if (diff.inDays == 0) return 'Today';
|
||||||
|
if (diff.inDays == 1) return 'Yesterday';
|
||||||
|
if (diff.inDays < 30) return '${diff.inDays} days ago';
|
||||||
|
if (diff.inDays < 365) return '${diff.inDays ~/ 30} months ago';
|
||||||
|
return DateFormat('MMM d, y').format(date);
|
||||||
|
} catch (_) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showSnackBar(String message, {bool isError = false}) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(message),
|
||||||
|
backgroundColor: isError ? Colors.red : const Color(0xFF638559),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user