login design changed

This commit is contained in:
Surendiran 2026-07-15 09:52:29 +05:30
parent afe9a0736d
commit 9856ff08b2
14 changed files with 976 additions and 527 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

BIN
assets/images/bcpl_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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),
),
),
);
}
}

View File

@ -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)!;
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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 {

View File

@ -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,

View File

@ -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,

View File

@ -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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 917 B

After

Width:  |  Height:  |  Size: 89 KiB