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

@@ -84,6 +84,9 @@ PODS:
- FlutterMacOS
- url_launcher_ios (0.0.1):
- Flutter
- webview_flutter_wkwebview (0.0.1):
- Flutter
- FlutterMacOS
DEPENDENCIES:
- emoji_picker_flutter (from `.symlinks/plugins/emoji_picker_flutter/ios`)
@@ -97,6 +100,7 @@ DEPENDENCIES:
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
SPEC REPOS:
trunk:
@@ -133,6 +137,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
webview_flutter_wkwebview:
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
SPEC CHECKSUMS:
emoji_picker_flutter: 8e50ec5caac456a23a78637e02c6293ea0ac8771
@@ -155,6 +161,7 @@ SPEC CHECKSUMS:
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa
webview_flutter_wkwebview: 29eb20d43355b48fe7d07113835b9128f84e3af4
PODFILE CHECKSUM: f8c2dcdfb50bb67645580d28a6bf814fca30bdec

View File

@@ -1,47 +1,13 @@
import Flutter
import UIKit
import FirebaseCore
import FirebaseMessaging
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
FirebaseApp.configure()
// Request notification permissions
UNUserNotificationCenter.current().delegate = self
let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound]
UNUserNotificationCenter.current().requestAuthorization(options: authOptions) { _, _ in }
application.registerForRemoteNotifications()
Messaging.messaging().delegate = self
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
override func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
Messaging.messaging().apnsToken = deviceToken
super.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken)
}
}
extension AppDelegate: MessagingDelegate {
func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
let dataDict: [String: String] = ["token": fcmToken ?? ""]
NotificationCenter.default.post(
name: Notification.Name("FCMToken"),
object: nil,
userInfo: dataDict
)
}
}

View File

@@ -9,7 +9,7 @@
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>com.requestn.realEstateMobile.dev</string>
<string>com.requestn.realEstateMobile.local</string>
<key>PROJECT_ID</key>
<string>real-estate-2d71e</string>
<key>STORAGE_BUCKET</key>
@@ -25,6 +25,6 @@
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>1:703616926518:ios:301cac94994fbdc3fca997</string>
<string>1:703616926518:ios:15652d299f9f2d7ffca997</string>
</dict>
</plist>

View File

@@ -0,0 +1,48 @@
import 'package:firebase_core/firebase_core.dart';
import 'package:real_estate_mobile/config/app_config.dart';
class FirebaseConfig {
static FirebaseOptions get currentOptions {
switch (AppConfig.flavor) {
case Flavor.dev:
return _dev;
case Flavor.demo:
return _demo;
case Flavor.local:
return _local;
}
}
// ── Dev ──
static const _dev = FirebaseOptions(
apiKey: 'AIzaSyAwb_9fc7n9gbN_IQqWeYRkHSSykzVHCyc', // Android
appId: '1:703616926518:android:4342b796fbabb690fca997',
messagingSenderId: '703616926518',
projectId: 'real-estate-2d71e',
storageBucket: 'real-estate-2d71e.firebasestorage.app',
iosBundleId: 'com.requestn.realEstateMobile.dev',
iosClientId: null,
);
// ── Demo ──
static const _demo = FirebaseOptions(
apiKey: 'AIzaSyAwb_9fc7n9gbN_IQqWeYRkHSSykzVHCyc',
appId: '1:703616926518:android:270639d6887d2cb4fca997',
messagingSenderId: '703616926518',
projectId: 'real-estate-2d71e',
storageBucket: 'real-estate-2d71e.firebasestorage.app',
iosBundleId: 'com.requestn.realEstateMobile.demo',
iosClientId: null,
);
// ── Local ──
static const _local = FirebaseOptions(
apiKey: 'AIzaSyAwb_9fc7n9gbN_IQqWeYRkHSSykzVHCyc',
appId: '1:703616926518:android:2eb242f07b174c25fca997',
messagingSenderId: '703616926518',
projectId: 'real-estate-2d71e',
storageBucket: 'real-estate-2d71e.firebasestorage.app',
iosBundleId: 'com.requestn.realEstateMobile.local',
iosClientId: null,
);
}

View File

@@ -19,9 +19,8 @@ class PushNotificationService {
factory PushNotificationService() => _instance;
PushNotificationService._internal();
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
final FlutterLocalNotificationsPlugin _localNotifications =
FlutterLocalNotificationsPlugin();
FirebaseMessaging? _messaging;
FlutterLocalNotificationsPlugin? _localNotifications;
final NotificationRepository _repo = NotificationRepository();
String? _currentToken;
@@ -31,15 +30,26 @@ class PushNotificationService {
void Function(String? actionUrl)? onNotificationTap;
/// Initialize Firebase Messaging and local notifications.
/// Skipped on iOS until Firebase iOS app is configured.
Future<void> initialize() async {
if (_initialized) return;
// Skip on iOS — no Firebase iOS app configured yet
if (Platform.isIOS) {
debugPrint('[FCM] Skipped on iOS — no Firebase iOS app configured');
return;
}
_initialized = true;
_messaging = FirebaseMessaging.instance;
_localNotifications = FlutterLocalNotificationsPlugin();
// Register background handler
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
// Request permission
final settings = await _messaging.requestPermission(
final settings = await _messaging!.requestPermission(
alert: true,
badge: true,
sound: true,
@@ -58,7 +68,7 @@ class PushNotificationService {
await _getAndRegisterToken();
// Listen for token refresh
_messaging.onTokenRefresh.listen(_onTokenRefresh);
_messaging!.onTokenRefresh.listen(_onTokenRefresh);
// Handle foreground messages
FirebaseMessaging.onMessage.listen(_onForegroundMessage);
@@ -67,14 +77,14 @@ class PushNotificationService {
FirebaseMessaging.onMessageOpenedApp.listen(_onMessageOpenedApp);
// Check if app was opened from a terminated state notification
final initialMessage = await _messaging.getInitialMessage();
final initialMessage = await _messaging!.getInitialMessage();
if (initialMessage != null) {
_onMessageOpenedApp(initialMessage);
}
// iOS: set foreground notification presentation options
if (Platform.isIOS) {
await _messaging.setForegroundNotificationPresentationOptions(
await _messaging!.setForegroundNotificationPresentationOptions(
alert: true,
badge: true,
sound: true,
@@ -90,7 +100,7 @@ class PushNotificationService {
importance: Importance.high,
);
await _localNotifications
await _localNotifications!
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(androidChannel);
@@ -103,7 +113,7 @@ class PushNotificationService {
requestSoundPermission: false,
);
await _localNotifications.initialize(
await _localNotifications!.initialize(
const InitializationSettings(
android: androidSettings,
iOS: iosSettings,
@@ -117,7 +127,7 @@ class PushNotificationService {
Future<void> _getAndRegisterToken() async {
try {
final token = await _messaging.getToken();
final token = await _messaging!.getToken();
if (token != null) {
_currentToken = token;
debugPrint('[FCM] Token: $token');
@@ -151,7 +161,7 @@ class PushNotificationService {
// Show local notification on Android (iOS handles it natively)
if (Platform.isAndroid) {
_localNotifications.show(
_localNotifications?.show(
notification.hashCode,
notification.title,
notification.body,
@@ -178,7 +188,7 @@ class PushNotificationService {
/// Unregister FCM token from backend (call on logout).
Future<void> unregister() async {
if (_currentToken == null) return;
if (Platform.isIOS || _currentToken == null) return;
try {
await _repo.removeFcmToken(_currentToken!);
debugPrint('[FCM] Token unregistered from backend');
@@ -190,6 +200,7 @@ class PushNotificationService {
/// Re-register token after login.
Future<void> registerAfterLogin() async {
if (Platform.isIOS) return;
await _getAndRegisterToken();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ import 'package:real_estate_mobile/core/widgets/s3_image.dart';
import 'package:real_estate_mobile/features/agents/data/models/agent_profile.dart';
import 'package:real_estate_mobile/features/agents/presentation/providers/agent_detail_provider.dart';
import 'package:real_estate_mobile/features/profile/data/profile_repository.dart';
import 'package:go_router/go_router.dart';
/// Provider that fetches the agent's own profile ID from /agents/profile/me
final _agentMyProfileProvider =
@@ -578,7 +579,7 @@ class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
const SizedBox(width: 8),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {},
onTap: () => context.push('/agent/edit-profile'),
child: Padding(
padding: const EdgeInsets.all(4),
child: SvgPicture.asset(

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

View File

@@ -2,13 +2,21 @@ import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:real_estate_mobile/config/app_config.dart';
import 'package:real_estate_mobile/config/firebase_config.dart';
import 'package:real_estate_mobile/core/theme/app_theme.dart';
import 'package:real_estate_mobile/routing/app_router.dart';
Future<void> mainCommon(Flavor flavor) async {
WidgetsFlutterBinding.ensureInitialized();
await AppConfig.init(flavor);
await Firebase.initializeApp();
// Initialize Firebase — iOS may fail if no iOS app configured in Firebase Console
try {
await Firebase.initializeApp(options: FirebaseConfig.currentOptions);
} catch (e) {
debugPrint('[Firebase] Init failed (expected on iOS without config): $e'); // ignore: avoid_print
}
runApp(const ProviderScope(child: MyApp()));
}

View File

@@ -18,6 +18,8 @@ import 'package:real_estate_mobile/features/about/presentation/screens/about_scr
import 'package:real_estate_mobile/features/agents/presentation/screens/agent_network_screen.dart';
import 'package:real_estate_mobile/features/profile/presentation/screens/profile_settings_screen.dart';
import 'package:real_estate_mobile/features/notifications/presentation/screens/notifications_screen.dart';
import 'package:real_estate_mobile/features/agents/presentation/screens/agent_edit_profile_screen.dart';
import 'package:real_estate_mobile/features/profile/presentation/screens/payment_success_screen.dart';
final routerProvider = Provider<GoRouter>((ref) {
final authRefreshNotifier = _AuthRefreshNotifier();
@@ -166,6 +168,14 @@ final routerProvider = Provider<GoRouter>((ref) {
transitionsBuilder: _fadeTransition,
),
),
GoRoute(
path: '/agent/edit-profile',
pageBuilder: (context, state) => CustomTransitionPage(
key: state.pageKey,
child: const AgentEditProfileScreen(),
transitionsBuilder: _fadeTransition,
),
),
GoRoute(
path: '/notifications',
pageBuilder: (context, state) => CustomTransitionPage(
@@ -174,6 +184,17 @@ final routerProvider = Provider<GoRouter>((ref) {
transitionsBuilder: _fadeTransition,
),
),
GoRoute(
path: '/payment-success',
pageBuilder: (context, state) {
final sessionId = state.uri.queryParameters['session_id'];
return CustomTransitionPage(
key: state.pageKey,
child: PaymentSuccessScreen(sessionId: sessionId),
transitionsBuilder: _fadeTransition,
);
},
),
],
),
],

View File

@@ -15,6 +15,7 @@ import path_provider_foundation
import shared_preferences_foundation
import sqflite_darwin
import url_launcher_macos
import webview_flutter_wkwebview
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
EmojiPickerFlutterPlugin.register(with: registry.registrar(forPlugin: "EmojiPickerFlutterPlugin"))
@@ -27,4 +28,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin"))
}

View File

@@ -1389,6 +1389,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.3"
webview_flutter:
dependency: "direct main"
description:
name: webview_flutter
sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9
url: "https://pub.dev"
source: hosted
version: "4.13.1"
webview_flutter_android:
dependency: transitive
description:
name: webview_flutter_android
sha256: "2a03df01df2fd30b075d1e7f24c28aee593f2e5d5ac4c3c4283c5eda63717b24"
url: "https://pub.dev"
source: hosted
version: "4.10.13"
webview_flutter_platform_interface:
dependency: transitive
description:
name: webview_flutter_platform_interface
sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0"
url: "https://pub.dev"
source: hosted
version: "2.14.0"
webview_flutter_wkwebview:
dependency: transitive
description:
name: webview_flutter_wkwebview
sha256: fc0af89d403e1c053f03d023d97550412fa79f35332e2939514c82e6fe633198
url: "https://pub.dev"
source: hosted
version: "3.23.8"
win32:
dependency: transitive
description:

View File

@@ -56,6 +56,7 @@ dependencies:
logger: ^2.5.0
flutter_dotenv: ^5.2.1
image_picker: ^1.2.1
webview_flutter: ^4.13.1
dev_dependencies:
flutter_test: