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 appVersion = '1.0.0';
static const String appTagline = 'Asset Management System'; 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 defaultPageSize = 20;
static const int maxPageSize = 100; static const int maxPageSize = 100;

View File

@ -1,7 +1,9 @@
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../constants/app_constants.dart';
import '../constants/storage_keys.dart'; import '../constants/storage_keys.dart';
import 'favicon_updater.dart'; import 'favicon_updater.dart';
import 'media_url.dart';
class FaviconStore { class FaviconStore {
FaviconStore(this._prefs); FaviconStore(this._prefs);
@ -20,9 +22,10 @@ class FaviconStore {
} }
void apply() { void apply() {
final url = read(); final stored = read();
if (url.isNotEmpty) { final href = resolveFaviconHref(
updateFavicon(url); stored.isNotEmpty ? stored : AppConstants.defaultFaviconAsset,
} );
updateFavicon(href);
} }
} }

View File

@ -1,8 +1,9 @@
import '../config/environment.dart'; import '../config/environment.dart';
import '../constants/app_constants.dart';
/// Turns API-relative media paths into absolute URLs the UI can load. /// 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) { String? resolveMediaUrl(String? path) {
if (path == null) return null; if (path == null) return null;
final trimmed = path.trim(); final trimmed = path.trim();
@ -10,7 +11,8 @@ String? resolveMediaUrl(String? path) {
if (trimmed.startsWith('data:') || if (trimmed.startsWith('data:') ||
trimmed.startsWith('http://') || trimmed.startsWith('http://') ||
trimmed.startsWith('https://') || trimmed.startsWith('https://') ||
trimmed.startsWith('blob:')) { trimmed.startsWith('blob:') ||
trimmed.startsWith('assets/')) {
return trimmed; return trimmed;
} }
@ -18,3 +20,17 @@ String? resolveMediaUrl(String? path) {
if (trimmed.startsWith('/')) return '$origin$trimmed'; if (trimmed.startsWith('/')) return '$origin$trimmed';
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/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../../core/config/dev_config.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/route_constants.dart';
import '../../../../core/constants/storage_keys.dart'; import '../../../../core/constants/storage_keys.dart';
import '../../../../core/theme/theme_provider.dart'; import '../../../../core/theme/theme_provider.dart';
@ -11,10 +14,11 @@ import '../../../../core/utils/validators.dart';
import '../../../../shared/models/user_model.dart'; import '../../../../shared/models/user_model.dart';
import '../../../../shared/providers/auth_provider.dart'; import '../../../../shared/providers/auth_provider.dart';
import '../../../../shared/widgets/app_button.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_colors.dart';
import '../widgets/login_hero_panel.dart'; import '../widgets/login_hero_panel.dart';
import '../../../../shared/widgets/app_toast.dart';
class LoginScreen extends ConsumerStatefulWidget { class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key}); const LoginScreen({super.key});
@ -54,7 +58,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
final prefs = ref.read(sharedPreferencesProvider); final prefs = ref.read(sharedPreferencesProvider);
if (_rememberMe) { if (_rememberMe) {
await prefs.setBool(StorageKeys.rememberMe, true); await prefs.setBool(StorageKeys.rememberMe, true);
await prefs.setString(StorageKeys.rememberedEmail, _emailController.text.trim()); await prefs.setString(
StorageKeys.rememberedEmail,
_emailController.text.trim(),
);
} else { } else {
await prefs.remove(StorageKeys.rememberMe); await prefs.remove(StorageKeys.rememberMe);
await prefs.remove(StorageKeys.rememberedEmail); await prefs.remove(StorageKeys.rememberedEmail);
@ -88,62 +95,74 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
context.go(RouteConstants.dashboard); context.go(RouteConstants.dashboard);
} else { } else {
final error = ref.read(authStateProvider).error; final error = ref.read(authStateProvider).error;
showAppToastFromSnackBar(context, showAppToastFromSnackBar(
context,
SnackBar(content: Text(error ?? 'Invalid credentials')), 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( return InputDecoration(
hintText: hint, labelText: label,
hintStyle: TextStyle(color: colors.iconMuted, fontSize: 14), 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, filled: true,
fillColor: colors.fieldFillColor, fillColor: colors.fieldFillColor,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), contentPadding: const EdgeInsets.fromLTRB(12, 18, 12, 14),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10), borderRadius: radius,
borderSide: BorderSide(color: colors.borderColor), borderSide: BorderSide(color: colors.outlineSoft, width: 1.5),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10), borderRadius: radius,
borderSide: BorderSide(color: colors.borderColor), borderSide: BorderSide(color: colors.outlineSoft, width: 1.5),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10), borderRadius: radius,
borderSide: BorderSide(color: colors.primaryAction, width: 1.5), borderSide: BorderSide(color: colors.primary, width: 1.5),
), ),
errorBorder: OutlineInputBorder( errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10), borderRadius: radius,
borderSide: BorderSide(color: colors.colorScheme.error), borderSide: BorderSide(color: colors.colorScheme.error, width: 1.5),
), ),
focusedErrorBorder: OutlineInputBorder( focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10), borderRadius: radius,
borderSide: BorderSide(color: colors.colorScheme.error, width: 1.5), 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) { 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( return Container(
constraints: const BoxConstraints(maxWidth: 440), constraints: const BoxConstraints(maxWidth: 420),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.cardBackground, color: colors.surface.withValues(alpha: 0.92),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(28),
border: Border.all(color: colors.outlineSoft),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: colors.cardShadowColor, 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( child: Form(
key: _formKey, key: _formKey,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const Center(child: BcplLogo(height: 52)), Center(
const SizedBox(height: 28), child: SidebarLogo(
logoUrl: logoUrl,
height: 64,
width: 220,
fit: BoxFit.contain,
showBackground: false,
),
),
const SizedBox(height: 20),
Text( Text(
'Welcome back 👋', 'Welcome back',
textAlign: TextAlign.center, style: GoogleFonts.manrope(
style: TextStyle( fontSize: 24,
fontSize: 22, fontWeight: FontWeight.w800,
fontWeight: FontWeight.w700, letterSpacing: -0.3,
color: colors.headingColor, color: colors.headingColor,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 5),
Text( Text(
'Sign in to continue to your account', 'Sign in to continue to your BCPL workspace.',
textAlign: TextAlign.center, style: GoogleFonts.inter(
style: TextStyle( fontSize: 13.5,
fontSize: 14,
color: colors.subtitleColor, color: colors.subtitleColor,
height: 1.4,
), ),
), ),
const SizedBox(height: 32), const SizedBox(height: 26),
_buildFieldLabel(colors, 'Email'),
TextFormField( TextFormField(
controller: _emailController, controller: _emailController,
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email], autofillHints: const [AutofillHints.email],
validator: Validators.email, validator: Validators.email,
style: TextStyle(color: colors.headingColor), style: GoogleFonts.inter(
decoration: _fieldDecoration(colors, 'Enter your email').copyWith( fontSize: 14.5,
prefixIcon: Icon(Icons.mail_outline, color: colors.iconMuted, size: 20), color: colors.headingColor,
),
decoration: _fieldDecoration(
colors: colors,
label: 'Email address',
icon: Icons.mail_outline_rounded,
), ),
), ),
const SizedBox(height: 20), const SizedBox(height: 18),
_buildFieldLabel(colors, 'Password'),
TextFormField( TextFormField(
controller: _passwordController, controller: _passwordController,
obscureText: _obscurePassword, obscureText: _obscurePassword,
autofillHints: const [AutofillHints.password], autofillHints: const [AutofillHints.password],
validator: (v) => Validators.required(v, fieldName: 'Password'), validator: (v) => Validators.required(v, fieldName: 'Password'),
style: TextStyle(color: colors.headingColor), style: GoogleFonts.inter(
decoration: _fieldDecoration(colors, 'Enter your password').copyWith( fontSize: 14.5,
prefixIcon: Icon(Icons.lock_outline, color: colors.iconMuted, size: 20), color: colors.headingColor,
suffixIcon: IconButton( ),
decoration: _fieldDecoration(
colors: colors,
label: 'Password',
icon: Icons.lock_outline_rounded,
suffix: IconButton(
tooltip: _obscurePassword ? 'Show password' : 'Hide password',
icon: Icon( icon: Icon(
_obscurePassword ? Icons.visibility_outlined : Icons.visibility_off_outlined, _obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
color: colors.iconMuted, color: colors.iconMuted,
size: 20, size: 18,
), ),
onPressed: () => setState(() => _obscurePassword = !_obscurePassword), onPressed: () =>
setState(() => _obscurePassword = !_obscurePassword),
), ),
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 10),
Row( Row(
children: [ children: [
SizedBox( SizedBox(
height: 36, height: 34,
width: 36, width: 34,
child: Checkbox( child: Checkbox(
value: _rememberMe, value: _rememberMe,
activeColor: colors.primaryAction, activeColor: colors.primary,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), checkColor: colors.onPrimary,
side: BorderSide(color: colors.outline, width: 1.5),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
),
onChanged: (v) => setState(() => _rememberMe = v ?? false), onChanged: (v) => setState(() => _rememberMe = v ?? false),
), ),
), ),
Text( Text(
'Remember me', 'Remember me',
style: TextStyle(fontSize: 13, color: colors.labelColor), style: GoogleFonts.inter(
fontSize: 13,
color: colors.labelColor,
),
), ),
const Spacer(), const Spacer(),
TextButton( TextButton(
@ -238,85 +282,126 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
minimumSize: Size.zero, minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap, tapTargetSize: MaterialTapTargetSize.shrinkWrap,
), ),
child: const Text( child: Text(
'Forgot password?', '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( Theme(
data: Theme.of(context).copyWith( data: Theme.of(context).copyWith(
elevatedButtonTheme: ElevatedButtonThemeData( elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: colors.primaryAction, backgroundColor: colors.primary,
foregroundColor: colors.colorScheme.onPrimary, foregroundColor: colors.onPrimary,
minimumSize: const Size(double.infinity, 50), disabledBackgroundColor:
colors.primary.withValues(alpha: 0.75),
minimumSize: const Size(double.infinity, 52),
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), shadowColor: colors.primary.withValues(alpha: 0.35),
textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: GoogleFonts.inter(
fontSize: 15,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
),
), ),
), ),
), ),
child: AppButton( child: AppButton(
label: 'Sign In', label: _isLoading ? 'Signing in…' : 'Sign in',
isLoading: _isLoading, isLoading: _isLoading,
onPressed: _login, onPressed: _login,
), ),
), ),
const SizedBox(height: 28), const SizedBox(height: 26),
Row( Row(
children: [ children: [
Expanded(child: Divider(color: colors.borderColor)), Expanded(child: Divider(color: colors.outlineSoft, height: 1)),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 10),
child: Text( child: Text(
'Secure access to your ERP system', 'SECURE ACCESS',
style: TextStyle( style: GoogleFonts.inter(
fontSize: 12, fontSize: 11.5,
color: colors.subtitleColor, 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( Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.shield_outlined, size: 16, color: colors.subtitleColor), Icon(
Icons.verified_user_outlined,
size: 14,
color: colors.success,
),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
'Your data is protected and secure.', 'Your data is protected and encrypted',
style: TextStyle( style: GoogleFonts.inter(
fontSize: 12, 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) ...[ if (DevConfig.screenPreviewEnabled) ...[
const SizedBox(height: 24), const SizedBox(height: 20),
Divider(color: colors.borderColor), Divider(color: colors.outlineSoft),
const SizedBox(height: 16), const SizedBox(height: 14),
Text( Text(
'Login API unavailable? Browse all screens without signing in:', 'Login API unavailable? Browse all screens without signing in:',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: GoogleFonts.inter(
fontSize: 12, fontSize: 12,
color: colors.subtitleColor, color: colors.subtitleColor,
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
AppButton( Theme(
label: 'Explore All Screens', data: Theme.of(context).copyWith(
isOutlined: true, outlinedButtonTheme: OutlinedButtonThemeData(
onPressed: () { style: OutlinedButton.styleFrom(
ref.read(authStateProvider.notifier).loginAsDemo(); foregroundColor: colors.primary,
context.go(RouteConstants.screenGallery); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isWide = context.isDesktop; final isWide = context.isDesktop;
final colors = LoginColors.of(context); final colors = LoginColors.of(context);
return Scaffold( return Scaffold(
backgroundColor: colors.pageBackground, backgroundColor: colors.bg,
body: isWide body: Stack(
? Row( children: [
if (isWide)
Row(
children: [ children: [
const Expanded(flex: 105, child: LoginHeroPanel()),
Expanded( Expanded(
flex: 42, flex: 100,
child: Center( child: Stack(
child: SingleChildScrollView( children: [
padding: const EdgeInsets.all(32), Positioned.fill(child: _buildAmbient(colors)),
child: _buildLoginCard(colors), Center(
), child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 40,
),
child: _buildLoginCard(colors),
),
),
],
), ),
), ),
const Expanded(
flex: 58,
child: LoginHeroPanel(),
),
], ],
) )
: SingleChildScrollView( else
child: Column( Stack(
children: [ children: [
Padding( Positioned.fill(child: _buildAmbient(colors)),
padding: const EdgeInsets.fromLTRB(24, 48, 24, 24), SingleChildScrollView(
child: Center(child: _buildLoginCard(colors)), 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'; 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 { class LoginColors {
LoginColors.of(BuildContext context) LoginColors.of(BuildContext context)
: theme = Theme.of(context), : theme = Theme.of(context),
@ -13,48 +13,79 @@ class LoginColors {
final ColorScheme colorScheme; final ColorScheme colorScheme;
final bool isDark; 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 => // Accent (from branding ColorScheme)
isDark ? colorScheme.surface : AppColors.card; 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; // Surfaces
Color get bg =>
Color get subtitleColor => colorScheme.onSurfaceVariant; isDark ? colorScheme.surface : theme.scaffoldBackgroundColor;
Color get bgGradA => Color.lerp(
Color get labelColor => isDark colorScheme.surface,
? colorScheme.onSurface.withValues(alpha: 0.9) colorScheme.primaryContainer,
: const Color(0xFF334155); isDark ? 0.18 : 0.22,
)!;
Color get linkColor => colorScheme.primary; Color get bgGradB => Color.lerp(
colorScheme.surface,
Color get borderColor => isDark colorScheme.primaryContainer,
? colorScheme.outline.withValues(alpha: 0.35) isDark ? 0.32 : 0.35,
: const Color(0xFFE2E8F0); )!;
Color get surface =>
Color get fieldFillColor => isDark ? colorScheme.surfaceContainerLow : AppColors.card;
isDark ? colorScheme.surfaceContainerHighest : Colors.white; Color get surfaceContainer => isDark
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
? colorScheme.surfaceContainerHighest ? colorScheme.surfaceContainerHighest
: const Color(0xFFF8FAFC); : colorScheme.surfaceContainerHighest.withValues(alpha: 0.55);
Color get surfaceContainerHigh => colorScheme.surfaceContainerHigh;
Color get decorCubeColor => Color get onSurface => colorScheme.onSurface;
colorScheme.primaryContainer.withValues(alpha: isDark ? 0.35 : 0.55); Color get onSurfaceVariant => colorScheme.onSurfaceVariant;
Color get outline => colorScheme.outline;
Color get connectionLineColor => colorScheme.outline.withValues( Color get outlineSoft => colorScheme.outlineVariant.withValues(
alpha: isDark ? 0.45 : 0.55, 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'; import 'login_colors.dart';
/// Isometric ERP module diagram for the login hero panel. /// Orbiting ERP module illustration for the login brand panel.
class LoginHeroIllustration extends StatelessWidget { class LoginHeroIllustration extends StatefulWidget {
const LoginHeroIllustration({super.key}); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = LoginColors.of(context); final colors = LoginColors.of(context);
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final size = math.min(constraints.maxWidth, constraints.maxHeight); final w = constraints.maxWidth;
return Center( final h = constraints.maxHeight;
child: SizedBox( final size = Size(w, h);
width: size, final center = Offset(w / 2, h / 2);
height: size * 0.85,
child: Stack( final nodes = <_OrbitNode>[
clipBehavior: Clip.none, _OrbitNode(
children: [ position: Offset(w * 0.13, h * 0.32),
Positioned( icon: Icons.inventory_2_outlined,
top: size * 0.02, delay: 0,
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),
),
],
),
), ),
_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 { class _OrbitNode {
const _ModuleData({ const _OrbitNode({
required this.dx, required this.position,
required this.dy,
required this.icon, required this.icon,
required this.label, required this.delay,
}); });
final double dx; final Offset position;
final double dy;
final IconData icon; final IconData icon;
final String label; final double delay;
} }
class _ErpCore extends StatelessWidget { class _FloatingNode extends StatelessWidget {
const _ErpCore({required this.size, required this.colors}); const _FloatingNode({
required this.node,
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,
required this.colors, required this.colors,
required this.floatValue,
}); });
final IconData icon; final _OrbitNode node;
final String label;
final double size;
final LoginColors colors; final LoginColors colors;
final double floatValue;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final nodeSize = size * 0.14; final phase = (floatValue + node.delay) % 1.0;
return Column( final dy = math.sin(phase * math.pi) * -6;
mainAxisSize: MainAxisSize.min,
children: [ return Positioned(
Container( left: node.position.dx - 26,
width: nodeSize, top: node.position.dy - 26 + dy,
height: nodeSize, child: Container(
decoration: BoxDecoration( width: 52,
color: colors.moduleNodeBackground, height: 52,
borderRadius: BorderRadius.circular(nodeSize * 0.22), decoration: BoxDecoration(
boxShadow: [ shape: BoxShape.circle,
BoxShadow( color: colors.panelText.withValues(alpha: 0.08),
color: colors.cardShadowColor, border: Border.all(
blurRadius: nodeSize * 0.15, color: colors.panelText.withValues(alpha: 0.22),
offset: Offset(0, nodeSize * 0.06),
),
],
),
child: Icon(
icon,
color: colors.primaryAction,
size: nodeSize * 0.45,
), ),
), ),
SizedBox(height: nodeSize * 0.12), child: Icon(node.icon, size: 20, color: colors.panelText),
Text( ),
label,
style: TextStyle(
fontSize: nodeSize * 0.28,
fontWeight: FontWeight.w500,
color: colors.subtitleColor,
),
),
],
); );
} }
} }
class _DecorCube extends StatelessWidget { class _Hub extends StatelessWidget {
const _DecorCube({ const _Hub({required this.colors});
required this.size,
required this.opacity,
required this.color,
});
final double size; final LoginColors colors;
final double opacity;
final Color color;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Transform.rotate( return Container(
angle: math.pi / 6, width: 84,
child: Container( height: 84,
width: size, alignment: Alignment.center,
height: size, decoration: BoxDecoration(
decoration: BoxDecoration( shape: BoxShape.circle,
color: color.withValues(alpha: opacity), gradient: LinearGradient(
borderRadius: BorderRadius.circular(size * 0.15), 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 { class _OrbitPainter extends CustomPainter {
_ConnectionLinesPainter({ _OrbitPainter({
required this.modules, required this.colors,
required this.center, required this.center,
required this.lineColor, required this.ringProgress,
required this.ring2Progress,
}); });
final List<_ModuleData> modules; final LoginColors colors;
final Offset center; final Offset center;
final Color lineColor; final double ringProgress;
final double ring2Progress;
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
final paint = Paint() final stroke = colors.panelText.withValues(alpha: 0.14);
..color = lineColor final line = colors.panelText.withValues(alpha: 0.16);
..style = PaintingStyle.stroke
..strokeWidth = 1.5;
for (final module in modules) { void drawRing(double rx, double ry, double progress, List<double> dash) {
final moduleCenter = Offset( canvas.save();
module.dx + size.width * 0.07 - (size.width * 0.32), canvas.translate(center.dx, center.dy);
module.dy + size.height * 0.05 - (size.height * 0.28), 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) { void _drawDashedOval(
const dashWidth = 5.0; Canvas canvas,
const dashSpace = 4.0; Rect rect,
final distance = (end - start).distance; Paint paint,
if (distance == 0) return; 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; var drawn = 0.0;
while (drawn < distance) { while (drawn < distance) {
final dashEnd = drawn + dashWidth > distance ? distance : drawn + dashWidth; final end = math.min(drawn + dash, distance);
canvas.drawLine( canvas.drawLine(a + dir * drawn, a + dir * end, paint);
start + direction * drawn, drawn = end + gap;
start + direction * dashEnd,
paint,
);
drawn += dashWidth + dashSpace;
} }
} }
@override @override
bool shouldRepaint(covariant _ConnectionLinesPainter oldDelegate) { bool shouldRepaint(covariant _OrbitPainter oldDelegate) {
return oldDelegate.lineColor != lineColor; 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:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'login_colors.dart'; import 'login_colors.dart';
import 'login_hero_illustration.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 { class LoginHeroPanel extends StatelessWidget {
const LoginHeroPanel({super.key, this.compact = false}); const LoginHeroPanel({super.key, this.compact = false});
@ -13,46 +14,226 @@ class LoginHeroPanel extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = LoginColors.of(context); final colors = LoginColors.of(context);
return Padding( return DecoratedBox(
padding: EdgeInsets.symmetric( decoration: BoxDecoration(
horizontal: compact ? 24 : 48, gradient: LinearGradient(
vertical: compact ? 32 : 48, 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( child: Stack(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: compact ? MainAxisAlignment.start : MainAxisAlignment.center,
children: [ children: [
Text( Positioned.fill(
'Smart. Integrated. Efficient.', child: CustomPaint(painter: _DotGridPainter(colors.panelText)),
style: TextStyle( ),
fontSize: compact ? 28 : 40, Padding(
fontWeight: FontWeight.w700, padding: EdgeInsets.symmetric(
color: colors.headingColor, horizontal: compact ? 24 : 56,
height: 1.2, vertical: compact ? 28 : 48,
letterSpacing: -0.5, ),
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 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/constants/app_constants.dart';
import '../../../../core/errors/failure.dart'; import '../../../../core/errors/failure.dart';
import '../../../../core/network/api_handler.dart'; import '../../../../core/network/api_handler.dart';
import '../../../../core/network/dio_client.dart'; import '../../../../core/network/dio_client.dart';
@ -97,9 +98,9 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
final resolved = resolveMediaUrl(faviconUrl) ?? faviconUrl?.trim(); final resolved = resolveMediaUrl(faviconUrl) ?? faviconUrl?.trim();
final value = (resolved == null || resolved.isEmpty) ? null : resolved; final value = (resolved == null || resolved.isEmpty) ? null : resolved;
await _faviconStore.write(value); await _faviconStore.write(value);
if (value != null) { updateFavicon(
updateFavicon(value); resolveFaviconHref(value ?? AppConstants.defaultFaviconAsset),
} );
} }
Future<void> _load() async { 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/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/constants/app_constants.dart';
import '../../../../core/network/api_handler.dart'; import '../../../../core/network/api_handler.dart';
import '../../../../core/theme/theme_provider.dart'; import '../../../../core/theme/theme_provider.dart';
import '../../../../core/utils/favicon_store.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_button.dart';
import '../../../../shared/widgets/app_side_panel.dart'; import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_text_field.dart'; import '../../../../shared/widgets/app_text_field.dart';
import '../../../../shared/widgets/app_toast.dart';
import '../../../../shared/widgets/sidebar_logo.dart'; import '../../../../shared/widgets/sidebar_logo.dart';
import '../../domain/entities/app_settings.dart'; import '../../domain/entities/app_settings.dart';
import '../providers/settings_provider.dart'; import '../providers/settings_provider.dart';
import '../widgets/settings_widgets.dart'; import '../widgets/settings_widgets.dart';
import '../../../../shared/widgets/app_toast.dart';
class CompanyProfileSettingsScreen extends ConsumerStatefulWidget { class CompanyProfileSettingsScreen extends ConsumerStatefulWidget {
const CompanyProfileSettingsScreen({super.key}); const CompanyProfileSettingsScreen({super.key});
@ -389,7 +390,7 @@ class _CompanyProfileSettingsScreenState
Center( Center(
child: SidebarLogo( child: SidebarLogo(
logoUrl: _logoUrlController.text.trim().isEmpty logoUrl: _logoUrlController.text.trim().isEmpty
? null ? AppConstants.defaultLogoAsset
: _logoUrlController.text.trim(), : _logoUrlController.text.trim(),
width: 240, width: 240,
height: 80, height: 80,
@ -428,7 +429,7 @@ class _CompanyProfileSettingsScreenState
Center( Center(
child: SidebarLogo( child: SidebarLogo(
logoUrl: _faviconUrlController.text.trim().isEmpty logoUrl: _faviconUrlController.text.trim().isEmpty
? null ? AppConstants.defaultFaviconAsset
: _faviconUrlController.text.trim(), : _faviconUrlController.text.trim(),
width: 64, width: 64,
height: 64, height: 64,

View File

@ -42,10 +42,6 @@ class AppTopNav extends ConsumerWidget {
companyProfileLogo: companyProfile.logoUrl, companyProfileLogo: companyProfile.logoUrl,
brandingLogo: branding.logoUrl, brandingLogo: branding.logoUrl,
); );
final companyName = resolveSidebarTitle(
companyName: companyProfile.companyName,
fallback: AppConstants.appName,
);
return Material( return Material(
color: isDark ? AppColors.darkSurface : AppColors.lightSurface, color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
@ -62,15 +58,14 @@ class AppTopNav extends ConsumerWidget {
), ),
child: Row( child: Row(
children: [ children: [
SidebarLogo(logoUrl: logoUrl, size: 32), SidebarLogo(
const SizedBox(width: 10), logoUrl: logoUrl,
Text( height: 48,
companyName, width: 140,
style: theme.textTheme.titleSmall?.copyWith( fit: BoxFit.contain,
fontWeight: FontWeight.w700, showBackground: false,
),
), ),
const SizedBox(width: 32), const SizedBox(width: 24),
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,

View File

@ -3,9 +3,10 @@ import 'dart:convert';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/constants/app_constants.dart';
import '../../core/utils/media_url.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 { class SidebarLogo extends StatelessWidget {
const SidebarLogo({ const SidebarLogo({
super.key, super.key,
@ -30,10 +31,16 @@ class SidebarLogo extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final fallback = Icon( final fallback = Image.asset(
Icons.inventory_2_outlined, AppConstants.defaultLogoAsset,
size: (_width < _height ? _width : _height) * 0.55, width: _width,
color: theme.colorScheme.primary, 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); final content = _buildLogoContent(fallback);
@ -57,7 +64,17 @@ class SidebarLogo extends StatelessWidget {
Widget _buildLogoContent(Widget fallback) { Widget _buildLogoContent(Widget fallback) {
final url = resolveMediaUrl(logoUrl); final url = resolveMediaUrl(logoUrl);
if (url == null || url.isEmpty) { 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')) { if (url.startsWith('data:image')) {
@ -68,10 +85,10 @@ class SidebarLogo extends StatelessWidget {
width: _width, width: _width,
height: _height, height: _height,
fit: fit, fit: fit,
errorBuilder: (_, __, ___) => Center(child: fallback), errorBuilder: (_, __, ___) => fallback,
); );
} catch (_) { } catch (_) {
return Center(child: fallback); return fallback;
} }
} }
@ -88,32 +105,36 @@ class SidebarLogo extends StatelessWidget {
child: const CircularProgressIndicator(strokeWidth: 2), 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. /// Resolves logo URL from company profile, branding, or bundled default.
String? resolveSidebarLogoUrl({ String resolveSidebarLogoUrl({
required String companyProfileLogo, required String companyProfileLogo,
required String? brandingLogo, required String? brandingLogo,
}) { }) {
final fromProfile = resolveMediaUrl(companyProfileLogo); final fromProfile = resolveMediaUrl(companyProfileLogo);
if (fromProfile != null && fromProfile.isNotEmpty) return fromProfile; 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. /// Resolves favicon URL from company profile, local cache, or bundled default.
String? resolveSidebarFaviconUrl({ String resolveSidebarFaviconUrl({
required String companyProfileFavicon, required String companyProfileFavicon,
String? storedFavicon, String? storedFavicon,
}) { }) {
final fromProfile = resolveMediaUrl(companyProfileFavicon); final fromProfile = resolveMediaUrl(companyProfileFavicon);
if (fromProfile != null && fromProfile.isNotEmpty) return fromProfile; 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. /// 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