import 'dart: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/router/app_router.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.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(); // Blank on purpose. The role tabs that used to sit above these fields chose // between two built-in accounts and decided, locally, whether the terminal // opened the admin shell or the billing screen. The back office decides that // now — `POST /login` answers with the role — so offering the choice here // would only let someone pick a screen the server is about to override. final _email = TextEditingController(); final _password = TextEditingController(); bool _obscure = true; bool _rememberTerminal = true; @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( body: Stack( fit: StackFit.expand, children: [ // Background image Image.asset( 'assets/images/bg.webp', fit: BoxFit.cover, ), // Light blur over the image 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, 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: const TextStyle( fontSize: 11.5, color: AppColors.textTertiary, ), ), ), ], ), ), ), ), ); }, ), ), ], ), ); } } /// 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: 20, fontWeight: FontWeight.w700, letterSpacing: -0.4, color: Colors.white, height: 1.2, ), ), if (!tight) ...[ const SizedBox(height: 2), const Text( 'Scanner-first billing for Indian retail', style: TextStyle( fontSize: 13, color: AppColors.textSecondary, ), ), ], ], ).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.busy, required this.failure, 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 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(0x0F101828), blurRadius: 24, offset: Offset(0, 8), ), ], ), 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), const _Label('Email or username'), TextFormField( controller: email, keyboardType: TextInputType.emailAddress, textInputAction: TextInputAction.next, enabled: !busy, // Not validated as an email. The back office takes this as // `authname` and accepts either form; rejecting a username here // would block an account the server would have let in. validator: (v) => (v ?? '').trim().isEmpty ? 'This is required' : null, 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' : 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, ), ), ); } }