import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../app/providers.dart'; import '../../../core/constants/asset_paths.dart'; import '../../../core/router/app_router.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/utils/validators.dart'; import '../../../core/widgets/brand_mark.dart'; import '../../../core/widgets/primary_button.dart'; import '../providers/auth_controller.dart'; /// Store sign-in. The terminal shows this until a valid account is entered. /// /// One centred card on a plain background, at every width. The split-screen /// version put a marketing panel beside the form, which meant the thing the /// person came here to use was never in the middle of the screen, was a /// different width on every monitor, and collapsed into a different layout /// below 1000px. A till is signed into at the start of a shift by someone who /// already bought the product; the pitch was costing the form its position. class LoginScreen extends ConsumerStatefulWidget { const LoginScreen({super.key}); @override ConsumerState createState() => _LoginScreenState(); } class _LoginScreenState extends ConsumerState { final _formKey = GlobalKey(); /// Which of the two default accounts the tabs are pointing at. Only a /// convenience for filling the fields — [AuthController.signIn] decides the /// role from the email that is actually submitted, so typing a different /// address over the top still signs in as that account. TerminalLogin _login = TerminalLogin.admin; late final _email = TextEditingController(text: _login.email); late final _password = TextEditingController(text: _login.password); bool _obscure = true; bool _rememberTerminal = true; void _selectLogin(TerminalLogin login) { setState(() { _login = login; _email.text = login.email; _password.text = login.password; }); ref.read(authControllerProvider.notifier).clearError(); } @override void dispose() { _email.dispose(); _password.dispose(); super.dispose(); } Future _submit() async { FocusScope.of(context).unfocus(); if (!(_formKey.currentState?.validate() ?? false)) return; final ok = await ref.read(authControllerProvider.notifier).signIn( email: _email.text, password: _password.text, ); if (ok && mounted) context.go(AppRoutes.pos); } @override Widget build(BuildContext context) { final auth = ref.watch(authControllerProvider); final session = ref.watch(cashierSessionProvider); final busy = auth is Authenticating; return Scaffold( backgroundColor: AppColors.background, body: Stack( fit: StackFit.expand, children: [ const _Backdrop(), SafeArea( child: LayoutBuilder( builder: (context, box) { final tight = box.maxHeight < 620; return SingleChildScrollView( padding: EdgeInsets.symmetric( horizontal: AppSpacing.xl, vertical: tight ? AppSpacing.xl : AppSpacing.xxxl, ), child: ConstrainedBox( // Fills the viewport so the card is centred vertically, // and scrolls the moment it cannot be. constraints: BoxConstraints( minHeight: (box.maxHeight - (tight ? 40 : 64)) .clamp(0.0, double.infinity), ), child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 440), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // _Masthead(tight: tight), SizedBox( height: tight ? AppSpacing.lg : AppSpacing.xl, ), _Card( formKey: _formKey, email: _email, password: _password, obscure: _obscure, rememberTerminal: _rememberTerminal, login: _login, busy: busy, failure: auth is AuthFailure ? auth.message : null, onSelectLogin: _selectLogin, onToggleObscure: () => setState(() => _obscure = !_obscure), onToggleRemember: (v) => setState(() => _rememberTerminal = v ?? true), onSubmit: _submit, ), const SizedBox(height: AppSpacing.lg), Center( child: Text( 'Terminal ${session.terminalId}', style: TextStyle( fontSize: 11.5, color: Colors.white.withValues(alpha: 0.72), ), ), ), ], ), ), ), ), ); }, ), ), ], ), ); } } /// The shopfront behind the form. /// /// Blurred hard and darkened, because it is atmosphere rather than something /// to read: a sharp photograph under a sign-in card competes with the two /// fields the person came here to fill in. Scaled up slightly before the blur /// so the softened edges are pushed off-screen instead of showing as a pale /// border. /// /// Falls back to the plain background colour if the asset is missing, so an /// undeclared file costs the login screen its atmosphere and not its function. class _Backdrop extends StatelessWidget { const _Backdrop(); @override Widget build(BuildContext context) { return Stack( fit: StackFit.expand, children: [ ClipRect( child: ImageFiltered( imageFilter: ui.ImageFilter.blur(sigmaX: 0, sigmaY: 0), child: Transform.scale( scale: 1.12, child: Image.asset( AssetPaths.loginBackground, fit: BoxFit.cover, filterQuality: FilterQuality.medium, errorBuilder: (context, _, __) => const ColoredBox(color: AppColors.background), ), ), ), ), // Two layers, not one: the flat wash guarantees contrast for the white // masthead wherever the photograph happens to be pale, and the gradient // puts the darkest part behind the text rather than spreading it evenly // and flattening the image out. const ColoredBox(color: Color(0x8A1A0B22)), const DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0x66000000), Color(0x22000000), Color(0x77000000)], ), ), ), ], ); } } /// Mark and product name, directly above the card. class _Masthead extends StatelessWidget { const _Masthead({required this.tight}); final bool tight; @override Widget build(BuildContext context) { return Column( children: [ BrandMark(size: tight ? 48 : 60), const SizedBox(height: AppSpacing.md), const Text( 'Nearle POS', style: TextStyle( fontSize: 22, fontWeight: FontWeight.w700, letterSpacing: -0.4, color: Colors.white, height: 1.2, shadows: [ Shadow(color: Color(0x66000000), blurRadius: 12), ], ), ), if (!tight) ...[ const SizedBox(height: 2), Text( 'Scanner-first billing for Indian retail', style: TextStyle( fontSize: 13, color: Colors.white.withValues(alpha: 0.82), shadows: const [ Shadow(color: Color(0x55000000), blurRadius: 10), ], ), ), ], ], ).animate().fadeIn(duration: 260.ms); } } class _Card extends StatelessWidget { const _Card({ required this.formKey, required this.email, required this.password, required this.obscure, required this.rememberTerminal, required this.login, required this.busy, required this.failure, required this.onSelectLogin, required this.onToggleObscure, required this.onToggleRemember, required this.onSubmit, }); final GlobalKey formKey; final TextEditingController email; final TextEditingController password; final bool obscure; final bool rememberTerminal; final TerminalLogin login; final bool busy; final String? failure; final ValueChanged onSelectLogin; final VoidCallback onToggleObscure; final ValueChanged onToggleRemember; final VoidCallback onSubmit; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(AppSpacing.xxl), decoration: BoxDecoration( color: AppColors.surface, borderRadius: AppRadius.brXl, border: Border.all(color: AppColors.border), boxShadow: const [ BoxShadow( color: Color(0x33101828), blurRadius: 40, offset: Offset(0, 16), ), ], ), child: Form( key: formKey, autovalidateMode: AutovalidateMode.onUserInteraction, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ const Text( 'Sign in to your store', style: TextStyle( fontSize: 19, fontWeight: FontWeight.w600, letterSpacing: -0.3, color: AppColors.textPrimary, ), ), const SizedBox(height: 2), const Text( 'Use the credentials issued when your outlet was registered.', style: TextStyle( fontSize: 13, color: AppColors.textSecondary, height: 1.5, ), ), const SizedBox(height: AppSpacing.xl), _RoleSwitch( selected: login, enabled: !busy, onSelect: onSelectLogin, ), const SizedBox(height: AppSpacing.sm), Text( login.blurb, style: const TextStyle( fontSize: 12.5, color: AppColors.textTertiary, height: 1.45, ), ), const SizedBox(height: AppSpacing.xl), const _Label('Store email'), TextFormField( controller: email, keyboardType: TextInputType.emailAddress, textInputAction: TextInputAction.next, enabled: !busy, validator: (v) => (v ?? '').trim().isEmpty ? 'Store email is required' : Validators.emailOptional(v), decoration: const InputDecoration( hintText: 'store@example.in', prefixIcon: Icon(Icons.storefront_outlined), ), ), const SizedBox(height: AppSpacing.lg), const _Label('Password'), TextFormField( controller: password, obscureText: obscure, enabled: !busy, textInputAction: TextInputAction.done, onFieldSubmitted: (_) => onSubmit(), validator: (v) => (v ?? '').isEmpty ? 'Password is required' : ((v ?? '').length < 6 ? 'Password looks too short' : null), decoration: InputDecoration( hintText: 'Enter your password', prefixIcon: const Icon(Icons.lock_outline_rounded), suffixIcon: IconButton( onPressed: onToggleObscure, icon: Icon( obscure ? Icons.visibility_outlined : Icons.visibility_off_outlined, size: 20, ), tooltip: obscure ? 'Show password' : 'Hide password', ), ), ), const SizedBox(height: AppSpacing.sm), // Wrap, not Row — on a narrow tablet these would collide. Wrap( alignment: WrapAlignment.spaceBetween, crossAxisAlignment: WrapCrossAlignment.center, children: [ InkWell( onTap: busy ? null : () => onToggleRemember(!rememberTerminal), borderRadius: AppRadius.brXs, child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( mainAxisSize: MainAxisSize.min, children: [ SizedBox( width: 22, height: 22, child: Checkbox( value: rememberTerminal, onChanged: busy ? null : onToggleRemember, visualDensity: VisualDensity.compact, ), ), const SizedBox(width: AppSpacing.sm), const Text( 'Remember this terminal', style: TextStyle( fontSize: 13, color: AppColors.textSecondary, ), ), ], ), ), ), TextButton( onPressed: busy ? null : () {}, child: const Text('Forgot password?'), ), ], ), if (failure != null) ...[ const SizedBox(height: AppSpacing.sm), Container( padding: const EdgeInsets.all(AppSpacing.md), decoration: const BoxDecoration( color: AppColors.dangerSurface, borderRadius: AppRadius.brSm, ), child: Row( children: [ const Icon(Icons.error_outline_rounded, color: AppColors.danger, size: 18,), const SizedBox(width: AppSpacing.sm), Expanded( child: Text( failure!, style: const TextStyle( color: AppColors.danger, fontSize: 13, ), ), ), ], ), ).animate().shake(duration: 320.ms, hz: 3), ], const SizedBox(height: AppSpacing.lg), PrimaryButton( label: 'Sign in', icon: Icons.login_rounded, large: true, busy: busy, onPressed: onSubmit, ), const SizedBox(height: AppSpacing.lg), _DemoHint( login: login, onFill: busy ? null : () => onSelectLogin(login), ), ], ), ), ).animate().fadeIn(duration: 300.ms).slideY(begin: 0.02, end: 0); } } class _Label extends StatelessWidget { const _Label(this.text); final String text; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(bottom: AppSpacing.sm), child: Text( text, style: const TextStyle( fontSize: 12.5, fontWeight: FontWeight.w600, color: AppColors.textSecondary, ), ), ); } } /// Which of the two default accounts is being signed into. /// /// The roles are not cosmetic — they decide whether the terminal opens the /// full shell or the billing screen alone, and whether signing out leaves the /// products behind — so the choice is made before the credentials rather than /// inferred from them afterwards. class _RoleSwitch extends StatelessWidget { const _RoleSwitch({ required this.selected, required this.enabled, required this.onSelect, }); final TerminalLogin selected; final bool enabled; final ValueChanged onSelect; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: AppColors.surfaceAlt, borderRadius: AppRadius.brSm, border: Border.all(color: AppColors.border), ), child: Row( children: [ for (final login in TerminalLogin.values) Expanded( child: _RoleTab( login: login, selected: login == selected, enabled: enabled, onTap: () => onSelect(login), ), ), ], ), ); } } class _RoleTab extends StatelessWidget { const _RoleTab({ required this.login, required this.selected, required this.enabled, required this.onTap, }); final TerminalLogin login; final bool selected; final bool enabled; final VoidCallback onTap; @override Widget build(BuildContext context) { final icon = login == TerminalLogin.admin ? Icons.admin_panel_settings_outlined : Icons.point_of_sale_rounded; return Material( color: Colors.transparent, child: InkWell( onTap: enabled ? onTap : null, borderRadius: AppRadius.brXs, child: AnimatedContainer( duration: const Duration(milliseconds: 160), height: 40, alignment: Alignment.center, decoration: BoxDecoration( color: selected ? AppColors.surface : Colors.transparent, borderRadius: AppRadius.brXs, border: Border.all( color: selected ? AppColors.primaryBorder : Colors.transparent, ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( icon, size: 17, color: selected ? AppColors.primary : AppColors.textSecondary, ), const SizedBox(width: AppSpacing.sm), Flexible( child: Text( login.label, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13.5, fontWeight: selected ? FontWeight.w700 : FontWeight.w500, color: selected ? AppColors.primary : AppColors.textSecondary, ), ), ), ], ), ), ), ); } } class _DemoHint extends StatelessWidget { const _DemoHint({required this.login, this.onFill}); final TerminalLogin login; final VoidCallback? onFill; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(AppSpacing.md), decoration: BoxDecoration( color: AppColors.primarySurface, borderRadius: AppRadius.brSm, border: Border.all(color: AppColors.primaryBorder), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Icon(Icons.info_outline_rounded, size: 17, color: AppColors.primary,), const SizedBox(width: AppSpacing.sm), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Default ${login.label.toLowerCase()} account', style: const TextStyle( fontSize: 12.5, fontWeight: FontWeight.w600, color: AppColors.primary, ), ), const SizedBox(height: 2), SelectableText( '${login.email} \u00b7 ${login.password}', style: const TextStyle( fontSize: 12, color: AppColors.textSecondary, ), ), ], ), ), TextButton( onPressed: onFill, style: TextButton.styleFrom( minimumSize: const Size(0, 32), padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm), ), child: const Text('Fill', style: TextStyle(fontSize: 12.5)), ), ], ), ); } }