diff --git a/lib/controller/auth/login_controller.dart b/lib/controller/auth/login_controller.dart index 6cc1bc1..40cbd25 100644 --- a/lib/controller/auth/login_controller.dart +++ b/lib/controller/auth/login_controller.dart @@ -14,6 +14,7 @@ class LoginController extends MyController { final RxBool isLoading = false.obs; final RxBool showPassword = false.obs; final RxBool isChecked = false.obs; + final RxBool showSplash = false.obs; @override void onInit() { @@ -40,18 +41,14 @@ class LoginController extends MyController { ); } - void onChangeCheckBox(bool? value) { - isChecked.value = value ?? false; - } + void onChangeCheckBox(bool? value) => isChecked.value = value ?? false; - void onChangeShowPassword() { - showPassword.toggle(); - } + void onChangeShowPassword() => showPassword.toggle(); Future onLogin() async { if (!basicValidator.validateForm()) return; - isLoading.value = true; + showSplash.value = true; try { final loginData = basicValidator.getData(); @@ -60,39 +57,30 @@ class LoginController extends MyController { final errors = await AuthService.loginUser(loginData); if (errors != null) { - logSafe( - "Login failed for user: ${loginData['username']} with errors: $errors", - level: LogLevel.warning); - showAppSnackbar( title: "Login Failed", message: "Username or password is incorrect", type: SnackbarType.error, ); - basicValidator.addErrors(errors); basicValidator.validateForm(); basicValidator.clearErrors(); } else { await _handleRememberMe(); - // ✅ Enable remote logging after successful login enableRemoteLogging(); - logSafe("✅ Remote logging enabled after login."); - logSafe("Login successful for user: ${loginData['username']}"); - - Get.toNamed('/select-tenant'); + Get.offNamed('/select-tenant'); } } catch (e, stacktrace) { - logSafe("Exception during login", - level: LogLevel.error, error: e, stackTrace: stacktrace); showAppSnackbar( title: "Login Error", message: "An unexpected error occurred", type: SnackbarType.error, ); + logSafe("Exception during login", + level: LogLevel.error, error: e, stackTrace: stacktrace); } finally { - isLoading.value = false; + showSplash.value = false; } } @@ -124,11 +112,7 @@ class LoginController extends MyController { } } - void goToForgotPassword() { - Get.toNamed('/auth/forgot_password'); - } + void goToForgotPassword() => Get.toNamed('/auth/forgot_password'); - void gotoRegister() { - Get.offAndToNamed('/auth/register_account'); - } + void gotoRegister() => Get.offAndToNamed('/auth/register_account'); } diff --git a/lib/controller/tenant/tenant_selection_controller.dart b/lib/controller/tenant/tenant_selection_controller.dart index eb6f6a6..7952e91 100644 --- a/lib/controller/tenant/tenant_selection_controller.dart +++ b/lib/controller/tenant/tenant_selection_controller.dart @@ -9,19 +9,28 @@ import 'package:marco/controller/permission_controller.dart'; class TenantSelectionController extends GetxController { final TenantService _tenantService = TenantService(); + // Tenant list final tenants = [].obs; + + // Loading state final isLoading = false.obs; + + // Selected tenant ID final selectedTenantId = RxnString(); + // Flag to indicate auto-selection (for splash screen) + final isAutoSelecting = false.obs; + @override void onInit() { super.onInit(); loadTenants(); } - /// Load tenants and perform smart auto-selection + /// Load tenants and handle auto-selection Future loadTenants() async { isLoading.value = true; + isAutoSelecting.value = true; // show splash during auto-selection try { final data = await _tenantService.getTenants(); if (data == null || data.isEmpty) { @@ -34,21 +43,23 @@ class TenantSelectionController extends GetxController { final recentTenantId = LocalStorage.getRecentTenantId(); + // Auto-select if only one tenant if (tenants.length == 1) { await _selectTenant(tenants.first.id); - } else if (recentTenantId != null) { - final recentTenant = tenants - .firstWhereOrNull((t) => t.id == recentTenantId); - + } + // Auto-select recent tenant if available + else if (recentTenantId != null) { + final recentTenant = + tenants.firstWhereOrNull((t) => t.id == recentTenantId); if (recentTenant != null) { await _selectTenant(recentTenant.id); } else { - selectedTenantId.value = null; - TenantService.currentTenant = null; + _clearSelection(); } - } else { - selectedTenantId.value = null; - TenantService.currentTenant = null; + } + // No auto-selection + else { + _clearSelection(); } } catch (e, st) { logSafe("❌ Exception in loadTenants", @@ -60,22 +71,24 @@ class TenantSelectionController extends GetxController { ); } finally { isLoading.value = false; + isAutoSelecting.value = false; // hide splash } } - /// Manually select tenant (user triggered) + /// User manually selects a tenant Future onTenantSelected(String tenantId) async { + isAutoSelecting.value = true; await _selectTenant(tenantId); + isAutoSelecting.value = false; } /// Internal tenant selection logic Future _selectTenant(String tenantId) async { try { isLoading.value = true; + final success = await _tenantService.selectTenant(tenantId); if (!success) { - logSafe("❌ Tenant selection failed for: $tenantId", - level: LogLevel.warning); showAppSnackbar( title: "Error", message: "Unable to select organization. Please try again.", @@ -84,39 +97,27 @@ class TenantSelectionController extends GetxController { return; } + // Update tenant & persist final selectedTenant = tenants.firstWhere((t) => t.id == tenantId); TenantService.setSelectedTenant(selectedTenant); selectedTenantId.value = tenantId; - - // Persist recent tenant await LocalStorage.setRecentTenantId(tenantId); - logSafe("✅ Tenant selection successful: $tenantId"); - - // 🔹 Load permissions after tenant selection (null-safe) + // Load permissions if token exists final token = LocalStorage.getJwtToken(); if (token != null && token.isNotEmpty) { if (!Get.isRegistered()) { Get.put(PermissionController()); - logSafe("✅ PermissionController injected after tenant selection."); } await Get.find().loadData(token); - } else { - logSafe("⚠️ JWT token is null. Cannot load permissions.", - level: LogLevel.warning); } - // Navigate to dashboard - Get.offAllNamed('/dashboard'); + // Navigate **before changing isAutoSelecting** + await Get.offAllNamed('/dashboard'); - showAppSnackbar( - title: "Success", - message: "Organization selected successfully.", - type: SnackbarType.success, - ); - } catch (e, st) { - logSafe("❌ Exception in _selectTenant", - level: LogLevel.error, error: e, stackTrace: st); + // Then hide splash + isAutoSelecting.value = false; + } catch (e) { showAppSnackbar( title: "Error", message: "An unexpected error occurred while selecting organization.", @@ -126,4 +127,10 @@ class TenantSelectionController extends GetxController { isLoading.value = false; } } + + /// Clear tenant selection + void _clearSelection() { + selectedTenantId.value = null; + TenantService.currentTenant = null; + } } diff --git a/lib/view/auth/email_login_form.dart b/lib/view/auth/email_login_form.dart index eff0222..c2ef521 100644 --- a/lib/view/auth/email_login_form.dart +++ b/lib/view/auth/email_login_form.dart @@ -123,20 +123,24 @@ class _EmailLoginFormState extends State with UIMixin { ), MySpacing.height(28), Center( - child: MyButton.rounded( - onPressed: controller.onLogin, - elevation: 2, - padding: MySpacing.xy(80, 16), - borderRadiusAll: 10, - backgroundColor: contentTheme.brandRed, - child: MyText.labelLarge( - 'Login', - fontWeight: 700, - color: contentTheme.onPrimary, - ), - ), + child: Obx(() { + final isLoading = controller.isLoading.value; + return MyButton.rounded( + onPressed: isLoading + ? null + : controller.onLogin, + elevation: 2, + padding: MySpacing.xy(80, 16), + borderRadiusAll: 10, + backgroundColor: contentTheme.brandRed, + child: MyText.labelLarge( + isLoading ? 'Logging in...' : 'Login', + fontWeight: 700, + color: contentTheme.onPrimary, + ), + ); + }), ), - ], ), ); diff --git a/lib/view/auth/login_screen.dart b/lib/view/auth/login_screen.dart index 433e91a..56d0733 100644 --- a/lib/view/auth/login_screen.dart +++ b/lib/view/auth/login_screen.dart @@ -37,7 +37,7 @@ class _LoginScreenState extends State with UIMixin { builder: (_) { return Obx(() { if (controller.isLoading.value) { - return const Center(child: CircularProgressIndicator()); + return const Center(child: LinearProgressIndicator()); } return Form( diff --git a/lib/view/splash_screen.dart b/lib/view/splash_screen.dart new file mode 100644 index 0000000..67552b6 --- /dev/null +++ b/lib/view/splash_screen.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import 'package:marco/images.dart'; + +class SplashScreen extends StatefulWidget { + final String? message; + final double? logoSize; + final Color? backgroundColor; + + const SplashScreen({ + super.key, + this.message, + this.logoSize = 120, + this.backgroundColor = Colors.white, + }); + + @override + State createState() => _SplashScreenState(); +} + +class _SplashScreenState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + + @override + void initState() { + super.initState(); + + _controller = AnimationController( + duration: const Duration(seconds: 1), + vsync: this, + )..repeat(reverse: true); + + _animation = Tween(begin: 0.0, end: 8.0).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeInOut), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Widget _buildAnimatedDots() { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(3, (index) { + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + double opacity; + if (index == 0) { + opacity = (0.3 + _animation.value / 8).clamp(0.0, 1.0); + } else if (index == 1) { + opacity = (0.3 + (_animation.value / 8)).clamp(0.0, 1.0); + } else { + opacity = (0.3 + (1 - _animation.value / 8)).clamp(0.0, 1.0); + } + return Container( + margin: const EdgeInsets.symmetric(horizontal: 4), + width: 10, + height: 10, + decoration: BoxDecoration( + color: Colors.blueAccent.withOpacity(opacity), + shape: BoxShape.circle, + ), + ); + }, + ); + }), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: widget.backgroundColor, + body: SafeArea( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Logo with slight bounce animation + ScaleTransition( + scale: Tween(begin: 0.8, end: 1.0).animate( + CurvedAnimation( + parent: _controller, + curve: Curves.easeInOut, + ), + ), + child: SizedBox( + width: widget.logoSize, + height: widget.logoSize, + child: Image.asset(Images.logoDark), + ), + ), + + const SizedBox(height: 20), + // Text message + if (widget.message != null) + Text( + widget.message!, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + const SizedBox(height: 30), + // Animated loading dots + _buildAnimatedDots(), + ], + ), + ), + ), + ); + } +} diff --git a/lib/view/tenant/tenant_selection_screen.dart b/lib/view/tenant/tenant_selection_screen.dart index d3269b1..0565adf 100644 --- a/lib/view/tenant/tenant_selection_screen.dart +++ b/lib/view/tenant/tenant_selection_screen.dart @@ -6,6 +6,7 @@ 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'; +import 'package:marco/view/splash_screen.dart'; class TenantSelectionScreen extends StatefulWidget { const TenantSelectionScreen({super.key}); @@ -20,25 +21,23 @@ class _TenantSelectionScreenState extends State 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()); + _controller = Get.put(TenantSelectionController()); + _logoAnimController = AnimationController( vsync: this, duration: const Duration(milliseconds: 800), ); + _logoAnimation = CurvedAnimation( parent: _logoAnimController, curve: Curves.easeOutBack, ); - _logoAnimController.forward(); - // 🔥 Tell controller this is tenant selection screen - _controller.loadTenants(); + _logoAnimController.forward(); } @override @@ -49,61 +48,66 @@ class _TenantSelectionScreenState extends State } 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) ...[ + return Obx(() { + // Splash screen for auto-selection + if (_controller.isAutoSelecting.value) { + return const SplashScreen(); + } + + 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 _BetaBadge(), + const _WelcomeTexts(), + if (_isBetaEnvironment) ...[ + const SizedBox(height: 12), + const _BetaBadge(), + ], + const SizedBox(height: 36), + TenantCardList( + controller: _controller, + isLoading: _controller.isLoading.value, + onTenantSelected: _onTenantSelected, + ), ], - const SizedBox(height: 36), - // Tenant list directly reacts to controller - TenantCardList( - controller: _controller, - isLoading: _isLoading, - onTenantSelected: _onTenantSelected, - ), - ], + ), ), ), ), ), - ), - ], + ], + ), ), ), - ), - ], - ), - ); + ], + ), + ); + }); } } +/// Animated Logo Widget class _AnimatedLogo extends StatelessWidget { final Animation animation; const _AnimatedLogo({required this.animation}); @@ -133,6 +137,7 @@ class _AnimatedLogo extends StatelessWidget { } } +/// Welcome Texts class _WelcomeTexts extends StatelessWidget { const _WelcomeTexts(); @@ -149,7 +154,7 @@ class _WelcomeTexts extends StatelessWidget { ), const SizedBox(height: 10), MyText( - "Please select which dashboard you want to explore!.", + "Please select which dashboard you want to explore!", fontSize: 14, color: Colors.black54, textAlign: TextAlign.center, @@ -159,6 +164,7 @@ class _WelcomeTexts extends StatelessWidget { } } +/// Beta Badge class _BetaBadge extends StatelessWidget { const _BetaBadge(); @@ -180,6 +186,7 @@ class _BetaBadge extends StatelessWidget { } } +/// Tenant Card List class TenantCardList extends StatelessWidget { final TenantSelectionController controller; final bool isLoading; @@ -195,10 +202,9 @@ class TenantCardList extends StatelessWidget { Widget build(BuildContext context) { return Obx(() { if (controller.isLoading.value || isLoading) { - return const Center( - child: CircularProgressIndicator(strokeWidth: 2), - ); + return const Center(child: CircularProgressIndicator(strokeWidth: 2)); } + if (controller.tenants.isEmpty) { return Center( child: MyText( @@ -209,37 +215,35 @@ class TenantCardList extends StatelessWidget { ), ); } - // Show tenant even if only 1 tenant - 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), - ), + + return 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, - ), + ), + 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, ), - ], - ), + ), + ], ); }); } } +/// Single Tenant Card class _TenantCard extends StatelessWidget { final dynamic tenant; final VoidCallback onTap; @@ -252,9 +256,7 @@ class _TenantCard extends StatelessWidget { borderRadius: BorderRadius.circular(5), child: Card( elevation: 3, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), margin: const EdgeInsets.only(bottom: 20), child: Padding( padding: const EdgeInsets.all(16), @@ -290,11 +292,7 @@ class _TenantCard extends StatelessWidget { ], ), ), - Icon( - Icons.arrow_forward_ios, - size: 24, - color: Colors.red, - ), + const Icon(Icons.arrow_forward_ios, size: 24, color: Colors.red), ], ), ), @@ -303,6 +301,7 @@ class _TenantCard extends StatelessWidget { } } +/// Tenant Logo (supports base64 and URL) class TenantLogo extends StatelessWidget { final String? logoImage; const TenantLogo({required this.logoImage}); @@ -310,9 +309,7 @@ class TenantLogo extends StatelessWidget { @override Widget build(BuildContext context) { if (logoImage == null || logoImage!.isEmpty) { - return Center( - child: Icon(Icons.business, color: Colors.grey.shade600), - ); + return Center(child: Icon(Icons.business, color: Colors.grey.shade600)); } if (logoImage!.startsWith("data:image")) { try { @@ -320,9 +317,7 @@ class TenantLogo extends StatelessWidget { final bytes = base64Decode(base64Str); return Image.memory(bytes, fit: BoxFit.cover); } catch (_) { - return Center( - child: Icon(Icons.business, color: Colors.grey.shade600), - ); + return Center(child: Icon(Icons.business, color: Colors.grey.shade600)); } } else { return Image.network( @@ -336,6 +331,7 @@ class TenantLogo extends StatelessWidget { } } +/// Red Wave Background class _RedWaveBackground extends StatelessWidget { final Color brandRed; const _RedWaveBackground({required this.brandRed}); @@ -351,7 +347,6 @@ class _RedWaveBackground extends StatelessWidget { class _WavePainter extends CustomPainter { final Color brandRed; - _WavePainter(this.brandRed); @override