From 0102617dc738ab0367a9218711693a8609993eba Mon Sep 17 00:00:00 2001 From: pradeepkumar Date: Mon, 9 Mar 2026 06:04:54 +0530 Subject: [PATCH] feat: implement Stripe checkout flow, update profile settings UI for subscriptions, and add agent profile editing. --- ios/Podfile.lock | 7 + ios/Runner/AppDelegate.swift | 38 +- ios/Runner/GoogleService-Info.plist | 4 +- lib/config/firebase_config.dart | 48 + .../services/push_notification_service.dart | 35 +- .../screens/agent_edit_profile_screen.dart | 1599 +++++++++++++++++ .../screens/agent_home_screen.dart | 3 +- .../profile/data/profile_repository.dart | 47 + .../screens/payment_success_screen.dart | 375 ++++ .../screens/profile_settings_screen.dart | 459 +++-- .../screens/stripe_checkout_screen.dart | 99 + lib/main.dart | 10 +- lib/routing/app_router.dart | 21 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 32 + pubspec.yaml | 1 + 16 files changed, 2597 insertions(+), 183 deletions(-) create mode 100644 lib/config/firebase_config.dart create mode 100644 lib/features/agents/presentation/screens/agent_edit_profile_screen.dart create mode 100644 lib/features/profile/presentation/screens/payment_success_screen.dart create mode 100644 lib/features/profile/presentation/screens/stripe_checkout_screen.dart diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 0112cdf..0acd3e5 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -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 diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 60775fd..6266644 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -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 - ) - } } diff --git a/ios/Runner/GoogleService-Info.plist b/ios/Runner/GoogleService-Info.plist index fcf5ca2..4421dcb 100644 --- a/ios/Runner/GoogleService-Info.plist +++ b/ios/Runner/GoogleService-Info.plist @@ -9,7 +9,7 @@ PLIST_VERSION 1 BUNDLE_ID - com.requestn.realEstateMobile.dev + com.requestn.realEstateMobile.local PROJECT_ID real-estate-2d71e STORAGE_BUCKET @@ -25,6 +25,6 @@ IS_SIGNIN_ENABLED GOOGLE_APP_ID - 1:703616926518:ios:301cac94994fbdc3fca997 + 1:703616926518:ios:15652d299f9f2d7ffca997 \ No newline at end of file diff --git a/lib/config/firebase_config.dart b/lib/config/firebase_config.dart new file mode 100644 index 0000000..7f787a1 --- /dev/null +++ b/lib/config/firebase_config.dart @@ -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, + ); +} diff --git a/lib/core/services/push_notification_service.dart b/lib/core/services/push_notification_service.dart index 5cb25f3..be2280a 100644 --- a/lib/core/services/push_notification_service.dart +++ b/lib/core/services/push_notification_service.dart @@ -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 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 _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 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 registerAfterLogin() async { + if (Platform.isIOS) return; await _getAndRegisterToken(); } } diff --git a/lib/features/agents/presentation/screens/agent_edit_profile_screen.dart b/lib/features/agents/presentation/screens/agent_edit_profile_screen.dart new file mode 100644 index 0000000..6229aff --- /dev/null +++ b/lib/features/agents/presentation/screens/agent_edit_profile_screen.dart @@ -0,0 +1,1599 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.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'; + +// --------------------------------------------------------------------------- +// Data loading provider +// --------------------------------------------------------------------------- + +/// Bundles all three API calls needed to render the edit-profile form. +final _editProfileDataProvider = + FutureProvider.autoDispose<_EditProfileData>((ref) async { + final repo = ProfileRepository(); + + // 1. Fetch agent profile (need agentTypeId + pre-fill values) + final profile = await repo.getMyProfile('AGENT'); + + // 2. Resolve agentTypeId + final agentTypeId = profile['agentTypeId'] as String? ?? + (profile['agentType'] as Map?)?['id'] as String?; + if (agentTypeId == null || agentTypeId.isEmpty) { + throw Exception('Agent type not found on profile'); + } + + // 3 + 4. Fetch sections & field-values in parallel + final results = await Future.wait([ + repo.getSectionsForAgentType(agentTypeId), + repo.getFieldValues(), + ]); + + // getSectionsForAgentType returns { agentType: {...}, sections: [...] } + final sectionsResponse = results[0]; + final sections = (sectionsResponse['sections'] as List?) ?? []; + + // getFieldValues returns { agentProfileId: "...", fieldValues: [...] } + final fieldValuesResponse = results[1]; + final fieldValues = + (fieldValuesResponse['fieldValues'] as List?) ?? []; + + return _EditProfileData( + profile: profile, + sections: sections, + fieldValues: fieldValues, + ); +}); + +class _EditProfileData { + final Map profile; + final List sections; + final List fieldValues; + const _EditProfileData({ + required this.profile, + required this.sections, + required this.fieldValues, + }); +} + +// --------------------------------------------------------------------------- +// Screen +// --------------------------------------------------------------------------- + +class AgentEditProfileScreen extends ConsumerStatefulWidget { + const AgentEditProfileScreen({super.key}); + + @override + ConsumerState createState() => + _AgentEditProfileScreenState(); +} + +class _AgentEditProfileScreenState + extends ConsumerState { + /// Master form data keyed by field slug. + final Map _formData = {}; + + /// Repeatable section entries keyed by section slug. + final Map>> _repeatableEntries = {}; + + /// Tag-input controllers keyed by field slug. + final Map _tagControllers = {}; + + /// All text controllers for cleanup. + final List _controllers = []; + + bool _saving = false; + bool _initialized = false; + + final _formKey = GlobalKey(); + + // Pre-fill slug mapping to profile keys + static const _profileSlugMap = { + 'first-name': 'firstName', + 'first_name': 'firstName', + 'last-name': 'lastName', + 'last_name': 'lastName', + 'email': 'email', + 'bio': 'bio', + 'license-number': 'licenseNumber', + 'license_number': 'licenseNumber', + 'experience': 'experience', + 'specializations': 'specializations', + 'languages': 'languages', + 'service-areas': 'serviceAreas', + 'service_areas': 'serviceAreas', + }; + + @override + void dispose() { + for (final c in _controllers) { + c.dispose(); + } + for (final c in _tagControllers.values) { + c.dispose(); + } + super.dispose(); + } + + // --------------------------------------------------------------------------- + // Initialization + // --------------------------------------------------------------------------- + + void _initializeFormData(_EditProfileData data) { + if (_initialized) return; + _initialized = true; + + final profile = data.profile; + + // Build field-value lookup by slug + final fvMap = {}; + for (final fv in data.fieldValues) { + if (fv is Map) { + final slug = fv['fieldSlug'] as String? ?? ''; + if (slug.isNotEmpty) { + fvMap[slug] = fv['value']; + } + } + } + + // Walk sections & fields + for (final section in data.sections) { + if (section is! Map) continue; + final isRepeatable = section['isRepeatable'] == true; + final sectionSlug = section['slug'] as String? ?? ''; + final fields = section['fields'] as List? ?? []; + + if (isRepeatable) { + // Load existing repeatable entries from field values + final entriesKey = '${sectionSlug}_entries'; + final existingEntries = fvMap[entriesKey]; + if (existingEntries is List && existingEntries.isNotEmpty) { + _repeatableEntries[sectionSlug] = existingEntries + .map((e) => e is Map + ? Map.from(e) + : {}) + .toList(); + } else { + _repeatableEntries[sectionSlug] = [{}]; + } + } + + for (final field in fields) { + if (field is! Map) continue; + final slug = field['slug'] as String? ?? ''; + final fieldType = field['fieldType'] as String? ?? 'TEXT'; + if (slug.isEmpty) continue; + + // Priority: fieldValues API > profile pre-fill > defaultValue + dynamic value; + + // 1. Field values from API + if (fvMap.containsKey(slug)) { + value = fvMap[slug]; + } + // 2. Profile pre-fill + else if (_profileSlugMap.containsKey(slug)) { + value = profile[_profileSlugMap[slug]!]; + } + // 3. Default value + else { + value = field['defaultValue']; + } + + // Normalize value based on field type + if (!isRepeatable) { + _formData[slug] = _normalizeValue(value, fieldType, field); + } + } + } + } + + dynamic _normalizeValue( + dynamic value, String fieldType, Map field) { + switch (fieldType) { + case 'CHECKBOX': + if (value is bool) return value; + if (value is String) return value.toLowerCase() == 'true'; + return false; + case 'CHECKBOX_GROUP': + case 'MULTI_SELECT': + case 'TAG_INPUT': + if (value is List) return List.from(value.map((e) => '$e')); + if (value is String && value.isNotEmpty) { + try { + final decoded = jsonDecode(value); + if (decoded is List) { + return List.from(decoded.map((e) => '$e')); + } + } catch (_) {} + return [value]; + } + return []; + case 'NUMBER': + if (value is num) return value; + if (value is String) return num.tryParse(value); + return null; + case 'RANGE': + if (value is Map) return value; + if (value is String && value.isNotEmpty) { + try { + return jsonDecode(value); + } catch (_) {} + } + final rangeConfig = field['rangeConfig'] as Map?; + final min = (rangeConfig?['min'] as num?)?.toDouble() ?? 0; + final max = (rangeConfig?['max'] as num?)?.toDouble() ?? 100; + return {'min': min, 'max': max}; + default: + return value?.toString() ?? ''; + } + } + + // --------------------------------------------------------------------------- + // Save + // --------------------------------------------------------------------------- + + Future _save(_EditProfileData data) async { + if (!(_formKey.currentState?.validate() ?? false)) return; + + setState(() => _saving = true); + + try { + final repo = ProfileRepository(); + final fieldValuesToSave = >[]; + + // Collect all form data + for (final entry in _formData.entries) { + dynamic val = entry.value; + // Convert lists to JSON-compatible format + if (val is List) { + val = val; + } else if (val is Map) { + val = val; + } + fieldValuesToSave.add({ + 'fieldSlug': entry.key, + 'value': val, + }); + } + + // Add repeatable section entries + for (final entry in _repeatableEntries.entries) { + fieldValuesToSave.add({ + 'fieldSlug': '${entry.key}_entries', + 'value': entry.value, + }); + } + + // Save field values + await repo.saveFieldValues(fieldValuesToSave); + + // Sync phone to main profile if present + const phoneSlugs = [ + 'phone_number', + 'phone-number', + 'cell_number', + 'cell-number', + 'office_number', + 'office-number', + 'phone', + ]; + for (final slug in phoneSlugs) { + if (_formData.containsKey(slug)) { + final phoneVal = _formData[slug]; + if (phoneVal is String && phoneVal.isNotEmpty) { + await repo.updateProfile('AGENT', {'phone': phoneVal}); + break; + } + } + } + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Profile updated successfully'), + backgroundColor: AppColors.success, + ), + ); + context.pop(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to save: $e'), + backgroundColor: AppColors.error, + ), + ); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + // --------------------------------------------------------------------------- + // Build + // --------------------------------------------------------------------------- + + @override + Widget build(BuildContext context) { + final asyncData = ref.watch(_editProfileDataProvider); + + return Scaffold( + backgroundColor: const Color(0xFFF5F7FA), + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: AppColors.primaryDark), + onPressed: () => context.pop(), + ), + title: const Text( + 'Edit Profile', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 18, + fontWeight: FontWeight.w600, + color: AppColors.primaryDark, + ), + ), + centerTitle: true, + ), + body: asyncData.when( + loading: () => const Center( + child: CircularProgressIndicator( + color: AppColors.accentOrange, + strokeWidth: 2, + ), + ), + error: (err, _) => Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, + size: 48, color: AppColors.error), + const SizedBox(height: 16), + Text( + 'Failed to load profile data', + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 8), + Text( + '$err', + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + color: AppColors.hintText, + ), + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () => + ref.invalidate(_editProfileDataProvider), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.accentOrange, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15), + ), + ), + child: const Text('Retry', + style: TextStyle(color: Colors.white)), + ), + ], + ), + ), + ), + data: (data) { + _initializeFormData(data); + return _buildForm(data); + }, + ), + ); + } + + Widget _buildForm(_EditProfileData data) { + final sections = data.sections + .whereType>() + .where((s) => s['isActive'] != false) + .toList() + ..sort((a, b) => + ((a['sortOrder'] as num?) ?? 0).compareTo((b['sortOrder'] as num?) ?? 0)); + + return Form( + key: _formKey, + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + child: Column( + children: [ + for (final section in sections) ...[ + _buildSection(section), + const SizedBox(height: 16), + ], + const SizedBox(height: 24), + ], + ), + ), + ), + _buildBottomButtons(data), + ], + ), + ); + } + + // --------------------------------------------------------------------------- + // Bottom buttons + // --------------------------------------------------------------------------- + + Widget _buildBottomButtons(_EditProfileData data) { + return Container( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, -2), + ), + ], + ), + child: Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _saving ? null : () => context.pop(), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: AppColors.primaryDark), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15), + ), + padding: const EdgeInsets.symmetric(vertical: 14), + ), + child: const Text( + 'Cancel', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.primaryDark, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + onPressed: _saving ? null : () => _save(data), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.accentOrange, + disabledBackgroundColor: AppColors.accentOrange.withValues(alpha: 0.5), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15), + ), + padding: const EdgeInsets.symmetric(vertical: 14), + ), + child: _saving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text( + 'Save', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ); + } + + // --------------------------------------------------------------------------- + // Section card + // --------------------------------------------------------------------------- + + Widget _buildSection(Map section) { + final name = section['name'] as String? ?? ''; + final iconName = section['icon'] as String?; + final isRepeatable = section['isRepeatable'] == true; + final sectionSlug = section['slug'] as String? ?? ''; + final fields = (section['fields'] as List? ?? []) + .whereType>() + .where((f) => f['isActive'] != false && f['isSearchableOnly'] != true) + .toList() + ..sort((a, b) => ((a['sortOrder'] as num?) ?? 0) + .compareTo((b['sortOrder'] as num?) ?? 0)); + + if (fields.isEmpty) return const SizedBox.shrink(); + + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(15), + border: Border.all(color: const Color(0xFFE5E7EB)), + boxShadow: const [ + BoxShadow( + color: Color(0x80D9D9D9), + blurRadius: 20, + offset: Offset(0, 10), + ), + ], + ), + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + if (iconName != null && iconName.isNotEmpty) ...[ + Icon( + _resolveIcon(iconName), + size: 20, + color: AppColors.accentOrange, + ), + const SizedBox(width: 8), + ], + Expanded( + child: Text( + name, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 15, + fontWeight: FontWeight.w500, + color: AppColors.primaryDark, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + + // Fields + if (isRepeatable) + _buildRepeatableFields(sectionSlug, fields) + else + ...fields.map((f) => _buildFieldWidget(f)), + ], + ), + ); + } + + // --------------------------------------------------------------------------- + // Repeatable section + // --------------------------------------------------------------------------- + + Widget _buildRepeatableFields( + String sectionSlug, List> fields) { + final entries = _repeatableEntries[sectionSlug] ?? [{}]; + + return Column( + children: [ + for (int i = 0; i < entries.length; i++) ...[ + if (i > 0) + const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Divider(height: 1, color: Color(0xFFE5E7EB)), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Entry ${i + 1}', + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.hintText, + ), + ), + if (entries.length > 1) + GestureDetector( + onTap: () { + setState(() { + entries.removeAt(i); + }); + }, + child: const Icon(Icons.remove_circle_outline, + size: 20, color: AppColors.error), + ), + ], + ), + const SizedBox(height: 8), + ...fields.map((f) { + final slug = f['slug'] as String? ?? ''; + return _buildFieldWidget( + f, + getValue: () => entries[i][slug], + setValue: (val) { + setState(() { + entries[i][slug] = val; + }); + }, + ); + }), + ], + const SizedBox(height: 12), + GestureDetector( + onTap: () { + setState(() { + entries.add({}); + }); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + Icon(Icons.add_circle_outline, + size: 20, color: AppColors.accentOrange), + SizedBox(width: 6), + Text( + 'Add Entry', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.accentOrange, + ), + ), + ], + ), + ), + ], + ); + } + + // --------------------------------------------------------------------------- + // Field dispatcher + // --------------------------------------------------------------------------- + + Widget _buildFieldWidget( + Map field, { + dynamic Function()? getValue, + void Function(dynamic)? setValue, + }) { + final slug = field['slug'] as String? ?? ''; + final fieldType = field['fieldType'] as String? ?? 'TEXT'; + final label = field['name'] as String? ?? ''; + final placeholder = field['placeholder'] as String? ?? ''; + final description = field['description'] as String?; + final isRequired = field['isRequired'] == true; + final validation = field['validation'] as Map?; + final options = (field['options'] as List?) + ?.whereType>() + .toList() ?? + []; + final uiConfig = field['uiConfig'] as Map?; + final helpText = uiConfig?['helpText'] as String?; + + // Getter / setter — either use repeatable entry or global formData + dynamic currentValue() => getValue != null ? getValue() : _formData[slug]; + void updateValue(dynamic val) { + if (setValue != null) { + setValue(val); + } else { + setState(() { + _formData[slug] = val; + }); + } + } + + Widget fieldWidget; + + switch (fieldType) { + case 'TEXT': + fieldWidget = + _buildTextField(slug, currentValue, updateValue, placeholder, validation, false); + break; + case 'TEXTAREA': + fieldWidget = + _buildTextField(slug, currentValue, updateValue, placeholder, validation, true); + break; + case 'NUMBER': + fieldWidget = _buildNumberField( + slug, currentValue, updateValue, placeholder, validation); + break; + case 'DATE': + fieldWidget = _buildDateField(slug, currentValue, updateValue, placeholder); + break; + case 'CHECKBOX': + fieldWidget = _buildCheckboxField(slug, currentValue, updateValue, label); + // Checkbox renders its own label, so skip the standard label wrapper. + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: fieldWidget, + ); + case 'CHECKBOX_GROUP': + fieldWidget = _buildCheckboxGroup(slug, currentValue, updateValue, options, uiConfig); + break; + case 'RADIO': + fieldWidget = _buildRadioGroup(slug, currentValue, updateValue, options); + break; + case 'SELECT': + fieldWidget = + _buildSelectField(slug, currentValue, updateValue, placeholder, options); + break; + case 'MULTI_SELECT': + fieldWidget = + _buildMultiSelect(slug, currentValue, updateValue, options); + break; + case 'TAG_INPUT': + fieldWidget = _buildTagInput(slug, currentValue, updateValue, placeholder); + break; + case 'RANGE': + fieldWidget = _buildRangeField(slug, currentValue, updateValue, field); + break; + case 'FILE_UPLOAD': + fieldWidget = _buildFileUpload(slug, currentValue, updateValue); + break; + case 'REPEATER': + return const SizedBox.shrink(); + default: + fieldWidget = + _buildTextField(slug, currentValue, updateValue, placeholder, validation, false); + } + + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Label + RichText( + text: TextSpan( + text: label, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.primaryDark, + ), + children: isRequired + ? [ + const TextSpan( + text: ' *', + style: TextStyle(color: AppColors.error), + ), + ] + : null, + ), + ), + if (description != null && description.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + description, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 12, + color: AppColors.hintText, + ), + ), + ], + if (helpText != null && helpText.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + helpText, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 12, + color: AppColors.hintText, + ), + ), + ], + const SizedBox(height: 6), + fieldWidget, + ], + ), + ); + } + + // --------------------------------------------------------------------------- + // TEXT / TEXTAREA + // --------------------------------------------------------------------------- + + Widget _buildTextField( + String slug, + dynamic Function() getValue, + void Function(dynamic) setValue, + String placeholder, + Map? validation, + bool multiline, + ) { + final key = '${slug}_text'; + if (!_tagControllers.containsKey(key)) { + final controller = TextEditingController(text: '${getValue() ?? ''}'); + _tagControllers[key] = controller; + _controllers.add(controller); + } + final controller = _tagControllers[key]!; + + final maxLength = validation?['maxLength'] as int?; + + return TextFormField( + controller: controller, + maxLines: multiline ? 5 : 1, + minLines: multiline ? 3 : 1, + maxLength: maxLength != null && maxLength > 0 ? maxLength : null, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w300, + color: AppColors.primaryDark, + ), + decoration: _inputDecoration(placeholder), + onChanged: (val) => setValue(val), + ); + } + + // --------------------------------------------------------------------------- + // NUMBER + // --------------------------------------------------------------------------- + + Widget _buildNumberField( + String slug, + dynamic Function() getValue, + void Function(dynamic) setValue, + String placeholder, + Map? validation, + ) { + final key = '${slug}_number'; + if (!_tagControllers.containsKey(key)) { + final val = getValue(); + final controller = + TextEditingController(text: val != null ? '$val' : ''); + _tagControllers[key] = controller; + _controllers.add(controller); + } + final controller = _tagControllers[key]!; + + return TextFormField( + controller: controller, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9.]')), + ], + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w300, + color: AppColors.primaryDark, + ), + decoration: _inputDecoration(placeholder), + onChanged: (val) => setValue(num.tryParse(val)), + ); + } + + // --------------------------------------------------------------------------- + // DATE + // --------------------------------------------------------------------------- + + Widget _buildDateField( + String slug, + dynamic Function() getValue, + void Function(dynamic) setValue, + String placeholder, + ) { + final val = getValue(); + final displayText = val is String && val.isNotEmpty ? val : placeholder; + + return GestureDetector( + onTap: () async { + DateTime initial = DateTime.now(); + if (val is String && val.isNotEmpty) { + try { + initial = DateTime.parse(val); + } catch (_) {} + } + final picked = await showDatePicker( + context: context, + initialDate: initial, + firstDate: DateTime(1900), + lastDate: DateTime(2100), + builder: (ctx, child) => Theme( + data: Theme.of(ctx).copyWith( + colorScheme: const ColorScheme.light( + primary: AppColors.accentOrange, + onPrimary: Colors.white, + surface: Colors.white, + onSurface: AppColors.primaryDark, + ), + ), + child: child!, + ), + ); + if (picked != null) { + setValue(DateFormat('yyyy-MM-dd').format(picked)); + } + }, + child: Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFE5E7EB)), + borderRadius: BorderRadius.circular(15), + ), + child: Row( + children: [ + Expanded( + child: Text( + displayText.isEmpty ? 'Select date' : displayText, + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w300, + color: val is String && val.isNotEmpty + ? AppColors.primaryDark + : AppColors.hintText, + ), + ), + ), + const Icon(Icons.calendar_today, + size: 18, color: AppColors.hintText), + ], + ), + ), + ); + } + + // --------------------------------------------------------------------------- + // CHECKBOX (single toggle) + // --------------------------------------------------------------------------- + + Widget _buildCheckboxField( + String slug, + dynamic Function() getValue, + void Function(dynamic) setValue, + String label, + ) { + final checked = getValue() == true; + return GestureDetector( + onTap: () => setValue(!checked), + child: Row( + children: [ + Container( + width: 17, + height: 17, + decoration: BoxDecoration( + color: checked ? AppColors.accentOrange : Colors.white, + borderRadius: BorderRadius.circular(5), + border: Border.all( + color: checked ? AppColors.accentOrange : const Color(0xFFD1D5DB), + ), + ), + child: checked + ? const Icon(Icons.check, size: 13, color: Colors.white) + : null, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + label, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.primaryDark, + ), + ), + ), + ], + ), + ); + } + + // --------------------------------------------------------------------------- + // CHECKBOX_GROUP + // --------------------------------------------------------------------------- + + Widget _buildCheckboxGroup( + String slug, + dynamic Function() getValue, + void Function(dynamic) setValue, + List> options, + Map? uiConfig, + ) { + final selected = List.from((getValue() ?? []) as List); + final columns = (uiConfig?['columns'] as num?)?.toInt() ?? 2; + + return Wrap( + spacing: 8, + runSpacing: 8, + children: options.map((opt) { + final optVal = opt['value'] as String? ?? ''; + final optLabel = opt['label'] as String? ?? optVal; + final isChecked = selected.contains(optVal); + + return GestureDetector( + onTap: () { + final newList = List.from(selected); + if (isChecked) { + newList.remove(optVal); + } else { + newList.add(optVal); + } + setValue(newList); + }, + child: SizedBox( + width: columns > 1 + ? (MediaQuery.of(context).size.width - 72) / columns + : null, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 17, + height: 17, + decoration: BoxDecoration( + color: + isChecked ? AppColors.accentOrange : Colors.white, + borderRadius: BorderRadius.circular(5), + border: Border.all( + color: isChecked + ? AppColors.accentOrange + : const Color(0xFFD1D5DB), + ), + ), + child: isChecked + ? const Icon(Icons.check, + size: 13, color: Colors.white) + : null, + ), + const SizedBox(width: 8), + Flexible( + child: Text( + optLabel, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 13, + fontWeight: FontWeight.w300, + color: AppColors.primaryDark, + ), + ), + ), + ], + ), + ), + ); + }).toList(), + ); + } + + // --------------------------------------------------------------------------- + // RADIO + // --------------------------------------------------------------------------- + + Widget _buildRadioGroup( + String slug, + dynamic Function() getValue, + void Function(dynamic) setValue, + List> options, + ) { + final currentVal = '${getValue() ?? ''}'; + return Column( + children: options.map((opt) { + final optVal = opt['value'] as String? ?? ''; + final optLabel = opt['label'] as String? ?? optVal; + return GestureDetector( + onTap: () => setValue(optVal), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Container( + width: 20, + height: 20, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: currentVal == optVal + ? AppColors.accentOrange + : const Color(0xFFD1D5DB), + width: 2, + ), + ), + child: currentVal == optVal + ? Center( + child: Container( + width: 10, + height: 10, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: AppColors.accentOrange, + ), + ), + ) + : null, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + optLabel, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w300, + color: AppColors.primaryDark, + ), + ), + ), + ], + ), + ), + ); + }).toList(), + ); + } + + // --------------------------------------------------------------------------- + // SELECT (dropdown) + // --------------------------------------------------------------------------- + + Widget _buildSelectField( + String slug, + dynamic Function() getValue, + void Function(dynamic) setValue, + String placeholder, + List> options, + ) { + final currentVal = '${getValue() ?? ''}'; + final hasMatch = options.any((o) => o['value'] == currentVal); + + return DropdownButtonFormField( + initialValue: hasMatch ? currentVal : null, + isExpanded: true, + decoration: _inputDecoration(placeholder), + icon: const Icon(Icons.keyboard_arrow_down, color: AppColors.hintText), + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w300, + color: AppColors.primaryDark, + ), + hint: Text( + placeholder.isEmpty ? 'Select...' : placeholder, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w300, + color: AppColors.hintText, + ), + ), + items: options.map((opt) { + final v = opt['value'] as String? ?? ''; + final l = opt['label'] as String? ?? v; + return DropdownMenuItem(value: v, child: Text(l)); + }).toList(), + onChanged: (val) { + if (val != null) setValue(val); + }, + ); + } + + // --------------------------------------------------------------------------- + // MULTI_SELECT (chip group) + // --------------------------------------------------------------------------- + + Widget _buildMultiSelect( + String slug, + dynamic Function() getValue, + void Function(dynamic) setValue, + List> options, + ) { + final selected = List.from((getValue() ?? []) as List); + + return Wrap( + spacing: 8, + runSpacing: 8, + children: options.map((opt) { + final optVal = opt['value'] as String? ?? ''; + final optLabel = opt['label'] as String? ?? optVal; + final isSelected = selected.contains(optVal); + + return GestureDetector( + onTap: () { + final newList = List.from(selected); + if (isSelected) { + newList.remove(optVal); + } else { + newList.add(optVal); + } + setValue(newList); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: isSelected + ? AppColors.accentOrange.withValues(alpha: 0.1) + : Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isSelected + ? AppColors.accentOrange + : const Color(0xFFD1D5DB), + ), + ), + child: Text( + optLabel, + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 13, + fontWeight: FontWeight.w400, + color: isSelected + ? AppColors.accentOrange + : AppColors.primaryDark, + ), + ), + ), + ); + }).toList(), + ); + } + + // --------------------------------------------------------------------------- + // TAG_INPUT + // --------------------------------------------------------------------------- + + Widget _buildTagInput( + String slug, + dynamic Function() getValue, + void Function(dynamic) setValue, + String placeholder, + ) { + final tags = List.from((getValue() ?? []) as List); + + if (!_tagControllers.containsKey(slug)) { + final controller = TextEditingController(); + _tagControllers[slug] = controller; + _controllers.add(controller); + } + final controller = _tagControllers[slug]!; + + void addTag(String text) { + final trimmed = text.trim(); + if (trimmed.isEmpty || tags.contains(trimmed)) return; + final newList = List.from(tags)..add(trimmed); + setValue(newList); + controller.clear(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (tags.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Wrap( + spacing: 6, + runSpacing: 6, + children: tags.map((tag) { + return Container( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: AppColors.accentOrange.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: AppColors.accentOrange.withValues(alpha: 0.3)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + tag, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 13, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + ), + ), + const SizedBox(width: 4), + GestureDetector( + onTap: () { + final newList = List.from(tags)..remove(tag); + setValue(newList); + }, + child: const Icon(Icons.close, + size: 14, color: AppColors.hintText), + ), + ], + ), + ); + }).toList(), + ), + ), + TextFormField( + controller: controller, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w300, + color: AppColors.primaryDark, + ), + decoration: _inputDecoration( + placeholder.isEmpty ? 'Type and press Enter' : placeholder, + ).copyWith( + suffixIcon: IconButton( + icon: const Icon(Icons.add_circle_outline, + color: AppColors.accentOrange), + onPressed: () => addTag(controller.text), + ), + ), + onFieldSubmitted: addTag, + ), + ], + ); + } + + // --------------------------------------------------------------------------- + // RANGE + // --------------------------------------------------------------------------- + + Widget _buildRangeField( + String slug, + dynamic Function() getValue, + void Function(dynamic) setValue, + Map field, + ) { + final rangeConfig = field['rangeConfig'] as Map? ?? {}; + final configMin = (rangeConfig['min'] as num?)?.toDouble() ?? 0; + final configMax = (rangeConfig['max'] as num?)?.toDouble() ?? 100; + final step = (rangeConfig['step'] as num?)?.toDouble() ?? 1; + final unit = rangeConfig['unit'] as String? ?? ''; + + final currentRange = getValue(); + double rangeMin = configMin; + double rangeMax = configMax; + if (currentRange is Map) { + rangeMin = (currentRange['min'] as num?)?.toDouble() ?? configMin; + rangeMax = (currentRange['max'] as num?)?.toDouble() ?? configMax; + } + // Clamp to valid range + rangeMin = rangeMin.clamp(configMin, configMax); + rangeMax = rangeMax.clamp(configMin, configMax); + if (rangeMin > rangeMax) rangeMin = rangeMax; + + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${rangeMin.toInt()} $unit', + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 13, + color: AppColors.primaryDark, + ), + ), + Text( + '${rangeMax.toInt()} $unit', + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 13, + color: AppColors.primaryDark, + ), + ), + ], + ), + SliderTheme( + data: SliderTheme.of(context).copyWith( + activeTrackColor: AppColors.accentOrange, + inactiveTrackColor: AppColors.accentOrange.withValues(alpha: 0.2), + thumbColor: AppColors.accentOrange, + overlayColor: AppColors.accentOrange.withValues(alpha: 0.1), + ), + child: RangeSlider( + values: RangeValues(rangeMin, rangeMax), + min: configMin, + max: configMax, + divisions: + step > 0 ? ((configMax - configMin) / step).round() : null, + onChanged: (values) { + setValue({ + 'min': values.start, + 'max': values.end, + }); + }, + ), + ), + ], + ); + } + + // --------------------------------------------------------------------------- + // FILE_UPLOAD + // --------------------------------------------------------------------------- + + Widget _buildFileUpload( + String slug, + dynamic Function() getValue, + void Function(dynamic) setValue, + ) { + final files = getValue(); + final fileList = files is List ? List.from(files.map((e) => '$e')) : []; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + OutlinedButton.icon( + onPressed: () { + // Placeholder: actual file picking would use image_picker or file_picker + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('File upload is not yet available on mobile'), + backgroundColor: AppColors.hintText, + ), + ); + }, + icon: const Icon(Icons.upload_file, size: 18), + label: const Text('Upload File'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.accentOrange, + side: const BorderSide(color: AppColors.accentOrange), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15), + ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + ), + ), + if (fileList.isNotEmpty) ...[ + const SizedBox(height: 8), + ...fileList.map((f) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + children: [ + const Icon(Icons.attach_file, + size: 16, color: AppColors.hintText), + const SizedBox(width: 4), + Expanded( + child: Text( + f, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 13, + color: AppColors.primaryDark, + ), + ), + ), + GestureDetector( + onTap: () { + final newList = List.from(fileList)..remove(f); + setValue(newList); + }, + child: const Icon(Icons.close, + size: 16, color: AppColors.error), + ), + ], + ), + )), + ], + ], + ); + } + + // --------------------------------------------------------------------------- + // Shared input decoration + // --------------------------------------------------------------------------- + + InputDecoration _inputDecoration(String hint) { + return InputDecoration( + hintText: hint, + hintStyle: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w300, + color: AppColors.hintText, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(15), + borderSide: const BorderSide(color: Color(0xFFE5E7EB)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(15), + borderSide: const BorderSide(color: Color(0xFFE5E7EB)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(15), + borderSide: const BorderSide(color: AppColors.accentOrange), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(15), + borderSide: const BorderSide(color: AppColors.error), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(15), + borderSide: const BorderSide(color: AppColors.error), + ), + isDense: true, + ); + } + + // --------------------------------------------------------------------------- + // Icon resolver + // --------------------------------------------------------------------------- + + IconData _resolveIcon(String name) { + const iconMap = { + 'user': Icons.person_outline, + 'person': Icons.person_outline, + 'profile': Icons.person_outline, + 'briefcase': Icons.work_outline, + 'work': Icons.work_outline, + 'business': Icons.business_outlined, + 'building': Icons.business_outlined, + 'office': Icons.business_outlined, + 'education': Icons.school_outlined, + 'school': Icons.school_outlined, + 'award': Icons.emoji_events_outlined, + 'trophy': Icons.emoji_events_outlined, + 'certificate': Icons.card_membership_outlined, + 'star': Icons.star_outline, + 'heart': Icons.favorite_outline, + 'home': Icons.home_outlined, + 'house': Icons.home_outlined, + 'location': Icons.location_on_outlined, + 'map': Icons.map_outlined, + 'phone': Icons.phone_outlined, + 'call': Icons.phone_outlined, + 'email': Icons.email_outlined, + 'mail': Icons.email_outlined, + 'globe': Icons.language, + 'world': Icons.language, + 'link': Icons.link, + 'settings': Icons.settings_outlined, + 'gear': Icons.settings_outlined, + 'info': Icons.info_outline, + 'dollar': Icons.attach_money, + 'money': Icons.attach_money, + 'finance': Icons.attach_money, + 'shield': Icons.shield_outlined, + 'lock': Icons.lock_outline, + 'clock': Icons.access_time, + 'time': Icons.access_time, + 'calendar': Icons.calendar_today_outlined, + 'file': Icons.description_outlined, + 'document': Icons.description_outlined, + 'camera': Icons.camera_alt_outlined, + 'photo': Icons.photo_outlined, + 'image': Icons.image_outlined, + 'chat': Icons.chat_outlined, + 'message': Icons.message_outlined, + 'list': Icons.list, + 'check': Icons.check_circle_outline, + 'target': Icons.gps_fixed, + 'flag': Icons.flag_outlined, + 'tag': Icons.label_outline, + }; + + final lower = name.toLowerCase().replaceAll(RegExp(r'[^a-z]'), ''); + for (final entry in iconMap.entries) { + if (lower.contains(entry.key)) return entry.value; + } + return Icons.article_outlined; + } +} diff --git a/lib/features/agents/presentation/screens/agent_home_screen.dart b/lib/features/agents/presentation/screens/agent_home_screen.dart index 6234745..192003f 100644 --- a/lib/features/agents/presentation/screens/agent_home_screen.dart +++ b/lib/features/agents/presentation/screens/agent_home_screen.dart @@ -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( diff --git a/lib/features/profile/data/profile_repository.dart b/lib/features/profile/data/profile_repository.dart index 70b5edb..9ebc777 100644 --- a/lib/features/profile/data/profile_repository.dart +++ b/lib/features/profile/data/profile_repository.dart @@ -299,6 +299,53 @@ class ProfileRepository { } } + /// Get sections for an agent type: GET /profile-sections/agent-type/{agentTypeId} + /// Returns { agentType: {...}, sections: [...] } + Future> getSectionsForAgentType( + String agentTypeId) async { + try { + final response = + await _dio.get('/profile-sections/agent-type/$agentTypeId'); + return response.data['data'] as Map; + } 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> getFieldValues() async { + try { + final response = await _dio.get('/agents/profile/field-values'); + return response.data['data'] as Map; + } 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 saveFieldValues(List> 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 cancelSubscription() async { try { diff --git a/lib/features/profile/presentation/screens/payment_success_screen.dart b/lib/features/profile/presentation/screens/payment_success_screen.dart new file mode 100644 index 0000000..742d9da --- /dev/null +++ b/lib/features/profile/presentation/screens/payment_success_screen.dart @@ -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 createState() => + _PaymentSuccessScreenState(); +} + +class _PaymentSuccessScreenState extends ConsumerState { + Map? _subscription; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSubscription(); + } + + Future _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, + ), + ), + ], + ); + } +} diff --git a/lib/features/profile/presentation/screens/profile_settings_screen.dart b/lib/features/profile/presentation/screens/profile_settings_screen.dart index 0f61657..71182a6 100644 --- a/lib/features/profile/presentation/screens/profile_settings_screen.dart +++ b/lib/features/profile/presentation/screens/profile_settings_screen.dart @@ -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 { 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 { ), ), ] 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 { ]; 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 { 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?>( + 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 { } 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?>( + 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); diff --git a/lib/features/profile/presentation/screens/stripe_checkout_screen.dart b/lib/features/profile/presentation/screens/stripe_checkout_screen.dart new file mode 100644 index 0000000..bc12610 --- /dev/null +++ b/lib/features/profile/presentation/screens/stripe_checkout_screen.dart @@ -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 createState() => _StripeCheckoutScreenState(); +} + +class _StripeCheckoutScreenState extends State { + 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( + AppColors.accentOrange), + ), + ) + : null, + ), + body: WebViewWidget(controller: _controller), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index f3876fc..11305f5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 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())); } diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index e6441cf..e928430 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -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((ref) { final authRefreshNotifier = _AuthRefreshNotifier(); @@ -166,6 +168,14 @@ final routerProvider = Provider((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((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, + ); + }, + ), ], ), ], diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index a9dbff2..6fb495d 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) } diff --git a/pubspec.lock b/pubspec.lock index a8c723f..e702fc3 100644 --- a/pubspec.lock +++ b/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: diff --git a/pubspec.yaml b/pubspec.yaml index 6643ee4..52ec8e8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: