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/widgets/primary_button.dart'; import '../providers/auth_controller.dart'; /// Store sign-in. The terminal shows this until the back office issues a /// session. /// /// 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. /// /// There is no role picker. The role comes back in the login response and is /// the server's to decide — a tab on this screen would only ever have been a /// hint, and a hint that disagreed with the response would be a bug someone /// spends an afternoon on. class LoginScreen extends ConsumerStatefulWidget { const LoginScreen({super.key}); @override ConsumerState createState() => _LoginScreenState(); } class _LoginScreenState extends ConsumerState { final _formKey = GlobalKey(); final _authname = TextEditingController(); final _password = TextEditingController(); bool _obscure = true; bool _rememberTerminal = true; @override void dispose() { _authname.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( authname: _authname.text, password: _password.text, ); if (!ok || !mounted) return; // Admin accounts land on the full shell, cashiers on the till. Read from // the session that was just opened rather than from anything typed here. context.go(AppRoutes.homeFor(ref.read(terminalLoginProvider))); } @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: [ SizedBox( height: tight ? AppSpacing.lg : AppSpacing.xl, ), _Card( formKey: _formKey, authname: _authname, password: _password, obscure: _obscure, rememberTerminal: _rememberTerminal, busy: busy, failure: auth is AuthFailure ? auth.message : null, 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. /// /// 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 so the 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: 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 wherever the // photograph happens to be pale, and the gradient puts the darkest part // behind the card 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)], ), ), ), ], ); } } class _Card extends StatelessWidget { const _Card({ required this.formKey, required this.authname, required this.password, required this.obscure, required this.rememberTerminal, required this.busy, required this.failure, required this.onToggleObscure, required this.onToggleRemember, required this.onSubmit, }); final GlobalKey formKey; final TextEditingController authname; final TextEditingController password; final bool obscure; final bool rememberTerminal; final bool busy; final String? failure; 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 terminal account issued when your outlet was ' 'registered.', style: TextStyle( fontSize: 13, color: AppColors.textSecondary, height: 1.5, ), ), const SizedBox(height: AppSpacing.xl), const _Label('Terminal account'), TextFormField( controller: authname, keyboardType: TextInputType.emailAddress, textInputAction: TextInputAction.next, enabled: !busy, autocorrect: false, // Deliberately not validated as an email address. It looks like // one, but it is an account name the back office issues and its // shape is theirs to change. validator: (v) => (v ?? '').trim().isEmpty ? 'The terminal account is required' : null, decoration: const InputDecoration( hintText: 'supervisor.1135@pos.nearle.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(), // Length is the server's rule to enforce. Refusing to *send* a // short password only produces a second, different error message // for the same wrong credential. validator: (v) => (v ?? '').isEmpty ? 'Password is required' : 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, ), ], ), ), ).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, ), ), ); } }