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 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<void> 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');
}

View File

@ -9,19 +9,28 @@ import 'package:marco/controller/permission_controller.dart';
class TenantSelectionController extends GetxController {
final TenantService _tenantService = TenantService();
// Tenant list
final tenants = <Tenant>[].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<void> 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<void> onTenantSelected(String tenantId) async {
isAutoSelecting.value = true;
await _selectTenant(tenantId);
isAutoSelecting.value = false;
}
/// Internal tenant selection logic
Future<void> _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<PermissionController>()) {
Get.put(PermissionController());
logSafe("✅ PermissionController injected after tenant selection.");
}
await Get.find<PermissionController>().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;
}
}

View File

@ -123,20 +123,24 @@ class _EmailLoginFormState extends State<EmailLoginForm> with UIMixin {
),
MySpacing.height(28),
Center(
child: MyButton.rounded(
onPressed: controller.onLogin,
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(
'Login',
isLoading ? 'Logging in...' : 'Login',
fontWeight: 700,
color: contentTheme.onPrimary,
),
);
}),
),
),
],
),
);

View File

@ -37,7 +37,7 @@ class _LoginScreenState extends State<LoginScreen> with UIMixin {
builder: (_) {
return Obx(() {
if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator());
return const Center(child: LinearProgressIndicator());
}
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/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<TenantSelectionScreen>
late final AnimationController _logoAnimController;
late final Animation<double> _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,13 +48,17 @@ class _TenantSelectionScreenState extends State<TenantSelectionScreen>
}
Future<void> _onTenantSelected(String tenantId) async {
setState(() => _isLoading = true);
await _controller.onTenantSelected(tenantId);
setState(() => _isLoading = false);
}
@override
Widget build(BuildContext context) {
return Obx(() {
// Splash screen for auto-selection
if (_controller.isAutoSelecting.value) {
return const SplashScreen();
}
return Scaffold(
body: Stack(
children: [
@ -82,10 +85,9 @@ class _TenantSelectionScreenState extends State<TenantSelectionScreen>
const _BetaBadge(),
],
const SizedBox(height: 36),
// Tenant list directly reacts to controller
TenantCardList(
controller: _controller,
isLoading: _isLoading,
isLoading: _controller.isLoading.value,
onTenantSelected: _onTenantSelected,
),
],
@ -101,9 +103,11 @@ class _TenantSelectionScreenState extends State<TenantSelectionScreen>
],
),
);
});
}
}
/// Animated Logo Widget
class _AnimatedLogo extends StatelessWidget {
final Animation<double> 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,10 +215,8 @@ class TenantCardList extends StatelessWidget {
),
);
}
// Show tenant even if only 1 tenant
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 24),
child: Column(
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
...controller.tenants.map(
@ -224,8 +228,8 @@ class TenantCardList extends StatelessWidget {
const SizedBox(height: 16),
TextButton.icon(
onPressed: () => Get.back(),
icon: const Icon(Icons.arrow_back,
size: 20, color: Colors.redAccent),
icon:
const Icon(Icons.arrow_back, size: 20, color: Colors.redAccent),
label: MyText(
'Back to Login',
color: Colors.red,
@ -234,12 +238,12 @@ class TenantCardList extends StatelessWidget {
),
),
],
),
);
});
}
}
/// 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