feat: implement Stripe checkout flow, update profile settings UI for subscriptions, and add agent profile editing.
This commit is contained in:
@@ -299,6 +299,53 @@ class ProfileRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get sections for an agent type: GET /profile-sections/agent-type/{agentTypeId}
|
||||
/// Returns { agentType: {...}, sections: [...] }
|
||||
Future<Map<String, dynamic>> getSectionsForAgentType(
|
||||
String agentTypeId) async {
|
||||
try {
|
||||
final response =
|
||||
await _dio.get('/profile-sections/agent-type/$agentTypeId');
|
||||
return response.data['data'] as Map<String, dynamic>;
|
||||
} on DioException catch (e) {
|
||||
if (e.error is ApiException) throw e.error as ApiException;
|
||||
throw ApiException(
|
||||
message: e.message ?? 'Failed to fetch profile sections',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get field values: GET /agents/profile/field-values
|
||||
/// Returns { agentProfileId: "...", fieldValues: [...] }
|
||||
Future<Map<String, dynamic>> getFieldValues() async {
|
||||
try {
|
||||
final response = await _dio.get('/agents/profile/field-values');
|
||||
return response.data['data'] as Map<String, dynamic>;
|
||||
} on DioException catch (e) {
|
||||
if (e.error is ApiException) throw e.error as ApiException;
|
||||
throw ApiException(
|
||||
message: e.message ?? 'Failed to fetch field values',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Save field values: PUT /agents/profile/field-values
|
||||
Future<void> saveFieldValues(List<Map<String, dynamic>> fieldValues) async {
|
||||
try {
|
||||
await _dio.put('/agents/profile/field-values', data: {
|
||||
'fieldValues': fieldValues,
|
||||
});
|
||||
} on DioException catch (e) {
|
||||
if (e.error is ApiException) throw e.error as ApiException;
|
||||
throw ApiException(
|
||||
message: e.message ?? 'Failed to save field values',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel subscription: POST /stripe/cancel-subscription
|
||||
Future<void> cancelSubscription() async {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:real_estate_mobile/core/constants/app_colors.dart';
|
||||
import 'package:real_estate_mobile/features/profile/data/profile_repository.dart';
|
||||
|
||||
class PaymentSuccessScreen extends ConsumerStatefulWidget {
|
||||
final String? sessionId;
|
||||
|
||||
const PaymentSuccessScreen({super.key, this.sessionId});
|
||||
|
||||
@override
|
||||
ConsumerState<PaymentSuccessScreen> createState() =>
|
||||
_PaymentSuccessScreenState();
|
||||
}
|
||||
|
||||
class _PaymentSuccessScreenState extends ConsumerState<PaymentSuccessScreen> {
|
||||
Map<String, dynamic>? _subscription;
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSubscription();
|
||||
}
|
||||
|
||||
Future<void> _loadSubscription() async {
|
||||
try {
|
||||
final repo = ProfileRepository();
|
||||
final sub = await repo.getSubscription();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_subscription = sub;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDate(String? dateStr) {
|
||||
if (dateStr == null) return 'N/A';
|
||||
try {
|
||||
final date = DateTime.parse(dateStr);
|
||||
return DateFormat('MMM dd, yyyy').format(date);
|
||||
} catch (_) {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
String _formatAmount(dynamic amount) {
|
||||
if (amount == null) return '\$499.00';
|
||||
if (amount is int) return '\$${(amount / 100).toStringAsFixed(2)}';
|
||||
if (amount is double) return '\$${(amount / 100).toStringAsFixed(2)}';
|
||||
return '\$$amount';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: SafeArea(
|
||||
child: _isLoading
|
||||
? const Center(
|
||||
child:
|
||||
CircularProgressIndicator(color: AppColors.accentOrange))
|
||||
: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(24, 20, 24, 40),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Back button
|
||||
GestureDetector(
|
||||
onTap: () => context.go('/'),
|
||||
child: const Icon(Icons.arrow_back,
|
||||
color: AppColors.primaryDark),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Title
|
||||
const Center(
|
||||
child: Text(
|
||||
'Payments',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 25,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Center(
|
||||
child: Text(
|
||||
'Manage Your billing cycles , upgrade your\nplan , or boost individual listings',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 14,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
|
||||
// Success card
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 32, horizontal: 24),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: AppColors.primaryDark.withValues(alpha: 0.5),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Success icon
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: AppColors.accentOrange, width: 2),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.check,
|
||||
color: AppColors.accentOrange,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Payment Successful!
|
||||
const Text(
|
||||
'Payment Successful !',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
const Text(
|
||||
'Your annual membership is now active. You have\nfull access to all premium features',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 14,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Transaction Details header
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Transaction Details',
|
||||
style: TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 14,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.verified_user,
|
||||
size: 18,
|
||||
color: Colors.green.shade600),
|
||||
const SizedBox(width: 6),
|
||||
const Text(
|
||||
'Verified User',
|
||||
style: TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 14,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Transaction ID
|
||||
_buildDetailRow(
|
||||
'Transaction ID',
|
||||
widget.sessionId != null &&
|
||||
widget.sessionId!.length > 16
|
||||
? 'REQ-${widget.sessionId!.substring(0, 8)}'
|
||||
: 'REQ-${widget.sessionId ?? 'N/A'}',
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Amount Paid
|
||||
_buildDetailRow(
|
||||
'Amount Paid',
|
||||
_formatAmount(
|
||||
_subscription?['plan']?['amount']),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Date
|
||||
_buildDetailRow(
|
||||
'Date',
|
||||
_formatDate(_subscription?['createdAt'] ??
|
||||
DateTime.now().toIso8601String()),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Next Renewal Date
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Next Renewal Date',
|
||||
style: TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 14,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_formatDate(
|
||||
_subscription?['currentPeriodEnd']),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.accentOrange,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Divider
|
||||
Divider(
|
||||
color:
|
||||
AppColors.primaryDark.withValues(alpha: 0.2)),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Go To Dashboard button
|
||||
SizedBox(
|
||||
width: 200,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => context.go('/'),
|
||||
icon: const Icon(Icons.dashboard,
|
||||
size: 18, color: Colors.white),
|
||||
label: const Text(
|
||||
'Go To Dashboard',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.accentOrange,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(15)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Download Receipt button
|
||||
SizedBox(
|
||||
width: 200,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
// TODO: implement receipt download
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content:
|
||||
Text('Receipt download coming soon')),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.download,
|
||||
size: 18, color: Colors.white),
|
||||
label: const Text(
|
||||
'Download Receipt',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.accentOrange,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(15)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Trust badges
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.verified_user,
|
||||
size: 18, color: Colors.green.shade600),
|
||||
const SizedBox(width: 6),
|
||||
const Text(
|
||||
'Verified User',
|
||||
style: TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 14,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
const Icon(Icons.lock,
|
||||
size: 18, color: AppColors.primaryDark),
|
||||
const SizedBox(width: 6),
|
||||
const Text(
|
||||
'Secure Checkout',
|
||||
style: TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 14,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailRow(String label, String value) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 14,
|
||||
color: AppColors.primaryDark.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 14,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ 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:url_launcher/url_launcher.dart';
|
||||
import 'package:real_estate_mobile/features/profile/presentation/screens/stripe_checkout_screen.dart';
|
||||
|
||||
class ProfileSettingsScreen extends ConsumerStatefulWidget {
|
||||
const ProfileSettingsScreen({super.key});
|
||||
@@ -1078,117 +1079,173 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(24, 10, 24, 30),
|
||||
children: [
|
||||
const Text(
|
||||
'Billings & Payments',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primaryDark,
|
||||
// Title section matching Figma
|
||||
const Center(
|
||||
child: Text(
|
||||
'Subscription & Payments',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Manage your subscription and billing details.',
|
||||
style: TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
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) ...[
|
||||
// Section title
|
||||
const Text(
|
||||
'Current Plan',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Active subscription card
|
||||
_buildSectionCard(
|
||||
title: 'Current Subscription',
|
||||
subtitle: 'Your active plan details',
|
||||
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: [
|
||||
// Annual Subscription badge
|
||||
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),
|
||||
Text(
|
||||
plan?['name'] as String? ?? 'Premium',
|
||||
style: const TextStyle(
|
||||
const Text(
|
||||
'Active',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primaryDark,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF4CAF50),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF4CAF50).withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
if (_subscription?['currentPeriodEnd'] != null)
|
||||
Text(
|
||||
'Renews ${_formatDate(_subscription!['currentPeriodEnd'] as String)}',
|
||||
style: TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 13,
|
||||
color: AppColors.primaryDark.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
child: const Text('Active',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF4CAF50))),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (plan?['amount'] != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'\$${plan!['amount']}/${plan['interval'] ?? 'year'}',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (_subscription?['currentPeriodEnd'] != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Renews on ${_formatDate(_subscription!['currentPeriodEnd'] as String)}',
|
||||
style: TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 13,
|
||||
color: AppColors.primaryDark.withValues(alpha: 0.6),
|
||||
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),
|
||||
// Plan features
|
||||
..._buildPlanFeatures(),
|
||||
const SizedBox(height: 20),
|
||||
// Manage Subscription
|
||||
// Manage Subscription button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
width: 155,
|
||||
child: ElevatedButton(
|
||||
onPressed: _openBillingPortal,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.primaryDark,
|
||||
side: BorderSide(
|
||||
color: AppColors.primaryDark.withValues(alpha: 0.2)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
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: 14,
|
||||
fontWeight: FontWeight.w600)),
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const SizedBox(height: 24),
|
||||
// Include Features
|
||||
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),
|
||||
// Cancel
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: _cancelSubscription,
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
@@ -1203,97 +1260,202 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
// No subscription — upgrade card
|
||||
_buildSectionCard(
|
||||
title: 'Get Premium Access',
|
||||
subtitle: 'Unlock all features with our premium plan',
|
||||
// Section title
|
||||
const Text(
|
||||
'Annual Membership Plans',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Plan card matching Figma
|
||||
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: [
|
||||
// Annual Subscription badge
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.accentOrange.withValues(alpha: 0.05),
|
||||
color: AppColors.accentOrange,
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
border: Border.all(
|
||||
color: AppColors.accentOrange.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Column(
|
||||
child: const Text(
|
||||
'Annual Subscription',
|
||||
style: TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// Professional Annual Plan
|
||||
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 agents and lenders to grow their business.',
|
||||
style: TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 14,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// Price
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.accentOrange,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Text('Best Value',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white)),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'\$499/Year',
|
||||
const TextSpan(
|
||||
text: '\$499',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 28,
|
||||
fontSize: 35,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
..._buildPlanFeatures(),
|
||||
const TextSpan(
|
||||
text: ' / ',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: 'Year',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primaryDark.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// Get Premium Access button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
width: 155,
|
||||
child: ElevatedButton(
|
||||
onPressed: _startCheckout,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.accentOrange,
|
||||
foregroundColor: AppColors.primaryDark,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(15)),
|
||||
),
|
||||
child: const Text('Get Premium',
|
||||
child: const Text('Get Premium Access',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700)),
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// Include Features
|
||||
const Text(
|
||||
'Include Features',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
..._buildPlanFeatures(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 24),
|
||||
// Need help section
|
||||
|
||||
// Need Help section matching Figma
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.symmetric(vertical: 30, horizontal: 20),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
border: Border.all(
|
||||
color: AppColors.primaryDark.withValues(alpha: 0.1)),
|
||||
color: AppColors.primaryDark.withValues(alpha: 0.7),
|
||||
width: 0.7,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.help_outline,
|
||||
color: AppColors.primaryDark.withValues(alpha: 0.6)),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Need help with billing? Contact support.',
|
||||
style: TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 13,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
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 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@re-quest.co',
|
||||
style: TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.accentOrange,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: AppColors.accentOrange,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Transform.rotate(
|
||||
angle: 0.7, // ~40 degrees
|
||||
child: const Icon(Icons.arrow_upward,
|
||||
size: 16, color: AppColors.accentOrange),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1312,12 +1474,21 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
|
||||
];
|
||||
return features
|
||||
.map((f) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.check_circle_outline,
|
||||
size: 18, color: AppColors.accentOrange),
|
||||
const SizedBox(width: 10),
|
||||
Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: AppColors.accentOrange, width: 1.5),
|
||||
),
|
||||
child: const Icon(Icons.check,
|
||||
size: 14, color: AppColors.accentOrange),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(f,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
@@ -1333,8 +1504,19 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
|
||||
try {
|
||||
final repo = ref.read(profileRepositoryProvider);
|
||||
final url = await repo.createPortalSession();
|
||||
if (await canLaunchUrl(Uri.parse(url))) {
|
||||
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
|
||||
if (!mounted) return;
|
||||
// Open billing portal in in-app browser
|
||||
await Navigator.of(context).push<Map<String, dynamic>?>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => StripeCheckoutScreen(
|
||||
url: url,
|
||||
title: 'Manage Subscription',
|
||||
),
|
||||
),
|
||||
);
|
||||
// Refresh subscription when user returns
|
||||
if (mounted) {
|
||||
_loadSubscription();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) _showSnackBar('Failed to open billing portal: $e', isError: true);
|
||||
@@ -1351,8 +1533,23 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
|
||||
}
|
||||
final planId = plans.first['id'] as String;
|
||||
final url = await repo.createCheckoutSession(planId);
|
||||
if (await canLaunchUrl(Uri.parse(url))) {
|
||||
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
|
||||
if (!mounted) return;
|
||||
// Open Stripe checkout in in-app browser
|
||||
final result = await Navigator.of(context).push<Map<String, dynamic>?>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => StripeCheckoutScreen(
|
||||
url: url,
|
||||
title: 'Checkout',
|
||||
successUrlPattern: 'confirmation',
|
||||
),
|
||||
),
|
||||
);
|
||||
// If checkout succeeded, navigate to payment success screen
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
import 'package:real_estate_mobile/core/constants/app_colors.dart';
|
||||
|
||||
/// In-app WebView for Stripe Checkout / Billing Portal.
|
||||
/// Pass [url] to load and [successUrlPattern] to detect completion.
|
||||
class StripeCheckoutScreen extends StatefulWidget {
|
||||
final String url;
|
||||
final String title;
|
||||
|
||||
/// URL pattern that indicates checkout success (e.g. your success redirect URL).
|
||||
final String? successUrlPattern;
|
||||
|
||||
const StripeCheckoutScreen({
|
||||
super.key,
|
||||
required this.url,
|
||||
this.title = 'Checkout',
|
||||
this.successUrlPattern,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StripeCheckoutScreen> createState() => _StripeCheckoutScreenState();
|
||||
}
|
||||
|
||||
class _StripeCheckoutScreenState extends State<StripeCheckoutScreen> {
|
||||
late final WebViewController _controller;
|
||||
bool _isLoading = true;
|
||||
double _progress = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onPageStarted: (_) {
|
||||
if (mounted) setState(() => _isLoading = true);
|
||||
},
|
||||
onPageFinished: (_) {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
},
|
||||
onProgress: (progress) {
|
||||
if (mounted) setState(() => _progress = progress / 100);
|
||||
},
|
||||
onNavigationRequest: (request) {
|
||||
// Detect success redirect
|
||||
if (widget.successUrlPattern != null &&
|
||||
request.url.contains(widget.successUrlPattern!)) {
|
||||
// Extract session_id if present
|
||||
final uri = Uri.parse(request.url);
|
||||
final sessionId = uri.queryParameters['session_id'];
|
||||
Navigator.of(context).pop({'success': true, 'sessionId': sessionId});
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
return NavigationDecision.navigate;
|
||||
},
|
||||
),
|
||||
)
|
||||
..loadRequest(Uri.parse(widget.url));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close, color: AppColors.primaryDark),
|
||||
onPressed: () => Navigator.of(context).pop(null),
|
||||
),
|
||||
title: Text(
|
||||
widget.title,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
bottom: _isLoading
|
||||
? PreferredSize(
|
||||
preferredSize: const Size.fromHeight(3),
|
||||
child: LinearProgressIndicator(
|
||||
value: _progress,
|
||||
backgroundColor: Colors.grey.shade200,
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(
|
||||
AppColors.accentOrange),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
body: WebViewWidget(controller: _controller),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user