The role split was right; only its source was wrong. Signing in matched what was typed against two constants compiled into the app — admin@nearle.in and cashier@nearle.in — so which shell a person got was a property of the *build*. A shop could not add a third person, revoke either of the two it had, or stop anyone with the APK reading both passwords out of it. TerminalLogin survives unchanged in shape, because the shape was the good part: one flag the shell reads, a session that decides it, and a cashier sign-out that takes the catalogue with it while a supervisor's leaves it behind. Every consumer — visibleModulesProvider, resolvedModuleProvider, the sidebar, the page header, the sign-out dialog — is untouched. What changed is that the enum is now only constructible from a session the back office signed, so there is no path left where the terminal grants itself a permission the server did not send. It reads `can_manage_staff` rather than the role name or id. app_roles holds six rows for four distinct roles, a great many accounts carry a roleid that is not in the table at all, and the name comes back blank for most of them. Matching on either would mean shipping a copy of the role table in the app and keeping the two in step for ever. One boolean, decided server-side, cannot drift. It defaults to false, which matters on the restore path: a session saved by a build that predates the field comes back as a cashier, never silently as an admin. This also restores the sign-in layer itself — pos_auth_api, pos_session, session_store, the staff import and the bearer token — which an earlier commit removed wholesale from a stale checkout. Its parent was the commit that added them, so the deletion was a bad merge rather than a decision; the terminal has been running on the two constants since. The login screen loses its role tabs and its credential prefill. You do not choose what you are on the way in. The opener is now matched on the back office user id rather than on the first account with a matching role, so the first bill of a shift is attributed to whoever actually signed in. Tests: the smoke suite pinned only the supervisor shell, and it was passing for the wrong reason — the fake session omitted can_manage_staff, and the sidebar it asserted on was there because the role was hardcoded. Both halves are pinned now and the fake is parameterised. widget_test.dart was the stock Flutter counter template, restored by the same bad merge, testing a MyApp that has never existed in this repo. 292 tests pass; analyzer reports no errors and no warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
396 lines
13 KiB
Dart
396 lines
13 KiB
Dart
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 '../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<LoginScreen> createState() => _LoginScreenState();
|
|
}
|
|
|
|
class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
|
|
// Both fields start empty. There is nothing to prefill: a person signs in
|
|
// with their own back-office account, and which shell they get is a property
|
|
// of that account rather than a tab they picked before typing.
|
|
final _email = TextEditingController();
|
|
final _password = TextEditingController();
|
|
|
|
bool _obscure = true;
|
|
bool _rememberTerminal = true;
|
|
|
|
@override
|
|
void dispose() {
|
|
_email.dispose();
|
|
_password.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _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<FormState> formKey;
|
|
final TextEditingController email;
|
|
final TextEditingController password;
|
|
final bool obscure;
|
|
final bool rememberTerminal;
|
|
final bool busy;
|
|
final String? failure;
|
|
final VoidCallback onToggleObscure;
|
|
final ValueChanged<bool?> 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('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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
).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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|