login design changed
This commit is contained in:
parent
afe9a0736d
commit
9856ff08b2
BIN
assets/images/bcpl_favicon.png
Normal file
BIN
assets/images/bcpl_favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
BIN
assets/images/bcpl_logo.png
Normal file
BIN
assets/images/bcpl_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
@ -5,6 +5,15 @@ class AppConstants {
|
|||||||
static const String appVersion = '1.0.0';
|
static const String 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;
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)!;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
BIN
web/favicon.png
BIN
web/favicon.png
Binary file not shown.
|
Before Width: | Height: | Size: 917 B After Width: | Height: | Size: 89 KiB |
Loading…
Reference in New Issue
Block a user