login design changed
This commit is contained in:
parent
afe9a0736d
commit
9856ff08b2
BIN
assets/images/bcpl_favicon.png
Normal file
BIN
assets/images/bcpl_favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
BIN
assets/images/bcpl_logo.png
Normal file
BIN
assets/images/bcpl_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
@ -5,6 +5,15 @@ class AppConstants {
|
||||
static const String appVersion = '1.0.0';
|
||||
static const String appTagline = 'Asset Management System';
|
||||
|
||||
/// Bundled BCPL branding used when company/branding logo is unset.
|
||||
static const String defaultLogoAsset = 'assets/images/bcpl_logo.png';
|
||||
|
||||
/// Bundled BCPL favicon used when company favicon is unset.
|
||||
static const String defaultFaviconAsset = 'assets/images/bcpl_favicon.png';
|
||||
|
||||
/// Web root favicon served from [web/favicon.png] (tab icon before app boot).
|
||||
static const String defaultWebFavicon = 'favicon.png';
|
||||
|
||||
static const int defaultPageSize = 20;
|
||||
static const int maxPageSize = 100;
|
||||
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../constants/app_constants.dart';
|
||||
import '../constants/storage_keys.dart';
|
||||
import 'favicon_updater.dart';
|
||||
import 'media_url.dart';
|
||||
|
||||
class FaviconStore {
|
||||
FaviconStore(this._prefs);
|
||||
@ -20,9 +22,10 @@ class FaviconStore {
|
||||
}
|
||||
|
||||
void apply() {
|
||||
final url = read();
|
||||
if (url.isNotEmpty) {
|
||||
updateFavicon(url);
|
||||
}
|
||||
final stored = read();
|
||||
final href = resolveFaviconHref(
|
||||
stored.isNotEmpty ? stored : AppConstants.defaultFaviconAsset,
|
||||
);
|
||||
updateFavicon(href);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import '../config/environment.dart';
|
||||
import '../constants/app_constants.dart';
|
||||
|
||||
/// Turns API-relative media paths into absolute URLs the UI can load.
|
||||
///
|
||||
/// Leaves `http(s)://` and `data:` URIs unchanged.
|
||||
/// Leaves `http(s)://`, `data:`, `blob:`, and local `assets/` paths unchanged.
|
||||
String? resolveMediaUrl(String? path) {
|
||||
if (path == null) return null;
|
||||
final trimmed = path.trim();
|
||||
@ -10,7 +11,8 @@ String? resolveMediaUrl(String? path) {
|
||||
if (trimmed.startsWith('data:') ||
|
||||
trimmed.startsWith('http://') ||
|
||||
trimmed.startsWith('https://') ||
|
||||
trimmed.startsWith('blob:')) {
|
||||
trimmed.startsWith('blob:') ||
|
||||
trimmed.startsWith('assets/')) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
@ -18,3 +20,17 @@ String? resolveMediaUrl(String? path) {
|
||||
if (trimmed.startsWith('/')) return '$origin$trimmed';
|
||||
return '$origin/$trimmed';
|
||||
}
|
||||
|
||||
/// Resolves a media path for the browser favicon API (`setAppFavicon`).
|
||||
///
|
||||
/// Flutter web serves pubspec assets under an extra `assets/` prefix.
|
||||
String resolveFaviconHref(String? path) {
|
||||
final resolved = resolveMediaUrl(path);
|
||||
if (resolved == null || resolved.isEmpty) {
|
||||
return AppConstants.defaultWebFavicon;
|
||||
}
|
||||
if (resolved.startsWith('assets/')) {
|
||||
return 'assets/$resolved';
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
import '../../../../core/config/dev_config.dart';
|
||||
import '../../../../core/constants/app_constants.dart';
|
||||
import '../../../../core/constants/enums.dart';
|
||||
import '../../../../core/constants/route_constants.dart';
|
||||
import '../../../../core/constants/storage_keys.dart';
|
||||
import '../../../../core/theme/theme_provider.dart';
|
||||
@ -11,10 +14,11 @@ import '../../../../core/utils/validators.dart';
|
||||
import '../../../../shared/models/user_model.dart';
|
||||
import '../../../../shared/providers/auth_provider.dart';
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
import '../widgets/bcpl_logo.dart';
|
||||
import '../../../../shared/widgets/app_toast.dart';
|
||||
import '../../../../shared/widgets/sidebar_logo.dart';
|
||||
import '../../../settings/presentation/providers/settings_provider.dart';
|
||||
import '../widgets/login_colors.dart';
|
||||
import '../widgets/login_hero_panel.dart';
|
||||
import '../../../../shared/widgets/app_toast.dart';
|
||||
|
||||
class LoginScreen extends ConsumerStatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
@ -54,7 +58,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
final prefs = ref.read(sharedPreferencesProvider);
|
||||
if (_rememberMe) {
|
||||
await prefs.setBool(StorageKeys.rememberMe, true);
|
||||
await prefs.setString(StorageKeys.rememberedEmail, _emailController.text.trim());
|
||||
await prefs.setString(
|
||||
StorageKeys.rememberedEmail,
|
||||
_emailController.text.trim(),
|
||||
);
|
||||
} else {
|
||||
await prefs.remove(StorageKeys.rememberMe);
|
||||
await prefs.remove(StorageKeys.rememberedEmail);
|
||||
@ -88,62 +95,74 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
context.go(RouteConstants.dashboard);
|
||||
} else {
|
||||
final error = ref.read(authStateProvider).error;
|
||||
showAppToastFromSnackBar(context,
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
SnackBar(content: Text(error ?? 'Invalid credentials')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
InputDecoration _fieldDecoration(LoginColors colors, String hint) {
|
||||
InputDecoration _fieldDecoration({
|
||||
required LoginColors colors,
|
||||
required String label,
|
||||
required IconData icon,
|
||||
Widget? suffix,
|
||||
}) {
|
||||
final radius = BorderRadius.circular(12);
|
||||
return InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: TextStyle(color: colors.iconMuted, fontSize: 14),
|
||||
labelText: label,
|
||||
floatingLabelBehavior: FloatingLabelBehavior.auto,
|
||||
labelStyle: GoogleFonts.inter(
|
||||
fontSize: 14.5,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
floatingLabelStyle: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colors.primary,
|
||||
),
|
||||
prefixIcon: Icon(icon, color: colors.iconMuted, size: 18),
|
||||
suffixIcon: suffix,
|
||||
filled: true,
|
||||
fillColor: colors.fieldFillColor,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
contentPadding: const EdgeInsets.fromLTRB(12, 18, 12, 14),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: BorderSide(color: colors.borderColor),
|
||||
borderRadius: radius,
|
||||
borderSide: BorderSide(color: colors.outlineSoft, width: 1.5),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: BorderSide(color: colors.borderColor),
|
||||
borderRadius: radius,
|
||||
borderSide: BorderSide(color: colors.outlineSoft, width: 1.5),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: BorderSide(color: colors.primaryAction, width: 1.5),
|
||||
borderRadius: radius,
|
||||
borderSide: BorderSide(color: colors.primary, width: 1.5),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: BorderSide(color: colors.colorScheme.error),
|
||||
borderRadius: radius,
|
||||
borderSide: BorderSide(color: colors.colorScheme.error, width: 1.5),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderRadius: radius,
|
||||
borderSide: BorderSide(color: colors.colorScheme.error, width: 1.5),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFieldLabel(LoginColors colors, String label) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colors.labelColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoginCard(LoginColors colors) {
|
||||
final companyProfile = ref.watch(appSettingsProvider).companyProfile;
|
||||
final branding = ref.watch(brandingProvider);
|
||||
final logoUrl = resolveSidebarLogoUrl(
|
||||
companyProfileLogo: companyProfile.logoUrl,
|
||||
brandingLogo: branding.logoUrl,
|
||||
);
|
||||
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxWidth: 440),
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.cardBackground,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: colors.surface.withValues(alpha: 0.92),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
border: Border.all(color: colors.outlineSoft),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colors.cardShadowColor,
|
||||
@ -152,82 +171,107 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 44),
|
||||
padding: const EdgeInsets.fromLTRB(36, 40, 36, 32),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Center(child: BcplLogo(height: 52)),
|
||||
const SizedBox(height: 28),
|
||||
Center(
|
||||
child: SidebarLogo(
|
||||
logoUrl: logoUrl,
|
||||
height: 64,
|
||||
width: 220,
|
||||
fit: BoxFit.contain,
|
||||
showBackground: false,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Welcome back 👋',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
'Welcome back',
|
||||
style: GoogleFonts.manrope(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.3,
|
||||
color: colors.headingColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
'Sign in to continue to your account',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
'Sign in to continue to your BCPL workspace.',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13.5,
|
||||
color: colors.subtitleColor,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
_buildFieldLabel(colors, 'Email'),
|
||||
const SizedBox(height: 26),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autofillHints: const [AutofillHints.email],
|
||||
validator: Validators.email,
|
||||
style: TextStyle(color: colors.headingColor),
|
||||
decoration: _fieldDecoration(colors, 'Enter your email').copyWith(
|
||||
prefixIcon: Icon(Icons.mail_outline, color: colors.iconMuted, size: 20),
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14.5,
|
||||
color: colors.headingColor,
|
||||
),
|
||||
decoration: _fieldDecoration(
|
||||
colors: colors,
|
||||
label: 'Email address',
|
||||
icon: Icons.mail_outline_rounded,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildFieldLabel(colors, 'Password'),
|
||||
const SizedBox(height: 18),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
autofillHints: const [AutofillHints.password],
|
||||
validator: (v) => Validators.required(v, fieldName: 'Password'),
|
||||
style: TextStyle(color: colors.headingColor),
|
||||
decoration: _fieldDecoration(colors, 'Enter your password').copyWith(
|
||||
prefixIcon: Icon(Icons.lock_outline, color: colors.iconMuted, size: 20),
|
||||
suffixIcon: IconButton(
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14.5,
|
||||
color: colors.headingColor,
|
||||
),
|
||||
decoration: _fieldDecoration(
|
||||
colors: colors,
|
||||
label: 'Password',
|
||||
icon: Icons.lock_outline_rounded,
|
||||
suffix: IconButton(
|
||||
tooltip: _obscurePassword ? 'Show password' : 'Hide password',
|
||||
icon: Icon(
|
||||
_obscurePassword ? Icons.visibility_outlined : Icons.visibility_off_outlined,
|
||||
_obscurePassword
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
color: colors.iconMuted,
|
||||
size: 20,
|
||||
size: 18,
|
||||
),
|
||||
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
|
||||
onPressed: () =>
|
||||
setState(() => _obscurePassword = !_obscurePassword),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 36,
|
||||
width: 36,
|
||||
height: 34,
|
||||
width: 34,
|
||||
child: Checkbox(
|
||||
value: _rememberMe,
|
||||
activeColor: colors.primaryAction,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
|
||||
activeColor: colors.primary,
|
||||
checkColor: colors.onPrimary,
|
||||
side: BorderSide(color: colors.outline, width: 1.5),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
onChanged: (v) => setState(() => _rememberMe = v ?? false),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Remember me',
|
||||
style: TextStyle(fontSize: 13, color: colors.labelColor),
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
color: colors.labelColor,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
@ -238,85 +282,126 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: const Text(
|
||||
child: Text(
|
||||
'Forgot password?',
|
||||
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const SizedBox(height: 14),
|
||||
Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colors.primaryAction,
|
||||
foregroundColor: colors.colorScheme.onPrimary,
|
||||
minimumSize: const Size(double.infinity, 50),
|
||||
backgroundColor: colors.primary,
|
||||
foregroundColor: colors.onPrimary,
|
||||
disabledBackgroundColor:
|
||||
colors.primary.withValues(alpha: 0.75),
|
||||
minimumSize: const Size(double.infinity, 52),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
||||
shadowColor: colors.primary.withValues(alpha: 0.35),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
textStyle: GoogleFonts.inter(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: AppButton(
|
||||
label: 'Sign In',
|
||||
label: _isLoading ? 'Signing in…' : 'Sign in',
|
||||
isLoading: _isLoading,
|
||||
onPressed: _login,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
const SizedBox(height: 26),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Divider(color: colors.borderColor)),
|
||||
Expanded(child: Divider(color: colors.outlineSoft, height: 1)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Text(
|
||||
'Secure access to your ERP system',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: colors.subtitleColor,
|
||||
'SECURE ACCESS',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 11.5,
|
||||
letterSpacing: 0.6,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(child: Divider(color: colors.borderColor)),
|
||||
Expanded(child: Divider(color: colors.outlineSoft, height: 1)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.shield_outlined, size: 16, color: colors.subtitleColor),
|
||||
Icon(
|
||||
Icons.verified_user_outlined,
|
||||
size: 14,
|
||||
color: colors.success,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Your data is protected and secure.',
|
||||
style: TextStyle(
|
||||
'Your data is protected and encrypted',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
color: colors.subtitleColor,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
Text(
|
||||
'${AppConstants.appName} · v${AppConstants.appVersion}',
|
||||
textAlign: TextAlign.center,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 11,
|
||||
letterSpacing: 0.3,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (DevConfig.screenPreviewEnabled) ...[
|
||||
const SizedBox(height: 24),
|
||||
Divider(color: colors.borderColor),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 20),
|
||||
Divider(color: colors.outlineSoft),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
'Login API unavailable? Browse all screens without signing in:',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
color: colors.subtitleColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
AppButton(
|
||||
label: 'Explore All Screens',
|
||||
isOutlined: true,
|
||||
onPressed: () {
|
||||
ref.read(authStateProvider.notifier).loginAsDemo();
|
||||
context.go(RouteConstants.screenGallery);
|
||||
},
|
||||
Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: colors.primary,
|
||||
side: BorderSide(color: colors.outlineSoft),
|
||||
minimumSize: const Size(double.infinity, 46),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: AppButton(
|
||||
label: 'Explore All Screens',
|
||||
isOutlined: true,
|
||||
onPressed: () {
|
||||
ref.read(authStateProvider.notifier).loginAsDemo();
|
||||
context.go(RouteConstants.screenGallery);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
@ -325,45 +410,190 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildThemeToggle(LoginColors colors) {
|
||||
final mode = ref.watch(themeModeProvider);
|
||||
final isDarkActive = Theme.of(context).brightness == Brightness.dark ||
|
||||
mode == ThemeModeOption.dark;
|
||||
|
||||
return Material(
|
||||
color: colors.surface,
|
||||
shape: StadiumBorder(
|
||||
side: BorderSide(color: colors.outlineSoft),
|
||||
),
|
||||
elevation: 1,
|
||||
shadowColor: colors.cardShadowColor,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_ThemeChip(
|
||||
icon: Icons.light_mode_outlined,
|
||||
active: !isDarkActive,
|
||||
colors: colors,
|
||||
onTap: () => ref
|
||||
.read(themeModeProvider.notifier)
|
||||
.setThemeMode(ThemeModeOption.light),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
_ThemeChip(
|
||||
icon: Icons.dark_mode_outlined,
|
||||
active: isDarkActive,
|
||||
colors: colors,
|
||||
onTap: () => ref
|
||||
.read(themeModeProvider.notifier)
|
||||
.setThemeMode(ThemeModeOption.dark),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAmbient(LoginColors colors) {
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [colors.bgGradA, colors.bg],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: -120,
|
||||
right: -100,
|
||||
child: _Blob(color: colors.primaryContainer, size: 420),
|
||||
),
|
||||
Positioned(
|
||||
bottom: -140,
|
||||
left: -120,
|
||||
child: _Blob(color: colors.tertiaryContainer, size: 360),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isWide = context.isDesktop;
|
||||
final colors = LoginColors.of(context);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colors.pageBackground,
|
||||
body: isWide
|
||||
? Row(
|
||||
backgroundColor: colors.bg,
|
||||
body: Stack(
|
||||
children: [
|
||||
if (isWide)
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(flex: 105, child: LoginHeroPanel()),
|
||||
Expanded(
|
||||
flex: 42,
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: _buildLoginCard(colors),
|
||||
),
|
||||
flex: 100,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(child: _buildAmbient(colors)),
|
||||
Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 32,
|
||||
vertical: 40,
|
||||
),
|
||||
child: _buildLoginCard(colors),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Expanded(
|
||||
flex: 58,
|
||||
child: LoginHeroPanel(),
|
||||
),
|
||||
],
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 48, 24, 24),
|
||||
child: Center(child: _buildLoginCard(colors)),
|
||||
else
|
||||
Stack(
|
||||
children: [
|
||||
Positioned.fill(child: _buildAmbient(colors)),
|
||||
SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 340,
|
||||
width: double.infinity,
|
||||
child: LoginHeroPanel(compact: true),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 24, 16, 40),
|
||||
child: Center(child: _buildLoginCard(colors)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: 420,
|
||||
child: LoginHeroPanel(compact: true),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
top: 22,
|
||||
right: 22,
|
||||
child: SafeArea(child: _buildThemeToggle(colors)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThemeChip extends StatelessWidget {
|
||||
const _ThemeChip({
|
||||
required this.icon,
|
||||
required this.active,
|
||||
required this.colors,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final bool active;
|
||||
final LoginColors colors;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: active ? colors.primary : Colors.transparent,
|
||||
shape: const CircleBorder(),
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
width: 34,
|
||||
height: 34,
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 17,
|
||||
color: active ? colors.onPrimary : colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Blob extends StatelessWidget {
|
||||
const _Blob({required this.color, required this.size});
|
||||
|
||||
final Color color;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IgnorePointer(
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: color.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
|
||||
/// Theme-aware colors for the login screen and its widgets.
|
||||
/// Theme-aware palette for the login screen, driven by app branding colors.
|
||||
class LoginColors {
|
||||
LoginColors.of(BuildContext context)
|
||||
: theme = Theme.of(context),
|
||||
@ -13,48 +13,79 @@ class LoginColors {
|
||||
final ColorScheme colorScheme;
|
||||
final bool isDark;
|
||||
|
||||
Color get pageBackground => theme.scaffoldBackgroundColor;
|
||||
/// Mid/deep brand tone suitable for mixing dark panel shades.
|
||||
/// In dark ColorSchemes, [ColorScheme.primary] is a light accent — use
|
||||
/// [ColorScheme.inversePrimary] instead so the hero panel stays rich & dark.
|
||||
Color get _brandSeed =>
|
||||
isDark ? colorScheme.inversePrimary : colorScheme.primary;
|
||||
|
||||
Color get cardBackground =>
|
||||
isDark ? colorScheme.surface : AppColors.card;
|
||||
// —— Accent (from branding ColorScheme) ——
|
||||
Color get primary => colorScheme.primary;
|
||||
Color get primaryDim => Color.lerp(
|
||||
colorScheme.primary,
|
||||
isDark ? Colors.white : Colors.black,
|
||||
0.12,
|
||||
)!;
|
||||
Color get onPrimary => colorScheme.onPrimary;
|
||||
Color get primaryContainer => colorScheme.primaryContainer;
|
||||
Color get tertiaryContainer => colorScheme.tertiaryContainer;
|
||||
Color get success => isDark
|
||||
? Color.lerp(AppColors.success, Colors.white, 0.35)!
|
||||
: AppColors.success;
|
||||
|
||||
Color get headingColor => colorScheme.onSurface;
|
||||
|
||||
Color get subtitleColor => colorScheme.onSurfaceVariant;
|
||||
|
||||
Color get labelColor => isDark
|
||||
? colorScheme.onSurface.withValues(alpha: 0.9)
|
||||
: const Color(0xFF334155);
|
||||
|
||||
Color get linkColor => colorScheme.primary;
|
||||
|
||||
Color get borderColor => isDark
|
||||
? colorScheme.outline.withValues(alpha: 0.35)
|
||||
: const Color(0xFFE2E8F0);
|
||||
|
||||
Color get fieldFillColor =>
|
||||
isDark ? colorScheme.surfaceContainerHighest : Colors.white;
|
||||
|
||||
Color get iconMuted => colorScheme.onSurfaceVariant;
|
||||
|
||||
Color get primaryAction => colorScheme.primary;
|
||||
|
||||
Color get cardShadowColor => isDark
|
||||
? Colors.black.withValues(alpha: 0.35)
|
||||
: Colors.black.withValues(alpha: 0.06);
|
||||
|
||||
Color get moduleNodeBackground => isDark
|
||||
? colorScheme.surfaceContainerHigh
|
||||
: Colors.white;
|
||||
|
||||
Color get platformColor => isDark
|
||||
// —— Surfaces ——
|
||||
Color get bg =>
|
||||
isDark ? colorScheme.surface : theme.scaffoldBackgroundColor;
|
||||
Color get bgGradA => Color.lerp(
|
||||
colorScheme.surface,
|
||||
colorScheme.primaryContainer,
|
||||
isDark ? 0.18 : 0.22,
|
||||
)!;
|
||||
Color get bgGradB => Color.lerp(
|
||||
colorScheme.surface,
|
||||
colorScheme.primaryContainer,
|
||||
isDark ? 0.32 : 0.35,
|
||||
)!;
|
||||
Color get surface =>
|
||||
isDark ? colorScheme.surfaceContainerLow : AppColors.card;
|
||||
Color get surfaceContainer => isDark
|
||||
? colorScheme.surfaceContainerHighest
|
||||
: const Color(0xFFF8FAFC);
|
||||
: colorScheme.surfaceContainerHighest.withValues(alpha: 0.55);
|
||||
Color get surfaceContainerHigh => colorScheme.surfaceContainerHigh;
|
||||
|
||||
Color get decorCubeColor =>
|
||||
colorScheme.primaryContainer.withValues(alpha: isDark ? 0.35 : 0.55);
|
||||
|
||||
Color get connectionLineColor => colorScheme.outline.withValues(
|
||||
alpha: isDark ? 0.45 : 0.55,
|
||||
Color get onSurface => colorScheme.onSurface;
|
||||
Color get onSurfaceVariant => colorScheme.onSurfaceVariant;
|
||||
Color get outline => colorScheme.outline;
|
||||
Color get outlineSoft => colorScheme.outlineVariant.withValues(
|
||||
alpha: isDark ? 0.55 : 0.9,
|
||||
);
|
||||
|
||||
// —— Brand panel (always a deep brand surface + light text) ——
|
||||
Color get panelA => _deepBrand(0.72);
|
||||
Color get panelB => _deepBrand(0.50);
|
||||
Color get panelC => _deepBrand(0.28);
|
||||
Color get panelText => Color.lerp(Colors.white, _brandSeed, 0.06)!;
|
||||
Color get panelTextDim => panelText.withValues(alpha: 0.72);
|
||||
|
||||
// —— Form aliases ——
|
||||
Color get pageBackground => bg;
|
||||
Color get cardBackground => surface;
|
||||
Color get headingColor => onSurface;
|
||||
Color get subtitleColor => onSurfaceVariant;
|
||||
Color get labelColor => onSurfaceVariant;
|
||||
Color get linkColor => primary;
|
||||
Color get borderColor => outlineSoft;
|
||||
Color get fieldFillColor => surfaceContainer;
|
||||
Color get iconMuted => onSurfaceVariant;
|
||||
Color get primaryAction => primary;
|
||||
Color get cardShadowColor => isDark
|
||||
? Colors.black.withValues(alpha: 0.5)
|
||||
: colorScheme.primary.withValues(alpha: 0.14);
|
||||
Color get moduleNodeBackground => surfaceContainerHigh;
|
||||
Color get platformColor => surfaceContainer;
|
||||
Color get decorCubeColor => primaryContainer;
|
||||
Color get connectionLineColor => panelText.withValues(alpha: 0.18);
|
||||
|
||||
Color _deepBrand(double blackMix) =>
|
||||
Color.lerp(_brandSeed, const Color(0xFF050508), blackMix)!;
|
||||
}
|
||||
|
||||
@ -4,354 +4,315 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'login_colors.dart';
|
||||
|
||||
/// Isometric ERP module diagram for the login hero panel.
|
||||
class LoginHeroIllustration extends StatelessWidget {
|
||||
/// Orbiting ERP module illustration for the login brand panel.
|
||||
class LoginHeroIllustration extends StatefulWidget {
|
||||
const LoginHeroIllustration({super.key});
|
||||
|
||||
@override
|
||||
State<LoginHeroIllustration> createState() => _LoginHeroIllustrationState();
|
||||
}
|
||||
|
||||
class _LoginHeroIllustrationState extends State<LoginHeroIllustration>
|
||||
with TickerProviderStateMixin {
|
||||
late final AnimationController _spin;
|
||||
late final AnimationController _spinReverse;
|
||||
late final AnimationController _pulse;
|
||||
late final AnimationController _float;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_spin = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 40),
|
||||
)..repeat();
|
||||
_spinReverse = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 55),
|
||||
)..repeat();
|
||||
_pulse = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 3400),
|
||||
)..repeat(reverse: true);
|
||||
_float = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 4500),
|
||||
)..repeat(reverse: true);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_spin.dispose();
|
||||
_spinReverse.dispose();
|
||||
_pulse.dispose();
|
||||
_float.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = LoginColors.of(context);
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final size = math.min(constraints.maxWidth, constraints.maxHeight);
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
width: size,
|
||||
height: size * 0.85,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned(
|
||||
top: size * 0.02,
|
||||
left: size * 0.08,
|
||||
child: _DecorCube(
|
||||
size: size * 0.06,
|
||||
opacity: 0.35,
|
||||
color: colors.decorCubeColor,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: size * 0.12,
|
||||
right: size * 0.06,
|
||||
child: _DecorCube(
|
||||
size: size * 0.05,
|
||||
opacity: 0.25,
|
||||
color: colors.decorCubeColor,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: size * 0.08,
|
||||
left: size * 0.04,
|
||||
child: _DecorCube(
|
||||
size: size * 0.04,
|
||||
opacity: 0.3,
|
||||
color: colors.decorCubeColor,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: size * 0.18,
|
||||
right: size * 0.1,
|
||||
child: _DecorCube(
|
||||
size: size * 0.07,
|
||||
opacity: 0.2,
|
||||
color: colors.decorCubeColor,
|
||||
),
|
||||
),
|
||||
..._modulePositions(size).map(
|
||||
(module) => Positioned(
|
||||
left: module.dx,
|
||||
top: module.dy,
|
||||
child: _ModuleNode(
|
||||
icon: module.icon,
|
||||
label: module.label,
|
||||
size: size,
|
||||
colors: colors,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: size * 0.32,
|
||||
top: size * 0.28,
|
||||
child: CustomPaint(
|
||||
size: Size(size * 0.36, size * 0.36),
|
||||
painter: _ConnectionLinesPainter(
|
||||
modules: _modulePositions(size),
|
||||
center: Offset(size * 0.18, size * 0.18),
|
||||
lineColor: colors.connectionLineColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: size * 0.34,
|
||||
top: size * 0.3,
|
||||
child: _ErpCore(size: size * 0.32, colors: colors),
|
||||
),
|
||||
],
|
||||
),
|
||||
final w = constraints.maxWidth;
|
||||
final h = constraints.maxHeight;
|
||||
final size = Size(w, h);
|
||||
final center = Offset(w / 2, h / 2);
|
||||
|
||||
final nodes = <_OrbitNode>[
|
||||
_OrbitNode(
|
||||
position: Offset(w * 0.13, h * 0.32),
|
||||
icon: Icons.inventory_2_outlined,
|
||||
delay: 0,
|
||||
),
|
||||
_OrbitNode(
|
||||
position: Offset(w * 0.87, h * 0.25),
|
||||
icon: Icons.storefront_outlined,
|
||||
delay: 0.25,
|
||||
),
|
||||
_OrbitNode(
|
||||
position: Offset(w * 0.13, h * 0.73),
|
||||
icon: Icons.shopping_cart_outlined,
|
||||
delay: 0.5,
|
||||
),
|
||||
_OrbitNode(
|
||||
position: Offset(w * 0.87, h * 0.77),
|
||||
icon: Icons.description_outlined,
|
||||
delay: 0.7,
|
||||
),
|
||||
_OrbitNode(
|
||||
position: Offset(w * 0.5, h * 0.85),
|
||||
icon: Icons.schedule_outlined,
|
||||
delay: 0.15,
|
||||
),
|
||||
];
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: Listenable.merge([_spin, _spinReverse, _pulse, _float]),
|
||||
builder: (context, _) {
|
||||
return CustomPaint(
|
||||
size: size,
|
||||
painter: _OrbitPainter(
|
||||
colors: colors,
|
||||
center: center,
|
||||
ringProgress: _spin.value,
|
||||
ring2Progress: _spinReverse.value,
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
for (final node in nodes)
|
||||
_FloatingNode(
|
||||
node: node,
|
||||
colors: colors,
|
||||
floatValue: _float.value,
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: Transform.scale(
|
||||
scale: 1 + (_pulse.value * 0.045),
|
||||
child: _Hub(colors: colors),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static List<_ModuleData> _modulePositions(double size) {
|
||||
return [
|
||||
_ModuleData(
|
||||
dx: size * 0.02,
|
||||
dy: size * 0.08,
|
||||
icon: Icons.inventory_2_outlined,
|
||||
label: 'Inventory',
|
||||
),
|
||||
_ModuleData(
|
||||
dx: size * 0.68,
|
||||
dy: size * 0.02,
|
||||
icon: Icons.bar_chart_rounded,
|
||||
label: 'Sales',
|
||||
),
|
||||
_ModuleData(
|
||||
dx: size * 0.74,
|
||||
dy: size * 0.38,
|
||||
icon: Icons.receipt_long_outlined,
|
||||
label: 'Accounts',
|
||||
),
|
||||
_ModuleData(
|
||||
dx: size * 0.58,
|
||||
dy: size * 0.62,
|
||||
icon: Icons.pie_chart_outline_rounded,
|
||||
label: 'Reports',
|
||||
),
|
||||
_ModuleData(
|
||||
dx: size * 0.02,
|
||||
dy: size * 0.52,
|
||||
icon: Icons.shopping_cart_outlined,
|
||||
label: 'Purchases',
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class _ModuleData {
|
||||
const _ModuleData({
|
||||
required this.dx,
|
||||
required this.dy,
|
||||
class _OrbitNode {
|
||||
const _OrbitNode({
|
||||
required this.position,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.delay,
|
||||
});
|
||||
|
||||
final double dx;
|
||||
final double dy;
|
||||
final Offset position;
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final double delay;
|
||||
}
|
||||
|
||||
class _ErpCore extends StatelessWidget {
|
||||
const _ErpCore({required this.size, required this.colors});
|
||||
|
||||
final double size;
|
||||
final LoginColors colors;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: size,
|
||||
height: size * 0.55,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
colors.primaryAction,
|
||||
Color.lerp(colors.primaryAction, Colors.black, 0.25)!,
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(size * 0.08),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colors.primaryAction.withValues(alpha: 0.35),
|
||||
blurRadius: size * 0.12,
|
||||
offset: Offset(0, size * 0.06),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'ERP',
|
||||
style: TextStyle(
|
||||
color: colors.colorScheme.onPrimary,
|
||||
fontSize: size * 0.22,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: size * 1.15,
|
||||
height: size * 0.14,
|
||||
margin: EdgeInsets.only(top: size * 0.04),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.platformColor,
|
||||
borderRadius: BorderRadius.circular(size * 0.04),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colors.cardShadowColor,
|
||||
blurRadius: size * 0.06,
|
||||
offset: Offset(0, size * 0.02),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: size * 1.35,
|
||||
height: size * 0.1,
|
||||
margin: EdgeInsets.only(top: size * 0.02),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.moduleNodeBackground,
|
||||
borderRadius: BorderRadius.circular(size * 0.03),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colors.cardShadowColor,
|
||||
blurRadius: size * 0.04,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ModuleNode extends StatelessWidget {
|
||||
const _ModuleNode({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.size,
|
||||
class _FloatingNode extends StatelessWidget {
|
||||
const _FloatingNode({
|
||||
required this.node,
|
||||
required this.colors,
|
||||
required this.floatValue,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final double size;
|
||||
final _OrbitNode node;
|
||||
final LoginColors colors;
|
||||
final double floatValue;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final nodeSize = size * 0.14;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: nodeSize,
|
||||
height: nodeSize,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.moduleNodeBackground,
|
||||
borderRadius: BorderRadius.circular(nodeSize * 0.22),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colors.cardShadowColor,
|
||||
blurRadius: nodeSize * 0.15,
|
||||
offset: Offset(0, nodeSize * 0.06),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: colors.primaryAction,
|
||||
size: nodeSize * 0.45,
|
||||
final phase = (floatValue + node.delay) % 1.0;
|
||||
final dy = math.sin(phase * math.pi) * -6;
|
||||
|
||||
return Positioned(
|
||||
left: node.position.dx - 26,
|
||||
top: node.position.dy - 26 + dy,
|
||||
child: Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: colors.panelText.withValues(alpha: 0.08),
|
||||
border: Border.all(
|
||||
color: colors.panelText.withValues(alpha: 0.22),
|
||||
),
|
||||
),
|
||||
SizedBox(height: nodeSize * 0.12),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: nodeSize * 0.28,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colors.subtitleColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
child: Icon(node.icon, size: 20, color: colors.panelText),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DecorCube extends StatelessWidget {
|
||||
const _DecorCube({
|
||||
required this.size,
|
||||
required this.opacity,
|
||||
required this.color,
|
||||
});
|
||||
class _Hub extends StatelessWidget {
|
||||
const _Hub({required this.colors});
|
||||
|
||||
final double size;
|
||||
final double opacity;
|
||||
final Color color;
|
||||
final LoginColors colors;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Transform.rotate(
|
||||
angle: math.pi / 6,
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: opacity),
|
||||
borderRadius: BorderRadius.circular(size * 0.15),
|
||||
return Container(
|
||||
width: 84,
|
||||
height: 84,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [colors.primaryDim, colors.panelB],
|
||||
),
|
||||
border: Border.all(color: colors.panelText.withValues(alpha: 0.3)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.25),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
'ERP',
|
||||
style: TextStyle(
|
||||
color: colors.panelText,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 17,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConnectionLinesPainter extends CustomPainter {
|
||||
_ConnectionLinesPainter({
|
||||
required this.modules,
|
||||
class _OrbitPainter extends CustomPainter {
|
||||
_OrbitPainter({
|
||||
required this.colors,
|
||||
required this.center,
|
||||
required this.lineColor,
|
||||
required this.ringProgress,
|
||||
required this.ring2Progress,
|
||||
});
|
||||
|
||||
final List<_ModuleData> modules;
|
||||
final LoginColors colors;
|
||||
final Offset center;
|
||||
final Color lineColor;
|
||||
final double ringProgress;
|
||||
final double ring2Progress;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = lineColor
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.5;
|
||||
final stroke = colors.panelText.withValues(alpha: 0.14);
|
||||
final line = colors.panelText.withValues(alpha: 0.16);
|
||||
|
||||
for (final module in modules) {
|
||||
final moduleCenter = Offset(
|
||||
module.dx + size.width * 0.07 - (size.width * 0.32),
|
||||
module.dy + size.height * 0.05 - (size.height * 0.28),
|
||||
void drawRing(double rx, double ry, double progress, List<double> dash) {
|
||||
canvas.save();
|
||||
canvas.translate(center.dx, center.dy);
|
||||
canvas.rotate(progress * math.pi * 2);
|
||||
final rect = Rect.fromCenter(
|
||||
center: Offset.zero,
|
||||
width: rx * 2,
|
||||
height: ry * 2,
|
||||
);
|
||||
_drawDashedLine(canvas, center, moduleCenter, paint);
|
||||
final paint = Paint()
|
||||
..color = stroke
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1;
|
||||
_drawDashedOval(canvas, rect, paint, dash[0], dash[1]);
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
drawRing(size.width * 0.43, size.height * 0.42, ringProgress, const [3, 7]);
|
||||
drawRing(
|
||||
size.width * 0.33,
|
||||
size.height * 0.31,
|
||||
-ring2Progress,
|
||||
const [2, 9],
|
||||
);
|
||||
|
||||
final nodes = [
|
||||
Offset(size.width * 0.13, size.height * 0.32),
|
||||
Offset(size.width * 0.87, size.height * 0.25),
|
||||
Offset(size.width * 0.13, size.height * 0.73),
|
||||
Offset(size.width * 0.87, size.height * 0.77),
|
||||
Offset(size.width * 0.5, size.height * 0.85),
|
||||
];
|
||||
|
||||
final paint = Paint()
|
||||
..color = line
|
||||
..strokeWidth = 1
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
for (final n in nodes) {
|
||||
_drawDashedLine(canvas, n, center, paint, 2, 5);
|
||||
}
|
||||
}
|
||||
|
||||
void _drawDashedLine(Canvas canvas, Offset start, Offset end, Paint paint) {
|
||||
const dashWidth = 5.0;
|
||||
const dashSpace = 4.0;
|
||||
final distance = (end - start).distance;
|
||||
if (distance == 0) return;
|
||||
void _drawDashedOval(
|
||||
Canvas canvas,
|
||||
Rect rect,
|
||||
Paint paint,
|
||||
double dash,
|
||||
double gap,
|
||||
) {
|
||||
final path = Path()..addOval(rect);
|
||||
for (final metric in path.computeMetrics()) {
|
||||
var distance = 0.0;
|
||||
while (distance < metric.length) {
|
||||
final next = math.min(distance + dash, metric.length);
|
||||
canvas.drawPath(metric.extractPath(distance, next), paint);
|
||||
distance = next + gap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final direction = (end - start) / distance;
|
||||
void _drawDashedLine(
|
||||
Canvas canvas,
|
||||
Offset a,
|
||||
Offset b,
|
||||
Paint paint,
|
||||
double dash,
|
||||
double gap,
|
||||
) {
|
||||
final distance = (b - a).distance;
|
||||
if (distance == 0) return;
|
||||
final dir = (b - a) / distance;
|
||||
var drawn = 0.0;
|
||||
while (drawn < distance) {
|
||||
final dashEnd = drawn + dashWidth > distance ? distance : drawn + dashWidth;
|
||||
canvas.drawLine(
|
||||
start + direction * drawn,
|
||||
start + direction * dashEnd,
|
||||
paint,
|
||||
);
|
||||
drawn += dashWidth + dashSpace;
|
||||
final end = math.min(drawn + dash, distance);
|
||||
canvas.drawLine(a + dir * drawn, a + dir * end, paint);
|
||||
drawn = end + gap;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _ConnectionLinesPainter oldDelegate) {
|
||||
return oldDelegate.lineColor != lineColor;
|
||||
bool shouldRepaint(covariant _OrbitPainter oldDelegate) {
|
||||
return oldDelegate.ringProgress != ringProgress ||
|
||||
oldDelegate.ring2Progress != ring2Progress ||
|
||||
oldDelegate.colors.isDark != colors.isDark;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
import 'login_colors.dart';
|
||||
import 'login_hero_illustration.dart';
|
||||
|
||||
/// Marketing hero section shown on the right side of the login screen.
|
||||
/// Left marketing / brand panel on the login screen.
|
||||
class LoginHeroPanel extends StatelessWidget {
|
||||
const LoginHeroPanel({super.key, this.compact = false});
|
||||
|
||||
@ -13,46 +14,226 @@ class LoginHeroPanel extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final colors = LoginColors.of(context);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: compact ? 24 : 48,
|
||||
vertical: compact ? 32 : 48,
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: const Alignment(-0.8, -1),
|
||||
end: const Alignment(0.9, 1),
|
||||
colors: [colors.panelA, colors.panelB, colors.panelC],
|
||||
stops: const [0, 0.45, 1],
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: compact ? MainAxisAlignment.start : MainAxisAlignment.center,
|
||||
child: Stack(
|
||||
children: [
|
||||
Text(
|
||||
'Smart. Integrated. Efficient.',
|
||||
style: TextStyle(
|
||||
fontSize: compact ? 28 : 40,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colors.headingColor,
|
||||
height: 1.2,
|
||||
letterSpacing: -0.5,
|
||||
Positioned.fill(
|
||||
child: CustomPaint(painter: _DotGridPainter(colors.panelText)),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: compact ? 24 : 56,
|
||||
vertical: compact ? 28 : 48,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_BrandMark(colors: colors),
|
||||
SizedBox(height: compact ? 20 : 28),
|
||||
Text(
|
||||
'Smart. Integrated.\nEfficient.',
|
||||
style: GoogleFonts.manrope(
|
||||
fontSize: compact ? 28 : 40,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1.12,
|
||||
letterSpacing: -0.5,
|
||||
color: colors.panelText,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Text(
|
||||
'One workspace for inventory, sales, purchases, accounts and reporting — built for teams who move fast.',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: compact ? 13.5 : 15,
|
||||
height: 1.6,
|
||||
color: colors.panelTextDim,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: compact ? 16 : 24),
|
||||
Expanded(
|
||||
child: LoginHeroIllustration(),
|
||||
),
|
||||
if (!compact) ...[
|
||||
const SizedBox(height: 12),
|
||||
_FeatureRow(colors: colors),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: compact ? 12 : 16),
|
||||
Text(
|
||||
'Manage your business operations seamlessly with BCPL ERP.',
|
||||
style: TextStyle(
|
||||
fontSize: compact ? 15 : 17,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: colors.subtitleColor,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
SizedBox(height: compact ? 24 : 40),
|
||||
if (compact)
|
||||
const SizedBox(
|
||||
height: 260,
|
||||
width: double.infinity,
|
||||
child: LoginHeroIllustration(),
|
||||
)
|
||||
else
|
||||
const Expanded(child: LoginHeroIllustration()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BrandMark extends StatelessWidget {
|
||||
const _BrandMark({required this.colors});
|
||||
|
||||
final LoginColors colors;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.panelText.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: colors.panelText.withValues(alpha: 0.18),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'BC',
|
||||
style: GoogleFonts.manrope(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 16,
|
||||
color: colors.panelText,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'BCPL',
|
||||
style: GoogleFonts.manrope(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 20,
|
||||
letterSpacing: 0.5,
|
||||
color: colors.panelText,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'BHARAT ERP',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 11,
|
||||
letterSpacing: 1.5,
|
||||
color: colors.panelTextDim,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FeatureRow extends StatelessWidget {
|
||||
const _FeatureRow({required this.colors});
|
||||
|
||||
final LoginColors colors;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final items = [
|
||||
(
|
||||
Icons.layers_outlined,
|
||||
'Unified data',
|
||||
'One source of truth across every module',
|
||||
),
|
||||
(
|
||||
Icons.bolt_outlined,
|
||||
'Real-time sync',
|
||||
'Every team sees the same live numbers',
|
||||
),
|
||||
(
|
||||
Icons.bar_chart_rounded,
|
||||
'Clear reporting',
|
||||
'Dashboards built for daily decisions',
|
||||
),
|
||||
];
|
||||
|
||||
return Wrap(
|
||||
spacing: 28,
|
||||
runSpacing: 16,
|
||||
children: [
|
||||
for (final item in items)
|
||||
SizedBox(
|
||||
width: 160,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.panelText.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
border: Border.all(
|
||||
color: colors.panelText.withValues(alpha: 0.16),
|
||||
),
|
||||
),
|
||||
child: Icon(item.$1, size: 15, color: colors.panelText),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.$2,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colors.panelText,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
item.$3,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 11.5,
|
||||
height: 1.4,
|
||||
color: colors.panelTextDim,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DotGridPainter extends CustomPainter {
|
||||
_DotGridPainter(this.color);
|
||||
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = color.withValues(alpha: 0.06)
|
||||
..style = PaintingStyle.fill;
|
||||
const step = 22.0;
|
||||
for (var x = 0.0; x < size.width; x += step) {
|
||||
for (var y = 0.0; y < size.height; y += step) {
|
||||
canvas.drawCircle(Offset(x, y), 1, paint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _DotGridPainter oldDelegate) =>
|
||||
oldDelegate.color != color;
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/constants/app_constants.dart';
|
||||
import '../../../../core/errors/failure.dart';
|
||||
import '../../../../core/network/api_handler.dart';
|
||||
import '../../../../core/network/dio_client.dart';
|
||||
@ -97,9 +98,9 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
|
||||
final resolved = resolveMediaUrl(faviconUrl) ?? faviconUrl?.trim();
|
||||
final value = (resolved == null || resolved.isEmpty) ? null : resolved;
|
||||
await _faviconStore.write(value);
|
||||
if (value != null) {
|
||||
updateFavicon(value);
|
||||
}
|
||||
updateFavicon(
|
||||
resolveFaviconHref(value ?? AppConstants.defaultFaviconAsset),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
|
||||
@ -4,6 +4,7 @@ import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/constants/app_constants.dart';
|
||||
import '../../../../core/network/api_handler.dart';
|
||||
import '../../../../core/theme/theme_provider.dart';
|
||||
import '../../../../core/utils/favicon_store.dart';
|
||||
@ -12,11 +13,11 @@ import '../../../../core/utils/validators.dart';
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/app_side_panel.dart';
|
||||
import '../../../../shared/widgets/app_text_field.dart';
|
||||
import '../../../../shared/widgets/app_toast.dart';
|
||||
import '../../../../shared/widgets/sidebar_logo.dart';
|
||||
import '../../domain/entities/app_settings.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../widgets/settings_widgets.dart';
|
||||
import '../../../../shared/widgets/app_toast.dart';
|
||||
|
||||
class CompanyProfileSettingsScreen extends ConsumerStatefulWidget {
|
||||
const CompanyProfileSettingsScreen({super.key});
|
||||
@ -389,7 +390,7 @@ class _CompanyProfileSettingsScreenState
|
||||
Center(
|
||||
child: SidebarLogo(
|
||||
logoUrl: _logoUrlController.text.trim().isEmpty
|
||||
? null
|
||||
? AppConstants.defaultLogoAsset
|
||||
: _logoUrlController.text.trim(),
|
||||
width: 240,
|
||||
height: 80,
|
||||
@ -428,7 +429,7 @@ class _CompanyProfileSettingsScreenState
|
||||
Center(
|
||||
child: SidebarLogo(
|
||||
logoUrl: _faviconUrlController.text.trim().isEmpty
|
||||
? null
|
||||
? AppConstants.defaultFaviconAsset
|
||||
: _faviconUrlController.text.trim(),
|
||||
width: 64,
|
||||
height: 64,
|
||||
|
||||
@ -42,10 +42,6 @@ class AppTopNav extends ConsumerWidget {
|
||||
companyProfileLogo: companyProfile.logoUrl,
|
||||
brandingLogo: branding.logoUrl,
|
||||
);
|
||||
final companyName = resolveSidebarTitle(
|
||||
companyName: companyProfile.companyName,
|
||||
fallback: AppConstants.appName,
|
||||
);
|
||||
|
||||
return Material(
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
@ -62,15 +58,14 @@ class AppTopNav extends ConsumerWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SidebarLogo(logoUrl: logoUrl, size: 32),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
companyName,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
SidebarLogo(
|
||||
logoUrl: logoUrl,
|
||||
height: 48,
|
||||
width: 140,
|
||||
fit: BoxFit.contain,
|
||||
showBackground: false,
|
||||
),
|
||||
const SizedBox(width: 32),
|
||||
const SizedBox(width: 24),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
|
||||
@ -3,9 +3,10 @@ import 'dart:convert';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/constants/app_constants.dart';
|
||||
import '../../core/utils/media_url.dart';
|
||||
|
||||
/// Displays company logo in the sidebar from URL, data URI, or fallback icon.
|
||||
/// Displays company logo in the sidebar from URL, data URI, asset, or default.
|
||||
class SidebarLogo extends StatelessWidget {
|
||||
const SidebarLogo({
|
||||
super.key,
|
||||
@ -30,10 +31,16 @@ class SidebarLogo extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final fallback = Icon(
|
||||
Icons.inventory_2_outlined,
|
||||
size: (_width < _height ? _width : _height) * 0.55,
|
||||
color: theme.colorScheme.primary,
|
||||
final fallback = Image.asset(
|
||||
AppConstants.defaultLogoAsset,
|
||||
width: _width,
|
||||
height: _height,
|
||||
fit: fit,
|
||||
errorBuilder: (_, __, ___) => Icon(
|
||||
Icons.inventory_2_outlined,
|
||||
size: (_width < _height ? _width : _height) * 0.55,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
);
|
||||
|
||||
final content = _buildLogoContent(fallback);
|
||||
@ -57,7 +64,17 @@ class SidebarLogo extends StatelessWidget {
|
||||
Widget _buildLogoContent(Widget fallback) {
|
||||
final url = resolveMediaUrl(logoUrl);
|
||||
if (url == null || url.isEmpty) {
|
||||
return Center(child: fallback);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (url.startsWith('assets/')) {
|
||||
return Image.asset(
|
||||
url,
|
||||
width: _width,
|
||||
height: _height,
|
||||
fit: fit,
|
||||
errorBuilder: (_, __, ___) => fallback,
|
||||
);
|
||||
}
|
||||
|
||||
if (url.startsWith('data:image')) {
|
||||
@ -68,10 +85,10 @@ class SidebarLogo extends StatelessWidget {
|
||||
width: _width,
|
||||
height: _height,
|
||||
fit: fit,
|
||||
errorBuilder: (_, __, ___) => Center(child: fallback),
|
||||
errorBuilder: (_, __, ___) => fallback,
|
||||
);
|
||||
} catch (_) {
|
||||
return Center(child: fallback);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
@ -88,32 +105,36 @@ class SidebarLogo extends StatelessWidget {
|
||||
child: const CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
errorWidget: (_, __, ___) => Center(child: fallback),
|
||||
errorWidget: (_, __, ___) => fallback,
|
||||
);
|
||||
}
|
||||
|
||||
return Center(child: fallback);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves logo URL from company profile settings or branding config.
|
||||
String? resolveSidebarLogoUrl({
|
||||
/// Resolves logo URL from company profile, branding, or bundled default.
|
||||
String resolveSidebarLogoUrl({
|
||||
required String companyProfileLogo,
|
||||
required String? brandingLogo,
|
||||
}) {
|
||||
final fromProfile = resolveMediaUrl(companyProfileLogo);
|
||||
if (fromProfile != null && fromProfile.isNotEmpty) return fromProfile;
|
||||
return resolveMediaUrl(brandingLogo);
|
||||
final fromBranding = resolveMediaUrl(brandingLogo);
|
||||
if (fromBranding != null && fromBranding.isNotEmpty) return fromBranding;
|
||||
return AppConstants.defaultLogoAsset;
|
||||
}
|
||||
|
||||
/// Resolves favicon URL from company profile settings or local cache.
|
||||
String? resolveSidebarFaviconUrl({
|
||||
/// Resolves favicon URL from company profile, local cache, or bundled default.
|
||||
String resolveSidebarFaviconUrl({
|
||||
required String companyProfileFavicon,
|
||||
String? storedFavicon,
|
||||
}) {
|
||||
final fromProfile = resolveMediaUrl(companyProfileFavicon);
|
||||
if (fromProfile != null && fromProfile.isNotEmpty) return fromProfile;
|
||||
return resolveMediaUrl(storedFavicon);
|
||||
final fromStore = resolveMediaUrl(storedFavicon);
|
||||
if (fromStore != null && fromStore.isNotEmpty) return fromStore;
|
||||
return AppConstants.defaultFaviconAsset;
|
||||
}
|
||||
|
||||
/// Resolves sidebar title from company name or app tagline.
|
||||
|
||||
BIN
web/favicon.png
BIN
web/favicon.png
Binary file not shown.
|
Before Width: | Height: | Size: 917 B After Width: | Height: | Size: 89 KiB |
Loading…
Reference in New Issue
Block a user