feat: implement Stripe checkout flow, update profile settings UI for subscriptions, and add agent profile editing.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
48
lib/config/firebase_config.dart
Normal file
48
lib/config/firebase_config.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
32
pubspec.lock
32
pubspec.lock
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user