added splash screen

This commit is contained in:
Vaibhav Surve 2025-10-08 17:33:20 +05:30
parent 041b62ca2f
commit d5a8d08e63
6 changed files with 270 additions and 160 deletions

View File

@ -14,6 +14,7 @@ class LoginController extends MyController {
final RxBool isLoading = false.obs; final RxBool isLoading = false.obs;
final RxBool showPassword = false.obs; final RxBool showPassword = false.obs;
final RxBool isChecked = false.obs; final RxBool isChecked = false.obs;
final RxBool showSplash = false.obs;
@override @override
void onInit() { void onInit() {
@ -40,18 +41,14 @@ class LoginController extends MyController {
); );
} }
void onChangeCheckBox(bool? value) { void onChangeCheckBox(bool? value) => isChecked.value = value ?? false;
isChecked.value = value ?? false;
}
void onChangeShowPassword() { void onChangeShowPassword() => showPassword.toggle();
showPassword.toggle();
}
Future<void> onLogin() async { Future<void> onLogin() async {
if (!basicValidator.validateForm()) return; if (!basicValidator.validateForm()) return;
isLoading.value = true; showSplash.value = true;
try { try {
final loginData = basicValidator.getData(); final loginData = basicValidator.getData();
@ -60,39 +57,30 @@ class LoginController extends MyController {
final errors = await AuthService.loginUser(loginData); final errors = await AuthService.loginUser(loginData);
if (errors != null) { if (errors != null) {
logSafe(
"Login failed for user: ${loginData['username']} with errors: $errors",
level: LogLevel.warning);
showAppSnackbar( showAppSnackbar(
title: "Login Failed", title: "Login Failed",
message: "Username or password is incorrect", message: "Username or password is incorrect",
type: SnackbarType.error, type: SnackbarType.error,
); );
basicValidator.addErrors(errors); basicValidator.addErrors(errors);
basicValidator.validateForm(); basicValidator.validateForm();
basicValidator.clearErrors(); basicValidator.clearErrors();
} else { } else {
await _handleRememberMe(); await _handleRememberMe();
// Enable remote logging after successful login
enableRemoteLogging(); enableRemoteLogging();
logSafe("✅ Remote logging enabled after login.");
logSafe("Login successful for user: ${loginData['username']}"); logSafe("Login successful for user: ${loginData['username']}");
Get.offNamed('/select-tenant');
Get.toNamed('/select-tenant');
} }
} catch (e, stacktrace) { } catch (e, stacktrace) {
logSafe("Exception during login",
level: LogLevel.error, error: e, stackTrace: stacktrace);
showAppSnackbar( showAppSnackbar(
title: "Login Error", title: "Login Error",
message: "An unexpected error occurred", message: "An unexpected error occurred",
type: SnackbarType.error, type: SnackbarType.error,
); );
logSafe("Exception during login",
level: LogLevel.error, error: e, stackTrace: stacktrace);
} finally { } finally {
isLoading.value = false; showSplash.value = false;
} }
} }
@ -124,11 +112,7 @@ class LoginController extends MyController {
} }
} }
void goToForgotPassword() { void goToForgotPassword() => Get.toNamed('/auth/forgot_password');
Get.toNamed('/auth/forgot_password');
}
void gotoRegister() { void gotoRegister() => Get.offAndToNamed('/auth/register_account');
Get.offAndToNamed('/auth/register_account');
}
} }

View File

@ -9,19 +9,28 @@ import 'package:marco/controller/permission_controller.dart';
class TenantSelectionController extends GetxController { class TenantSelectionController extends GetxController {
final TenantService _tenantService = TenantService(); final TenantService _tenantService = TenantService();
// Tenant list
final tenants = <Tenant>[].obs; final tenants = <Tenant>[].obs;
// Loading state
final isLoading = false.obs; final isLoading = false.obs;
// Selected tenant ID
final selectedTenantId = RxnString(); final selectedTenantId = RxnString();
// Flag to indicate auto-selection (for splash screen)
final isAutoSelecting = false.obs;
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
loadTenants(); loadTenants();
} }
/// Load tenants and perform smart auto-selection /// Load tenants and handle auto-selection
Future<void> loadTenants() async { Future<void> loadTenants() async {
isLoading.value = true; isLoading.value = true;
isAutoSelecting.value = true; // show splash during auto-selection
try { try {
final data = await _tenantService.getTenants(); final data = await _tenantService.getTenants();
if (data == null || data.isEmpty) { if (data == null || data.isEmpty) {
@ -34,21 +43,23 @@ class TenantSelectionController extends GetxController {
final recentTenantId = LocalStorage.getRecentTenantId(); final recentTenantId = LocalStorage.getRecentTenantId();
// Auto-select if only one tenant
if (tenants.length == 1) { if (tenants.length == 1) {
await _selectTenant(tenants.first.id); await _selectTenant(tenants.first.id);
} else if (recentTenantId != null) { }
final recentTenant = tenants // Auto-select recent tenant if available
.firstWhereOrNull((t) => t.id == recentTenantId); else if (recentTenantId != null) {
final recentTenant =
tenants.firstWhereOrNull((t) => t.id == recentTenantId);
if (recentTenant != null) { if (recentTenant != null) {
await _selectTenant(recentTenant.id); await _selectTenant(recentTenant.id);
} else { } else {
selectedTenantId.value = null; _clearSelection();
TenantService.currentTenant = null;
} }
} else { }
selectedTenantId.value = null; // No auto-selection
TenantService.currentTenant = null; else {
_clearSelection();
} }
} catch (e, st) { } catch (e, st) {
logSafe("❌ Exception in loadTenants", logSafe("❌ Exception in loadTenants",
@ -60,22 +71,24 @@ class TenantSelectionController extends GetxController {
); );
} finally { } finally {
isLoading.value = false; isLoading.value = false;
isAutoSelecting.value = false; // hide splash
} }
} }
/// Manually select tenant (user triggered) /// User manually selects a tenant
Future<void> onTenantSelected(String tenantId) async { Future<void> onTenantSelected(String tenantId) async {
isAutoSelecting.value = true;
await _selectTenant(tenantId); await _selectTenant(tenantId);
isAutoSelecting.value = false;
} }
/// Internal tenant selection logic /// Internal tenant selection logic
Future<void> _selectTenant(String tenantId) async { Future<void> _selectTenant(String tenantId) async {
try { try {
isLoading.value = true; isLoading.value = true;
final success = await _tenantService.selectTenant(tenantId); final success = await _tenantService.selectTenant(tenantId);
if (!success) { if (!success) {
logSafe("❌ Tenant selection failed for: $tenantId",
level: LogLevel.warning);
showAppSnackbar( showAppSnackbar(
title: "Error", title: "Error",
message: "Unable to select organization. Please try again.", message: "Unable to select organization. Please try again.",
@ -84,39 +97,27 @@ class TenantSelectionController extends GetxController {
return; return;
} }
// Update tenant & persist
final selectedTenant = tenants.firstWhere((t) => t.id == tenantId); final selectedTenant = tenants.firstWhere((t) => t.id == tenantId);
TenantService.setSelectedTenant(selectedTenant); TenantService.setSelectedTenant(selectedTenant);
selectedTenantId.value = tenantId; selectedTenantId.value = tenantId;
// Persist recent tenant
await LocalStorage.setRecentTenantId(tenantId); await LocalStorage.setRecentTenantId(tenantId);
logSafe("✅ Tenant selection successful: $tenantId"); // Load permissions if token exists
// 🔹 Load permissions after tenant selection (null-safe)
final token = LocalStorage.getJwtToken(); final token = LocalStorage.getJwtToken();
if (token != null && token.isNotEmpty) { if (token != null && token.isNotEmpty) {
if (!Get.isRegistered<PermissionController>()) { if (!Get.isRegistered<PermissionController>()) {
Get.put(PermissionController()); Get.put(PermissionController());
logSafe("✅ PermissionController injected after tenant selection.");
} }
await Get.find<PermissionController>().loadData(token); await Get.find<PermissionController>().loadData(token);
} else {
logSafe("⚠️ JWT token is null. Cannot load permissions.",
level: LogLevel.warning);
} }
// Navigate to dashboard // Navigate **before changing isAutoSelecting**
Get.offAllNamed('/dashboard'); await Get.offAllNamed('/dashboard');
showAppSnackbar( // Then hide splash
title: "Success", isAutoSelecting.value = false;
message: "Organization selected successfully.", } catch (e) {
type: SnackbarType.success,
);
} catch (e, st) {
logSafe("❌ Exception in _selectTenant",
level: LogLevel.error, error: e, stackTrace: st);
showAppSnackbar( showAppSnackbar(
title: "Error", title: "Error",
message: "An unexpected error occurred while selecting organization.", message: "An unexpected error occurred while selecting organization.",
@ -126,4 +127,10 @@ class TenantSelectionController extends GetxController {
isLoading.value = false; isLoading.value = false;
} }
} }
/// Clear tenant selection
void _clearSelection() {
selectedTenantId.value = null;
TenantService.currentTenant = null;
}
} }

View File

@ -123,20 +123,24 @@ class _EmailLoginFormState extends State<EmailLoginForm> with UIMixin {
), ),
MySpacing.height(28), MySpacing.height(28),
Center( Center(
child: MyButton.rounded( child: Obx(() {
onPressed: controller.onLogin, final isLoading = controller.isLoading.value;
return MyButton.rounded(
onPressed: isLoading
? null
: controller.onLogin,
elevation: 2, elevation: 2,
padding: MySpacing.xy(80, 16), padding: MySpacing.xy(80, 16),
borderRadiusAll: 10, borderRadiusAll: 10,
backgroundColor: contentTheme.brandRed, backgroundColor: contentTheme.brandRed,
child: MyText.labelLarge( child: MyText.labelLarge(
'Login', isLoading ? 'Logging in...' : 'Login',
fontWeight: 700, fontWeight: 700,
color: contentTheme.onPrimary, color: contentTheme.onPrimary,
), ),
);
}),
), ),
),
], ],
), ),
); );

View File

@ -37,7 +37,7 @@ class _LoginScreenState extends State<LoginScreen> with UIMixin {
builder: (_) { builder: (_) {
return Obx(() { return Obx(() {
if (controller.isLoading.value) { if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator()); return const Center(child: LinearProgressIndicator());
} }
return Form( return Form(

120
lib/view/splash_screen.dart Normal file
View File

@ -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<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 1),
vsync: this,
)..repeat(reverse: true);
_animation = Tween<double>(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(),
],
),
),
),
);
}
}

View File

@ -6,6 +6,7 @@ import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/images.dart'; import 'package:marco/images.dart';
import 'package:marco/controller/tenant/tenant_selection_controller.dart'; import 'package:marco/controller/tenant/tenant_selection_controller.dart';
import 'package:marco/view/splash_screen.dart';
class TenantSelectionScreen extends StatefulWidget { class TenantSelectionScreen extends StatefulWidget {
const TenantSelectionScreen({super.key}); const TenantSelectionScreen({super.key});
@ -20,25 +21,23 @@ class _TenantSelectionScreenState extends State<TenantSelectionScreen>
late final AnimationController _logoAnimController; late final AnimationController _logoAnimController;
late final Animation<double> _logoAnimation; late final Animation<double> _logoAnimation;
final bool _isBetaEnvironment = ApiEndpoints.baseUrl.contains("stage"); final bool _isBetaEnvironment = ApiEndpoints.baseUrl.contains("stage");
bool _isLoading = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_controller = _controller = Get.put(TenantSelectionController());
Get.put(TenantSelectionController());
_logoAnimController = AnimationController( _logoAnimController = AnimationController(
vsync: this, vsync: this,
duration: const Duration(milliseconds: 800), duration: const Duration(milliseconds: 800),
); );
_logoAnimation = CurvedAnimation( _logoAnimation = CurvedAnimation(
parent: _logoAnimController, parent: _logoAnimController,
curve: Curves.easeOutBack, curve: Curves.easeOutBack,
); );
_logoAnimController.forward();
// 🔥 Tell controller this is tenant selection screen _logoAnimController.forward();
_controller.loadTenants();
} }
@override @override
@ -49,13 +48,17 @@ class _TenantSelectionScreenState extends State<TenantSelectionScreen>
} }
Future<void> _onTenantSelected(String tenantId) async { Future<void> _onTenantSelected(String tenantId) async {
setState(() => _isLoading = true);
await _controller.onTenantSelected(tenantId); await _controller.onTenantSelected(tenantId);
setState(() => _isLoading = false);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Obx(() {
// Splash screen for auto-selection
if (_controller.isAutoSelecting.value) {
return const SplashScreen();
}
return Scaffold( return Scaffold(
body: Stack( body: Stack(
children: [ children: [
@ -82,10 +85,9 @@ class _TenantSelectionScreenState extends State<TenantSelectionScreen>
const _BetaBadge(), const _BetaBadge(),
], ],
const SizedBox(height: 36), const SizedBox(height: 36),
// Tenant list directly reacts to controller
TenantCardList( TenantCardList(
controller: _controller, controller: _controller,
isLoading: _isLoading, isLoading: _controller.isLoading.value,
onTenantSelected: _onTenantSelected, onTenantSelected: _onTenantSelected,
), ),
], ],
@ -101,9 +103,11 @@ class _TenantSelectionScreenState extends State<TenantSelectionScreen>
], ],
), ),
); );
});
} }
} }
/// Animated Logo Widget
class _AnimatedLogo extends StatelessWidget { class _AnimatedLogo extends StatelessWidget {
final Animation<double> animation; final Animation<double> animation;
const _AnimatedLogo({required this.animation}); const _AnimatedLogo({required this.animation});
@ -133,6 +137,7 @@ class _AnimatedLogo extends StatelessWidget {
} }
} }
/// Welcome Texts
class _WelcomeTexts extends StatelessWidget { class _WelcomeTexts extends StatelessWidget {
const _WelcomeTexts(); const _WelcomeTexts();
@ -149,7 +154,7 @@ class _WelcomeTexts extends StatelessWidget {
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
MyText( MyText(
"Please select which dashboard you want to explore!.", "Please select which dashboard you want to explore!",
fontSize: 14, fontSize: 14,
color: Colors.black54, color: Colors.black54,
textAlign: TextAlign.center, textAlign: TextAlign.center,
@ -159,6 +164,7 @@ class _WelcomeTexts extends StatelessWidget {
} }
} }
/// Beta Badge
class _BetaBadge extends StatelessWidget { class _BetaBadge extends StatelessWidget {
const _BetaBadge(); const _BetaBadge();
@ -180,6 +186,7 @@ class _BetaBadge extends StatelessWidget {
} }
} }
/// Tenant Card List
class TenantCardList extends StatelessWidget { class TenantCardList extends StatelessWidget {
final TenantSelectionController controller; final TenantSelectionController controller;
final bool isLoading; final bool isLoading;
@ -195,10 +202,9 @@ class TenantCardList extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Obx(() { return Obx(() {
if (controller.isLoading.value || isLoading) { if (controller.isLoading.value || isLoading) {
return const Center( return const Center(child: CircularProgressIndicator(strokeWidth: 2));
child: CircularProgressIndicator(strokeWidth: 2),
);
} }
if (controller.tenants.isEmpty) { if (controller.tenants.isEmpty) {
return Center( return Center(
child: MyText( child: MyText(
@ -209,10 +215,8 @@ class TenantCardList extends StatelessWidget {
), ),
); );
} }
// Show tenant even if only 1 tenant
return SingleChildScrollView( return Column(
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
...controller.tenants.map( ...controller.tenants.map(
@ -224,8 +228,8 @@ class TenantCardList extends StatelessWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
TextButton.icon( TextButton.icon(
onPressed: () => Get.back(), onPressed: () => Get.back(),
icon: const Icon(Icons.arrow_back, icon:
size: 20, color: Colors.redAccent), const Icon(Icons.arrow_back, size: 20, color: Colors.redAccent),
label: MyText( label: MyText(
'Back to Login', 'Back to Login',
color: Colors.red, color: Colors.red,
@ -234,12 +238,12 @@ class TenantCardList extends StatelessWidget {
), ),
), ),
], ],
),
); );
}); });
} }
} }
/// Single Tenant Card
class _TenantCard extends StatelessWidget { class _TenantCard extends StatelessWidget {
final dynamic tenant; final dynamic tenant;
final VoidCallback onTap; final VoidCallback onTap;
@ -252,9 +256,7 @@ class _TenantCard extends StatelessWidget {
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
child: Card( child: Card(
elevation: 3, elevation: 3,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
borderRadius: BorderRadius.circular(5),
),
margin: const EdgeInsets.only(bottom: 20), margin: const EdgeInsets.only(bottom: 20),
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@ -290,11 +292,7 @@ class _TenantCard extends StatelessWidget {
], ],
), ),
), ),
Icon( const Icon(Icons.arrow_forward_ios, size: 24, color: Colors.red),
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 { class TenantLogo extends StatelessWidget {
final String? logoImage; final String? logoImage;
const TenantLogo({required this.logoImage}); const TenantLogo({required this.logoImage});
@ -310,9 +309,7 @@ class TenantLogo extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (logoImage == null || logoImage!.isEmpty) { if (logoImage == null || logoImage!.isEmpty) {
return Center( return Center(child: Icon(Icons.business, color: Colors.grey.shade600));
child: Icon(Icons.business, color: Colors.grey.shade600),
);
} }
if (logoImage!.startsWith("data:image")) { if (logoImage!.startsWith("data:image")) {
try { try {
@ -320,9 +317,7 @@ class TenantLogo extends StatelessWidget {
final bytes = base64Decode(base64Str); final bytes = base64Decode(base64Str);
return Image.memory(bytes, fit: BoxFit.cover); return Image.memory(bytes, fit: BoxFit.cover);
} catch (_) { } catch (_) {
return Center( return Center(child: Icon(Icons.business, color: Colors.grey.shade600));
child: Icon(Icons.business, color: Colors.grey.shade600),
);
} }
} else { } else {
return Image.network( return Image.network(
@ -336,6 +331,7 @@ class TenantLogo extends StatelessWidget {
} }
} }
/// Red Wave Background
class _RedWaveBackground extends StatelessWidget { class _RedWaveBackground extends StatelessWidget {
final Color brandRed; final Color brandRed;
const _RedWaveBackground({required this.brandRed}); const _RedWaveBackground({required this.brandRed});
@ -351,7 +347,6 @@ class _RedWaveBackground extends StatelessWidget {
class _WavePainter extends CustomPainter { class _WavePainter extends CustomPainter {
final Color brandRed; final Color brandRed;
_WavePainter(this.brandRed); _WavePainter(this.brandRed);
@override @override