srs/lib/features/auth/login_screen.dart
Aswin Biju 45fcdcebfb Initial commit
Add SRS app: results, shops, orders and users, wired to srs-api
Flutter web/PWA for the lottery agency, built on Riverpod and go_router,
talking to srs-api over HTTP (HttpApi, token kept in SharedPreferences).

- Results, Shops, Orders and Users tabs (Users for admins only), with
  phone, tablet and desktop layouts and shared widgets and theme tokens
- Users: list with search and Active/Inactive filters; user detail with
  shops, sign-in details, and an Access card
- WhatsApp results switch on each user (sendResults on
  PATCH /api/users/:id). Shows the WhatsApp logo (Simple Icons, CC0,
  drawn with flutter_svg), green only when results will really go out.
  Can't be turned on without a phone number ("Add a phone number first"
  opens the phone sheet), and is paused while the user is inactive. The
  users list marks people who get results with the logo.
- Each Access switch has a one-line description; the explanation
  paragraphs are gone
- Remove demo mode: the app always uses the real AP
  test/support and is used only by tests.
2026-11-04 23:21:50 +05:30

247 lines
7.3 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api.dart';
import '../../core/providers.dart';
import '../../core/theme.dart';
import '../../widgets/ui.dart';
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _user = TextEditingController();
final _pass = TextEditingController();
bool _busy = false;
String? _error;
@override
void dispose() {
_user.dispose();
_pass.dispose();
super.dispose();
}
Future<void> _submit() async {
if (_busy) return;
if (_user.text.trim().isEmpty || _pass.text.isEmpty) {
setState(() => _error = 'Enter your username and password');
return;
}
setState(() {
_busy = true;
_error = null;
});
try {
await ref.read(sessionProvider.notifier).login(_user.text, _pass.text);
} on ApiException catch (e) {
if (mounted) setState(() => _error = e.message);
} catch (_) {
if (mounted) {
setState(() => _error = 'Unable to sign in. Please try again.');
}
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: C.navy,
body: LayoutBuilder(
builder: (context, c) {
final landscape = c.maxWidth > c.maxHeight && c.maxWidth >= 600;
if (c.maxWidth >= 900 || landscape) {
return Row(
children: [
Expanded(child: _BrandPanel(large: c.maxHeight >= 560)),
Container(
width: c.maxWidth >= 1280
? 560
: (c.maxWidth >= 900 ? 480 : 420),
color: C.bg,
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(S.s36),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 380),
child: _form(),
),
),
),
),
],
);
}
final hero = (c.maxHeight * 0.36).clamp(230.0, 340.0);
return Column(
children: [
SizedBox(
height: hero,
width: double.infinity,
child: const _BrandPanel(large: false),
),
Expanded(
child: Container(
width: double.infinity,
decoration: const BoxDecoration(
color: C.bg,
borderRadius: BorderRadius.vertical(
top: Radius.circular(R.hero),
),
),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
S.s24,
S.s28,
S.s24,
S.s24,
),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: _form(),
),
),
),
),
),
],
);
},
),
);
}
Widget _form() => AutofillGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Welcome back', style: ts(24, w: w7, ls: -0.3)),
const SizedBox(height: S.s4),
Text('Sign in to continue', style: ts(14, c: C.muted)),
const SizedBox(height: S.s24),
SrsField(
controller: _user,
label: 'Username',
hint: 'Enter username',
error: _error != null,
enabled: !_busy,
autofocus: true,
autofill: const [AutofillHints.username],
onChanged: (_) => setState(() => _error = null),
),
const SizedBox(height: S.s16),
SrsField(
controller: _pass,
label: 'Password',
hint: 'Enter password',
password: true,
error: _error != null,
enabled: !_busy,
autofill: const [AutofillHints.password],
onChanged: (_) => setState(() => _error = null),
onSubmitted: (_) => _submit(),
),
if (_error != null) ...[
const SizedBox(height: S.s12),
ErrorLine(_error!),
],
const SizedBox(height: S.s22),
BigButton('Sign in', loading: _busy, onPressed: _submit),
const SizedBox(height: S.s18),
Center(
child: Text(
'Forgot your password? Contact your administrator.',
textAlign: TextAlign.center,
style: ts(13, c: C.muted),
),
),
],
),
);
}
class _BrandPanel extends StatelessWidget {
const _BrandPanel({required this.large});
final bool large;
@override
Widget build(BuildContext context) {
final coin = large ? 112.0 : 84.0;
return DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [C.navy, C.navyDeep],
),
),
child: Stack(
children: [
Positioned(
right: large ? -120 : -50,
top: large ? -80 : -30,
child: _ring(large ? 420 : 190, C.onNavy8),
),
Positioned(
left: large ? -90 : -40,
bottom: large ? -60 : 10,
child: _ring(large ? 300 : 120, C.onNavy5),
),
SafeArea(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: coin,
height: coin,
decoration: const BoxDecoration(
color: C.gold,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: C.goldGlow,
blurRadius: 30,
offset: Offset(0, 10),
),
],
),
child: Icon(
Icons.confirmation_number_rounded,
color: C.navy,
size: coin * 0.48,
),
),
SizedBox(height: large ? S.s24 : S.s18),
Text(
'SRS',
style: ts(large ? 44 : 34, w: w7, c: Colors.white, ls: 2),
),
const SizedBox(height: S.s6),
Text(
'Your sequences, at a glance',
style: ts(large ? 16 : 14, c: C.softOnNavy),
),
],
),
),
),
],
),
);
}
Widget _ring(double size, Color color) => Container(
width: size,
height: size,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
);
}