diff --git a/lib/controller/auth/login_controller.dart b/lib/controller/auth/login_controller.dart index 833f19a..f52b28b 100644 --- a/lib/controller/auth/login_controller.dart +++ b/lib/controller/auth/login_controller.dart @@ -79,7 +79,6 @@ class LoginController extends MyController { enableRemoteLogging(); logSafe("✅ Remote logging enabled after login."); - final fcmToken = await LocalStorage.getFcmToken(); if (fcmToken?.isNotEmpty ?? false) { final success = await AuthService.registerDeviceToken(fcmToken!); @@ -90,9 +89,9 @@ class LoginController extends MyController { level: LogLevel.warning); } - logSafe("Login successful for user: ${loginData['username']}"); - Get.toNamed('/home'); + + Get.toNamed('/select_tenant'); } } catch (e, stacktrace) { logSafe("Exception during login", diff --git a/lib/controller/tenant/tenant_selection_controller.dart b/lib/controller/tenant/tenant_selection_controller.dart new file mode 100644 index 0000000..aa44fa8 --- /dev/null +++ b/lib/controller/tenant/tenant_selection_controller.dart @@ -0,0 +1,89 @@ +import 'package:get/get.dart'; +import 'package:marco/helpers/services/app_logger.dart'; +import 'package:marco/helpers/services/tenant_service.dart'; +import 'package:marco/model/tenant/tenant_list_model.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; + +class TenantSelectionController extends GetxController { + final TenantService _tenantService = TenantService(); + + var tenants = [].obs; + var isLoading = false.obs; + + @override + void onInit() { + super.onInit(); + loadTenants(); + } + + /// Load tenants from API + Future loadTenants() async { + try { + isLoading.value = true; + final data = await _tenantService.getTenants(); + if (data != null) { + tenants.value = data.map((e) => Tenant.fromJson(e)).toList(); + + // ✅ Automatically select if only one tenant + if (tenants.length == 1) { + await onTenantSelected(tenants.first.id); + } + } else { + tenants.clear(); + logSafe("⚠️ No tenants found for the user.", level: LogLevel.warning); + } + } catch (e, st) { + logSafe("❌ Exception in loadTenants", + level: LogLevel.error, error: e, stackTrace: st); + } finally { + isLoading.value = false; + } + } + + /// Select tenant + Future onTenantSelected(String tenantId) async { + try { + isLoading.value = true; + final success = await _tenantService.selectTenant(tenantId); + if (success) { + logSafe("✅ Tenant selection successful: $tenantId"); + + // Store selected tenant in memory + TenantService.setSelectedTenant( + tenants.firstWhere((t) => t.id == tenantId)); + + // Navigate to dashboard/home + Get.offAllNamed('/dashboard'); + + // Optional: show success snackbar + showAppSnackbar( + title: "Success", + message: "Organization selected successfully.", + type: SnackbarType.success, + ); + } else { + logSafe("❌ Tenant selection failed for: $tenantId", + level: LogLevel.warning); + + // Show error snackbar + showAppSnackbar( + title: "Error", + message: "Unable to select organization. Please try again.", + type: SnackbarType.error, + ); + } + } catch (e, st) { + logSafe("❌ Exception in onTenantSelected", + level: LogLevel.error, error: e, stackTrace: st); + + // Show error snackbar for exception + showAppSnackbar( + title: "Error", + message: "An unexpected error occurred while selecting organization.", + type: SnackbarType.error, + ); + } finally { + isLoading.value = false; + } + } +} diff --git a/lib/helpers/services/auth_service.dart b/lib/helpers/services/auth_service.dart index d12382e..3c9b02b 100644 --- a/lib/helpers/services/auth_service.dart +++ b/lib/helpers/services/auth_service.dart @@ -83,7 +83,7 @@ class AuthService { logSafe("Login payload (raw): $data"); logSafe("Login payload (JSON): ${jsonEncode(data)}"); - final responseData = await _post("/auth/login-mobile", data); + final responseData = await _post("/auth/app/login", data); if (responseData == null) return {"error": "Network error. Please check your connection."}; diff --git a/lib/helpers/services/permission_service.dart b/lib/helpers/services/permission_service.dart index adc1518..c3ee52e 100644 --- a/lib/helpers/services/permission_service.dart +++ b/lib/helpers/services/permission_service.dart @@ -11,19 +11,23 @@ import 'package:marco/helpers/services/auth_service.dart'; import 'package:marco/helpers/services/api_endpoints.dart'; class PermissionService { + // In-memory cache keyed by user token static final Map> _userDataCache = {}; static const String _baseUrl = ApiEndpoints.baseUrl; - /// Fetches all user-related data (permissions, employee info, projects) + /// Fetches all user-related data (permissions, employee info, projects). + /// Uses in-memory cache for repeated token queries during session. static Future> fetchAllUserData( String token, { bool hasRetried = false, }) async { - logSafe("Fetching user data...", ); + logSafe("Fetching user data..."); - if (_userDataCache.containsKey(token)) { - logSafe("User data cache hit.", ); - return _userDataCache[token]!; + // Check for cached data before network request + final cached = _userDataCache[token]; + if (cached != null) { + logSafe("User data cache hit."); + return cached; } final uri = Uri.parse("$_baseUrl/user/profile"); @@ -34,8 +38,8 @@ class PermissionService { final statusCode = response.statusCode; if (statusCode == 200) { - logSafe("User data fetched successfully."); - final data = json.decode(response.body)['data']; + final raw = json.decode(response.body); + final data = raw['data'] as Map; final result = { 'permissions': _parsePermissions(data['featurePermissions']), @@ -43,10 +47,12 @@ class PermissionService { 'projects': _parseProjectsInfo(data['projects']), }; - _userDataCache[token] = result; + _userDataCache[token] = result; // Cache it for future use + logSafe("User data fetched successfully."); return result; } + // Token expired, try refresh once then redirect on failure if (statusCode == 401 && !hasRetried) { logSafe("Unauthorized. Attempting token refresh...", level: LogLevel.warning); @@ -63,42 +69,43 @@ class PermissionService { throw Exception('Unauthorized. Token refresh failed.'); } - final error = json.decode(response.body)['message'] ?? 'Unknown error'; - logSafe("Failed to fetch user data: $error", level: LogLevel.warning); - throw Exception('Failed to fetch user data: $error'); + final errorMsg = json.decode(response.body)['message'] ?? 'Unknown error'; + logSafe("Failed to fetch user data: $errorMsg", level: LogLevel.warning); + throw Exception('Failed to fetch user data: $errorMsg'); } catch (e, stacktrace) { logSafe("Exception while fetching user data", level: LogLevel.error, error: e, stackTrace: stacktrace); - rethrow; + rethrow; // Let the caller handle or report } } - /// Clears auth data and redirects to login + /// Handles unauthorized/user sign out flow static Future _handleUnauthorized() async { logSafe("Clearing tokens and redirecting to login due to unauthorized access.", level: LogLevel.warning); - await LocalStorage.removeToken('jwt_token'); await LocalStorage.removeToken('refresh_token'); await LocalStorage.setLoggedInUser(false); Get.offAllNamed('/auth/login-option'); } - /// Converts raw permission data into list of `UserPermission` + /// Robust model parsing for permissions static List _parsePermissions(List permissions) { logSafe("Parsing user permissions..."); return permissions - .map((id) => UserPermission.fromJson({'id': id})) + .map((perm) => UserPermission.fromJson({'id': perm})) .toList(); } - /// Converts raw employee JSON into `EmployeeInfo` - static EmployeeInfo _parseEmployeeInfo(Map data) { + /// Robust model parsing for employee info + static EmployeeInfo _parseEmployeeInfo(Map? data) { logSafe("Parsing employee info..."); + if (data == null) throw Exception("Employee data missing"); return EmployeeInfo.fromJson(data); } - /// Converts raw projects JSON into list of `ProjectInfo` - static List _parseProjectsInfo(List projects) { + /// Robust model parsing for projects list + static List _parseProjectsInfo(List? projects) { logSafe("Parsing projects info..."); + if (projects == null) return []; return projects.map((proj) => ProjectInfo.fromJson(proj)).toList(); } } diff --git a/lib/helpers/services/tenant_service.dart b/lib/helpers/services/tenant_service.dart new file mode 100644 index 0000000..7e98512 --- /dev/null +++ b/lib/helpers/services/tenant_service.dart @@ -0,0 +1,126 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; + +import 'package:marco/helpers/services/api_endpoints.dart'; +import 'package:marco/helpers/services/storage/local_storage.dart'; +import 'package:marco/helpers/services/app_logger.dart'; +import 'package:marco/helpers/services/auth_service.dart'; +import 'package:marco/model/tenant/tenant_list_model.dart'; + + +/// Abstract interface for tenant service functionality +abstract class ITenantService { + Future>?> getTenants({bool hasRetried = false}); + Future selectTenant(String tenantId, {bool hasRetried = false}); +} + +/// Tenant API service +class TenantService implements ITenantService { + static const String _baseUrl = ApiEndpoints.baseUrl; + static const Map _headers = { + 'Content-Type': 'application/json', + }; + + /// Currently selected tenant + static Tenant? currentTenant; + + /// Set the selected tenant + static void setSelectedTenant(Tenant tenant) { + currentTenant = tenant; + } + + + + /// Check if tenant is selected + static bool get isTenantSelected => currentTenant != null; + + /// Build authorized headers + static Future> _authorizedHeaders() async { + final token = await LocalStorage.getJwtToken(); + if (token == null || token.isEmpty) { + throw Exception('Missing JWT token'); + } + return {..._headers, 'Authorization': 'Bearer $token'}; + } + + /// Handle API errors + static void _handleApiError(http.Response response, dynamic data, String context) { + final message = data['message'] ?? 'Unknown error'; + final level = response.statusCode >= 500 ? LogLevel.error : LogLevel.warning; + logSafe("❌ $context failed: $message [Status: ${response.statusCode}]", level: level); + } + + /// Log exceptions + static void _logException(dynamic e, dynamic st, String context) { + logSafe("❌ $context exception", level: LogLevel.error, error: e, stackTrace: st); + } + + @override + Future>?> getTenants({bool hasRetried = false}) async { + try { + final headers = await _authorizedHeaders(); + logSafe("➡️ GET $_baseUrl/auth/get/user/tenants\nHeaders: $headers", level: LogLevel.info); + + final response = await http.get(Uri.parse("$_baseUrl/auth/get/user/tenants"), headers: headers); + final data = jsonDecode(response.body); + + logSafe("⬅️ Response: ${jsonEncode(data)} [Status: ${response.statusCode}]", level: LogLevel.info); + + if (response.statusCode == 200 && data['success'] == true) { + logSafe("✅ Tenants fetched successfully."); + return List>.from(data['data']); + } + + if (response.statusCode == 401 && !hasRetried) { + logSafe("⚠️ Unauthorized while fetching tenants. Refreshing token...", level: LogLevel.warning); + final refreshed = await AuthService.refreshToken(); + if (refreshed) return getTenants(hasRetried: true); + logSafe("❌ Token refresh failed while fetching tenants.", level: LogLevel.error); + return null; + } + + _handleApiError(response, data, "Fetching tenants"); + return null; + } catch (e, st) { + _logException(e, st, "Get Tenants API"); + return null; + } + } + + @override + Future selectTenant(String tenantId, {bool hasRetried = false}) async { + try { + final headers = await _authorizedHeaders(); + logSafe("➡️ POST $_baseUrl/auth/select-tenant/$tenantId\nHeaders: $headers", level: LogLevel.info); + + final response = await http.post( + Uri.parse("$_baseUrl/auth/select-tenant/$tenantId"), + headers: headers, + ); + final data = jsonDecode(response.body); + + logSafe("⬅️ Response: ${jsonEncode(data)} [Status: ${response.statusCode}]", level: LogLevel.info); + + if (response.statusCode == 200 && data['success'] == true) { + await LocalStorage.setJwtToken(data['data']['token']); + await LocalStorage.setRefreshToken(data['data']['refreshToken']); + logSafe("✅ Tenant selected successfully. Tokens updated."); + return true; + } + + if (response.statusCode == 401 && !hasRetried) { + logSafe("⚠️ Unauthorized while selecting tenant. Refreshing token...", level: LogLevel.warning); + final refreshed = await AuthService.refreshToken(); + if (refreshed) return selectTenant(tenantId, hasRetried: true); + logSafe("❌ Token refresh failed while selecting tenant.", level: LogLevel.error); + return false; + } + + _handleApiError(response, data, "Selecting tenant"); + return false; + } catch (e, st) { + _logException(e, st, "Select Tenant API"); + return false; + } + } +} diff --git a/lib/model/tenant/tenant_list_model.dart b/lib/model/tenant/tenant_list_model.dart new file mode 100644 index 0000000..84490ab --- /dev/null +++ b/lib/model/tenant/tenant_list_model.dart @@ -0,0 +1,42 @@ +class Tenant { + final String id; + final String name; + final String email; + final String? domainName; + final String contactName; + final String contactNumber; + final String? logoImage; + final String? organizationSize; + final String? industry; + final String? tenantStatus; + + Tenant({ + required this.id, + required this.name, + required this.email, + this.domainName, + required this.contactName, + required this.contactNumber, + this.logoImage, + this.organizationSize, + this.industry, + this.tenantStatus, + }); + + factory Tenant.fromJson(Map json) { + return Tenant( + id: json['id'] ?? '', + name: json['name'] ?? '', + email: json['email'] ?? '', + domainName: json['domainName'] as String?, + contactName: json['contactName'] ?? '', + contactNumber: json['contactNumber'] ?? '', + logoImage: json['logoImage'] is String ? json['logoImage'] : null, + organizationSize: + json['organizationSize'] is String ? json['organizationSize'] : null, + industry: json['industry'] is String ? json['industry'] : null, + tenantStatus: + json['tenantStatus'] is String ? json['tenantStatus'] : null, + ); + } +} diff --git a/lib/routes.dart b/lib/routes.dart index 70bd46d..a2f9362 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/helpers/services/auth_service.dart'; +import 'package:marco/helpers/services/tenant_service.dart'; import 'package:marco/view/auth/forgot_password_screen.dart'; import 'package:marco/view/auth/login_screen.dart'; import 'package:marco/view/auth/register_account_screen.dart'; @@ -19,13 +20,21 @@ import 'package:marco/view/auth/mpin_auth_screen.dart'; import 'package:marco/view/directory/directory_main_screen.dart'; import 'package:marco/view/expense/expense_screen.dart'; import 'package:marco/view/document/user_document_screen.dart'; +import 'package:marco/view/tenant/tenant_selection_screen.dart'; class AuthMiddleware extends GetMiddleware { @override RouteSettings? redirect(String? route) { - return AuthService.isLoggedIn - ? null - : RouteSettings(name: '/auth/login-option'); + if (!AuthService.isLoggedIn) { + if (route != '/auth/login-option') { + return const RouteSettings(name: '/auth/login-option'); + } + } else if (!TenantService.isTenantSelected) { + if (route != '/select-tenant') { + return const RouteSettings(name: '/select-tenant'); + } + } + return null; } } @@ -40,6 +49,10 @@ getPageRoute() { page: () => DashboardScreen(), // or your actual home screen middlewares: [AuthMiddleware()], ), + GetPage( + name: '/select-tenant', + page: () => const TenantSelectionScreen(), + middlewares: [AuthMiddleware()]), // Dashboard GetPage( @@ -67,12 +80,12 @@ getPageRoute() { name: '/dashboard/directory-main-page', page: () => DirectoryMainScreen(), middlewares: [AuthMiddleware()]), - // Expense + // Expense GetPage( name: '/dashboard/expense-main-page', page: () => ExpenseMainScreen(), middlewares: [AuthMiddleware()]), - // Documents + // Documents GetPage( name: '/dashboard/document-main-page', page: () => UserDocumentsPage(), diff --git a/lib/view/layouts/user_profile_right_bar.dart b/lib/view/layouts/user_profile_right_bar.dart index d3d86d8..a15d2d6 100644 --- a/lib/view/layouts/user_profile_right_bar.dart +++ b/lib/view/layouts/user_profile_right_bar.dart @@ -9,7 +9,10 @@ import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/avatar.dart'; import 'package:marco/model/employees/employee_info.dart'; import 'package:marco/controller/auth/mpin_controller.dart'; +import 'package:marco/controller/tenant/tenant_selection_controller.dart'; import 'package:marco/view/employees/employee_profile_screen.dart'; +import 'package:marco/helpers/services/tenant_service.dart'; +import 'package:marco/view/tenant/tenant_selection_screen.dart'; class UserProfileBar extends StatefulWidget { final bool isCondensed; @@ -24,13 +27,21 @@ class _UserProfileBarState extends State late EmployeeInfo employeeInfo; bool _isLoading = true; bool hasMpin = true; + late final TenantSelectionController _tenantController; @override void initState() { super.initState(); + _tenantController = Get.put(TenantSelectionController()); _initData(); } + @override + void dispose() { + Get.delete(); + super.dispose(); + } + Future _initData() async { employeeInfo = LocalStorage.getEmployeeInfo()!; hasMpin = await LocalStorage.getIsMpin(); @@ -80,6 +91,10 @@ class _UserProfileBarState extends State _isLoading ? const _LoadingSection() : _userProfileSection(isCondensed), + + // --- SWITCH TENANT ROW BELOW AVATAR --- + if (!_isLoading && !isCondensed) _switchTenantRow(), + MySpacing.height(12), Divider( indent: 18, @@ -106,6 +121,119 @@ class _UserProfileBarState extends State ); } + /// Row widget to switch tenant with popup menu (button only) + Widget _switchTenantRow() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Obx(() { + if (_tenantController.isLoading.value) return _loadingTenantContainer(); + + final tenants = _tenantController.tenants; + if (tenants.isEmpty) return _noTenantContainer(); + + final selectedTenant = TenantService.currentTenant; + + // Sort tenants: selected tenant first + final sortedTenants = List.of(tenants); + if (selectedTenant != null) { + sortedTenants.sort((a, b) { + if (a.id == selectedTenant.id) return -1; + if (b.id == selectedTenant.id) return 1; + return 0; + }); + } + + return PopupMenuButton( + onSelected: (tenantId) => + _tenantController.onTenantSelected(tenantId), + itemBuilder: (_) => sortedTenants.map((tenant) { + return PopupMenuItem( + value: tenant.id, + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Container( + width: 20, + height: 20, + color: Colors.grey.shade200, + child: TenantLogo(logoImage: tenant.logoImage), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + tenant.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: tenant.id == selectedTenant?.id + ? FontWeight.bold + : FontWeight.w600, + color: tenant.id == selectedTenant?.id + ? Colors.blueAccent + : Colors.black87, + ), + ), + ), + if (tenant.id == selectedTenant?.id) + const Icon(Icons.check_circle, + color: Colors.blueAccent, size: 18), + ], + ), + ); + }).toList(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Icon(Icons.swap_horiz, color: Colors.blue.shade600), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Text( + "Switch Organization", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.blue, fontWeight: FontWeight.bold), + ), + ), + ), + Icon(Icons.arrow_drop_down, color: Colors.blue.shade600), + ], + ), + ), + ); + }), + ); + } + + Widget _loadingTenantContainer() => Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.blue.shade200, width: 1), + ), + child: const Center(child: CircularProgressIndicator(strokeWidth: 2)), + ); + + Widget _noTenantContainer() => Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.blue.shade200, width: 1), + ), + child: MyText.bodyMedium( + "No tenants available", + color: Colors.blueAccent, + fontWeight: 600, + ), + ); + Widget _userProfileSection(bool condensed) { final padding = MySpacing.fromLTRB( condensed ? 16 : 26, diff --git a/lib/view/tenant/tenant_selection_screen.dart b/lib/view/tenant/tenant_selection_screen.dart new file mode 100644 index 0000000..47d90c7 --- /dev/null +++ b/lib/view/tenant/tenant_selection_screen.dart @@ -0,0 +1,391 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/helpers/services/api_endpoints.dart'; +import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/images.dart'; +import 'package:marco/controller/tenant/tenant_selection_controller.dart'; + +class TenantSelectionScreen extends StatefulWidget { + const TenantSelectionScreen({super.key}); + + @override + State createState() => _TenantSelectionScreenState(); +} + +class _TenantSelectionScreenState extends State + with UIMixin, SingleTickerProviderStateMixin { + late final TenantSelectionController _controller; + late final AnimationController _logoAnimController; + late final Animation _logoAnimation; + final bool _isBetaEnvironment = ApiEndpoints.baseUrl.contains("stage"); + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _controller = Get.put(TenantSelectionController()); + _logoAnimController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + ); + _logoAnimation = CurvedAnimation( + parent: _logoAnimController, + curve: Curves.easeOutBack, + ); + _logoAnimController.forward(); + _controller.loadTenants(); + } + + @override + void dispose() { + _logoAnimController.dispose(); + Get.delete(); + super.dispose(); + } + + Future _onTenantSelected(String tenantId) async { + setState(() => _isLoading = true); + await _controller.onTenantSelected(tenantId); + setState(() => _isLoading = false); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Stack( + children: [ + _RedWaveBackground(brandRed: contentTheme.brandRed), + SafeArea( + child: Center( + child: Column( + children: [ + const SizedBox(height: 24), + _AnimatedLogo(animation: _logoAnimation), + const SizedBox(height: 8), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + children: [ + const SizedBox(height: 12), + const _WelcomeTexts(), + if (_isBetaEnvironment) ...[ + const SizedBox(height: 12), + const _BetaBadge(), + ], + const SizedBox(height: 36), + // Tenant list directly reacts to controller + TenantCardList( + controller: _controller, + isLoading: _isLoading, + onTenantSelected: _onTenantSelected, + ), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _AnimatedLogo extends StatelessWidget { + final Animation animation; + const _AnimatedLogo({required this.animation}); + + @override + Widget build(BuildContext context) { + return ScaleTransition( + scale: animation, + child: Container( + width: 100, + height: 100, + padding: const EdgeInsets.all(20), + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black12, + blurRadius: 10, + offset: Offset(0, 4), + ), + ], + ), + child: Image.asset(Images.logoDark), + ), + ); + } +} + +class _WelcomeTexts extends StatelessWidget { + const _WelcomeTexts(); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + MyText( + "Welcome", + fontSize: 24, + fontWeight: 600, + color: Colors.black87, + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + MyText( + "Please select which dashboard you want to explore!.", + fontSize: 14, + color: Colors.black54, + textAlign: TextAlign.center, + ), + ], + ); + } +} + +class _BetaBadge extends StatelessWidget { + const _BetaBadge(); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.orangeAccent, + borderRadius: BorderRadius.circular(6), + ), + child: MyText( + 'BETA', + color: Colors.white, + fontWeight: 600, + fontSize: 12, + ), + ); + } +} + +class TenantCardList extends StatelessWidget { + final TenantSelectionController controller; + final bool isLoading; + final Function(String tenantId) onTenantSelected; + + const TenantCardList({ + required this.controller, + required this.isLoading, + required this.onTenantSelected, + }); + + @override + Widget build(BuildContext context) { + return Obx(() { + if (controller.isLoading.value || isLoading) { + return const Center( + child: CircularProgressIndicator(strokeWidth: 2), + ); + } + if (controller.tenants.isEmpty) { + return Center( + child: MyText( + "No dashboards available for your account.", + fontSize: 14, + color: Colors.black54, + textAlign: TextAlign.center, + ), + ); + } + if (controller.tenants.length == 1) { + return const SizedBox.shrink(); + } + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ...controller.tenants.map( + (tenant) => _TenantCard( + tenant: tenant, + onTap: () => onTenantSelected(tenant.id), + ), + ), + const SizedBox(height: 16), + TextButton.icon( + onPressed: () => Get.back(), + icon: const Icon(Icons.arrow_back, size: 20, color: Colors.redAccent), + label: MyText( + 'Back to Login', + color: Colors.red, + fontWeight: 600, + fontSize: 14, + ), + ), + ], + ), + ); + }); + } +} + +class _TenantCard extends StatelessWidget { + final dynamic tenant; + final VoidCallback onTap; + const _TenantCard({required this.tenant, required this.onTap}); + + @override + Widget build(BuildContext context) { + return Card( + elevation: 3, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + margin: const EdgeInsets.only(bottom: 20), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Container( + width: 60, + height: 60, + color: Colors.grey.shade200, + child: TenantLogo(logoImage: tenant.logoImage), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText( + tenant.name, + fontSize: 18, + fontWeight: 700, + color: Colors.black87, + ), + const SizedBox(height: 6), + MyText( + "Industry: ${tenant.industry != null && tenant.industry!.isNotEmpty ? tenant.industry! : "-"}", + fontSize: 13, + color: Colors.black54, + ), + ], + ), + ), + InkWell( + onTap: onTap, + child: Container( + height: 60, + alignment: Alignment.center, + child: Icon( + Icons.arrow_forward_ios, + size: 24, + color: Colors.red, + ), + ), + ), + ], + ), + ), + ); + } +} + +class TenantLogo extends StatelessWidget { + final String? logoImage; + const TenantLogo({required this.logoImage}); + + @override + Widget build(BuildContext context) { + if (logoImage == null || logoImage!.isEmpty) { + return Center( + child: Icon(Icons.business, color: Colors.grey.shade600), + ); + } + if (logoImage!.startsWith("data:image")) { + try { + final base64Str = logoImage!.split(',').last; + final bytes = base64Decode(base64Str); + return Image.memory(bytes, fit: BoxFit.cover); + } catch (_) { + return Center( + child: Icon(Icons.business, color: Colors.grey.shade600), + ); + } + } else { + return Image.network( + logoImage!, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Center( + child: Icon(Icons.business, color: Colors.grey.shade600), + ), + ); + } + } +} + +class _RedWaveBackground extends StatelessWidget { + final Color brandRed; + const _RedWaveBackground({required this.brandRed}); + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WavePainter(brandRed), + size: Size.infinite, + ); + } +} + +class _WavePainter extends CustomPainter { + final Color brandRed; + + _WavePainter(this.brandRed); + + @override + void paint(Canvas canvas, Size size) { + final paint1 = Paint() + ..shader = LinearGradient( + colors: [brandRed, const Color.fromARGB(255, 97, 22, 22)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)); + + final path1 = Path() + ..moveTo(0, size.height * 0.2) + ..quadraticBezierTo(size.width * 0.25, size.height * 0.05, + size.width * 0.5, size.height * 0.15) + ..quadraticBezierTo( + size.width * 0.75, size.height * 0.25, size.width, size.height * 0.1) + ..lineTo(size.width, 0) + ..lineTo(0, 0) + ..close(); + canvas.drawPath(path1, paint1); + + final paint2 = Paint()..color = Colors.redAccent.withOpacity(0.15); + final path2 = Path() + ..moveTo(0, size.height * 0.25) + ..quadraticBezierTo( + size.width * 0.4, size.height * 0.1, size.width, size.height * 0.2) + ..lineTo(size.width, 0) + ..lineTo(0, 0) + ..close(); + canvas.drawPath(path2, paint2); + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) => false; +}