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/utils/validators.dart'; import '../../../core/widgets/primary_button.dart'; import '../../../domain/entities/pos_session.dart'; import '../providers/auth_controller.dart'; /// Store sign-in. The terminal shows this until a valid account is entered. class LoginScreen extends ConsumerStatefulWidget { const LoginScreen({super.key}); @override ConsumerState createState() => _LoginScreenState(); } class _LoginScreenState extends ConsumerState { final _formKey = GlobalKey(); 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 auth = ref.read(authControllerProvider.notifier); var ok = await auth.signIn( email: _email.text, password: _password.text, ); if (!ok || !mounted) return; // A proprietor with several shops signs in once and then says which counter // this terminal is standing at. The back office has already decided which // outlets they may reach, so this is a choice among those — never a free // text field, which is what the old Settings store id amounted to. final session = auth.session; if (session != null && session.hasChoiceOfOutlet) { final chosen = await showDialog( context: context, // Not dismissable: a terminal has to be standing somewhere, and // defaulting silently to the first outlet is how a day's takings end up // filed against the wrong shop. barrierDismissible: false, builder: (_) => _OutletPicker(session: session), ); if (chosen == null || !mounted) return; if (chosen.locationId != session.locationId) { ok = await auth.signIn( email: _email.text, password: _password.text, locationId: chosen.locationId, ); if (!ok || !mounted) return; } } if (mounted) context.go(AppRoutes.pos); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.background, body: LayoutBuilder( builder: (context, constraints) { // Below this there isn't room for the brand panel beside the form. final showBrandPanel = constraints.maxWidth >= 1000; return Row( children: [ if (showBrandPanel) const Expanded(flex: 5, child: _BrandPanel()), Expanded( flex: 4, child: _FormPanel( formKey: _formKey, email: _email, password: _password, obscure: _obscure, rememberTerminal: _rememberTerminal, showCompactLogo: !showBrandPanel, onToggleObscure: () => setState(() => _obscure = !_obscure), onToggleRemember: (v) => setState(() => _rememberTerminal = v ?? true), onSubmit: _submit, ), ), ], ); }, ), ); } } class _BrandPanel extends StatelessWidget { const _BrandPanel(); @override Widget build(BuildContext context) { return Container( decoration: const BoxDecoration(gradient: AppColors.primaryGradient), child: SafeArea( child: Padding( padding: const EdgeInsets.all(AppSpacing.giant), child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( width: 42, height: 42, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(11), ), alignment: Alignment.center, child: Image.asset('assets/images/logo.png', fit: BoxFit.contain), ), const SizedBox(width: AppSpacing.md), const Flexible( child: Text( 'Nearle POS', overflow: TextOverflow.ellipsis, style: TextStyle( color: Colors.white, fontSize: 22, fontWeight: FontWeight.w700, letterSpacing: -0.4, ), ), ), ], ), const SizedBox(height: AppSpacing.giant), const Text( 'Billing that keeps up\nwith your counter.', style: TextStyle( color: Colors.white, fontSize: 34, height: 1.25, fontWeight: FontWeight.w700, letterSpacing: -1, ), ), const SizedBox(height: AppSpacing.lg), Text( 'Scanner-first billing, GST-ready invoices and loyalty ' 'built in — for supermarkets, pharmacies and retail.', style: TextStyle( color: Colors.white.withValues(alpha: 0.78), fontSize: 15, height: 1.6, ), ), const SizedBox(height: AppSpacing.giant), const _Feature( icon: Icons.qr_code_scanner_rounded, title: 'Scan and go', body: 'No dialogs between items. Barcode to bill instantly.', ), const _Feature( icon: Icons.receipt_long_rounded, title: 'GST compliant', body: 'Per-slab tax split into CGST and SGST on every bill.', ), const _Feature( icon: Icons.stars_rounded, title: 'Loyalty that runs itself', body: 'Tiers and points applied without cashier input.', ), ], ), ), ), ), ).animate().fadeIn(duration: 300.ms); } } class _Feature extends StatelessWidget { const _Feature({ required this.icon, required this.title, required this.body, }); final IconData icon; final String title; final String body; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(bottom: AppSpacing.xl), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 38, height: 38, decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.16), borderRadius: AppRadius.brSm, ), child: Icon(icon, color: Colors.white, size: 19), ), const SizedBox(width: AppSpacing.lg), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: const TextStyle( color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 2), Text( body, style: TextStyle( color: Colors.white.withValues(alpha: 0.72), fontSize: 13, height: 1.5, ), ), ], ), ), ], ), ); } } class _FormPanel extends ConsumerWidget { const _FormPanel({ required this.formKey, required this.email, required this.password, required this.obscure, required this.rememberTerminal, required this.showCompactLogo, 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 showCompactLogo; final VoidCallback onToggleObscure; final ValueChanged onToggleRemember; final VoidCallback onSubmit; @override Widget build(BuildContext context, WidgetRef ref) { final auth = ref.watch(authControllerProvider); final session = ref.watch(cashierSessionProvider); final busy = auth is Authenticating; return SafeArea( child: Center( child: SingleChildScrollView( padding: const EdgeInsets.all(AppSpacing.xxl), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 420), child: Form( key: formKey, autovalidateMode: AutovalidateMode.onUserInteraction, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ if (showCompactLogo) ...[ Center( child: Container( width: 52, height: 52, decoration: BoxDecoration( gradient: AppColors.primaryGradient, borderRadius: BorderRadius.circular(14), ), alignment: Alignment.center, child: const Text( 'N', style: TextStyle( color: Colors.white, fontSize: 26, fontWeight: FontWeight.w800, ), ), ), ), const SizedBox(height: AppSpacing.xxl), ], Text( 'Sign in to your store', style: Theme.of(context).textTheme.headlineSmall, ), const SizedBox(height: AppSpacing.xs), const Text( 'Use the credentials issued when your outlet was ' 'registered.', style: TextStyle( fontSize: 13.5, color: AppColors.textSecondary, height: 1.5, ), ), const SizedBox(height: AppSpacing.xxxl), 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 (auth is AuthFailure) ...[ 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( auth.message, style: const TextStyle( color: AppColors.danger, fontSize: 13, ), ), ), ], ), ).animate().shake(duration: 320.ms, hz: 3), ], const SizedBox(height: AppSpacing.xl), PrimaryButton( label: 'Sign in', icon: Icons.login_rounded, large: true, busy: busy, onPressed: onSubmit, ), const SizedBox(height: AppSpacing.xxl), Center( child: Text( 'Terminal ${session.terminalId}', style: const TextStyle( fontSize: 11.5, color: AppColors.textTertiary, ), ), ), ], ), ), ), ), ), ); } } 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, ), ), ); } } /// Asks which of the signed-in account's outlets this terminal is standing in. /// /// Only shown when there is genuinely a choice. A shop manager pinned to one /// location never sees it, which is the common case — this is for a proprietor /// whose account reaches several shops. /// /// The list comes from the back office and cannot be typed into. That is the /// whole difference from what this replaced: the outlet used to be a store id /// entered in Settings, so a till asserted which shop it belonged to. Here it /// picks from what the account is already entitled to, and the choice is /// re-checked server-side when the new session is issued. class _OutletPicker extends StatelessWidget { const _OutletPicker({required this.session}); final PosSession session; @override Widget build(BuildContext context) { return AlertDialog( backgroundColor: AppColors.surface, shape: RoundedRectangleBorder(borderRadius: AppRadius.brMd), title: const Text( 'Which outlet is this terminal at?', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700), ), content: SizedBox( width: 380, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.only(bottom: AppSpacing.md), child: Text( 'Signed in as ${session.fullName.isNotEmpty ? session.fullName : session.email}' ' · ${session.tenantName}', style: const TextStyle( fontSize: 12.5, color: AppColors.textSecondary, ), ), ), Flexible( child: ListView.separated( shrinkWrap: true, itemCount: session.outlets.length, separatorBuilder: (_, __) => const SizedBox(height: AppSpacing.xs), itemBuilder: (context, i) { final outlet = session.outlets[i]; final isCurrent = outlet.locationId == session.locationId; return ListTile( dense: true, shape: RoundedRectangleBorder( borderRadius: AppRadius.brSm, side: BorderSide( color: isCurrent ? AppColors.primaryBorder : AppColors.border, ), ), tileColor: isCurrent ? AppColors.primarySurface : AppColors.surface, title: Text( outlet.locationName.isNotEmpty ? outlet.locationName : 'Outlet ${outlet.locationId}', style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, ), ), // The numeric id is shown deliberately. It is what appears // in the back office, on a support call and in every log // line, so a person can match what they are looking at. subtitle: Text( [ 'ID ${outlet.locationId}', if (outlet.city.isNotEmpty) outlet.city, ].join(' · '), style: const TextStyle( fontSize: 11.5, color: AppColors.textTertiary, ), ), onTap: () => Navigator.of(context).pop(outlet), ); }, ), ), ], ), ), ); } }