feat: implement Stripe checkout flow, update profile settings UI for subscriptions, and add agent profile editing.

This commit is contained in:
pradeepkumar
2026-03-09 06:04:54 +05:30
parent e129be5a59
commit 0102617dc7
16 changed files with 2597 additions and 183 deletions

View File

@@ -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 {

View File

@@ -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,
),
),
],
);
}
}

View File

@@ -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);

View File

@@ -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),
);
}
}