diff --git a/assets/images/bcpl_favicon.png b/assets/images/bcpl_favicon.png new file mode 100644 index 0000000..000bac3 Binary files /dev/null and b/assets/images/bcpl_favicon.png differ diff --git a/assets/images/bcpl_logo.png b/assets/images/bcpl_logo.png new file mode 100644 index 0000000..50015bc Binary files /dev/null and b/assets/images/bcpl_logo.png differ diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart index e57f62e..49ce65e 100644 --- a/lib/core/constants/app_constants.dart +++ b/lib/core/constants/app_constants.dart @@ -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; diff --git a/lib/core/utils/favicon_store.dart b/lib/core/utils/favicon_store.dart index 72a7064..e904d75 100644 --- a/lib/core/utils/favicon_store.dart +++ b/lib/core/utils/favicon_store.dart @@ -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); } } diff --git a/lib/core/utils/media_url.dart b/lib/core/utils/media_url.dart index 425bd07..b6b4172 100644 --- a/lib/core/utils/media_url.dart +++ b/lib/core/utils/media_url.dart @@ -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; +} diff --git a/lib/modules/auth/presentation/screens/login_screen.dart b/lib/modules/auth/presentation/screens/login_screen.dart index 1e2d59f..24f6b8d 100644 --- a/lib/modules/auth/presentation/screens/login_screen.dart +++ b/lib/modules/auth/presentation/screens/login_screen.dart @@ -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 { 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 { 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 { ), ], ), - 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 { 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 { ); } + 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), + ), + ), ); } } diff --git a/lib/modules/auth/presentation/widgets/login_colors.dart b/lib/modules/auth/presentation/widgets/login_colors.dart index bbc9c81..b6cecfa 100644 --- a/lib/modules/auth/presentation/widgets/login_colors.dart +++ b/lib/modules/auth/presentation/widgets/login_colors.dart @@ -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)!; } diff --git a/lib/modules/auth/presentation/widgets/login_hero_illustration.dart b/lib/modules/auth/presentation/widgets/login_hero_illustration.dart index aae9ee3..3f2bb66 100644 --- a/lib/modules/auth/presentation/widgets/login_hero_illustration.dart +++ b/lib/modules/auth/presentation/widgets/login_hero_illustration.dart @@ -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 createState() => _LoginHeroIllustrationState(); +} + +class _LoginHeroIllustrationState extends State + 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 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; } } diff --git a/lib/modules/auth/presentation/widgets/login_hero_panel.dart b/lib/modules/auth/presentation/widgets/login_hero_panel.dart index a003d80..0eb91a2 100644 --- a/lib/modules/auth/presentation/widgets/login_hero_panel.dart +++ b/lib/modules/auth/presentation/widgets/login_hero_panel.dart @@ -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; +} diff --git a/lib/modules/settings/presentation/providers/settings_provider.dart b/lib/modules/settings/presentation/providers/settings_provider.dart index 9e4385f..28fcfc5 100644 --- a/lib/modules/settings/presentation/providers/settings_provider.dart +++ b/lib/modules/settings/presentation/providers/settings_provider.dart @@ -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 { 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 _load() async { diff --git a/lib/modules/settings/presentation/screens/company_profile_settings_screen.dart b/lib/modules/settings/presentation/screens/company_profile_settings_screen.dart index 6a1949c..87113c3 100644 --- a/lib/modules/settings/presentation/screens/company_profile_settings_screen.dart +++ b/lib/modules/settings/presentation/screens/company_profile_settings_screen.dart @@ -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, diff --git a/lib/shared/widgets/app_top_nav.dart b/lib/shared/widgets/app_top_nav.dart index e53543f..c688c15 100644 --- a/lib/shared/widgets/app_top_nav.dart +++ b/lib/shared/widgets/app_top_nav.dart @@ -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, diff --git a/lib/shared/widgets/sidebar_logo.dart b/lib/shared/widgets/sidebar_logo.dart index 0fdb7b5..a34c849 100644 --- a/lib/shared/widgets/sidebar_logo.dart +++ b/lib/shared/widgets/sidebar_logo.dart @@ -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. diff --git a/web/favicon.png b/web/favicon.png index 8aaa46a..000bac3 100755 Binary files a/web/favicon.png and b/web/favicon.png differ