e_receipt_mobile/lib/presentation/login/login_page.dart

297 lines
14 KiB
Dart
Raw Normal View History

2026-03-30 09:40:06 +00:00
import 'package:e_receipt_mobile/presentation/login/login_form_providers.dart';
import 'package:e_receipt_mobile/presentation/login/login_state.dart';
2026-02-13 19:46:02 +00:00
import 'package:e_receipt_mobile/presentation/login/login_view_model.dart';
2026-03-30 09:40:06 +00:00
import 'package:e_receipt_mobile/presentation/login/widgets/login_background.dart';
import 'package:e_receipt_mobile/presentation/login/widgets/login_error_banner.dart';
2026-02-13 19:46:02 +00:00
import 'package:flutter/material.dart';
2026-03-30 09:40:06 +00:00
import 'package:flutter/services.dart';
2026-02-13 19:46:02 +00:00
import 'package:flutter_riverpod/flutter_riverpod.dart';
2026-03-30 09:40:06 +00:00
class LoginPage extends ConsumerWidget {
2026-02-13 19:46:02 +00:00
const LoginPage({super.key});
2026-03-30 09:40:06 +00:00
void _submit({
required WidgetRef ref,
required LoginState state,
required GlobalKey<FormState> formKey,
required TextEditingController usernameController,
required TextEditingController passwordController,
}) {
if (state.isLoading) {
return;
}
2026-02-13 19:46:02 +00:00
2026-03-30 09:40:06 +00:00
ref.read(loginViewModelProvider.notifier).clearMessages();
FocusManager.instance.primaryFocus?.unfocus();
ref.read(loginAttemptedSubmitProvider.notifier).state = true;
2026-02-13 19:46:02 +00:00
2026-03-30 09:40:06 +00:00
if (!(formKey.currentState?.validate() ?? false)) {
return;
}
TextInput.finishAutofillContext();
ref.read(loginViewModelProvider.notifier).login(
username: usernameController.text,
password: passwordController.text,
);
2026-02-13 19:46:02 +00:00
}
@override
2026-03-30 09:40:06 +00:00
Widget build(BuildContext context, WidgetRef ref) {
2026-02-13 19:46:02 +00:00
final state = ref.watch(loginViewModelProvider);
2026-03-30 09:40:06 +00:00
final colorScheme = Theme.of(context).colorScheme;
2026-02-13 19:46:02 +00:00
2026-03-30 09:40:06 +00:00
final formKey = ref.watch(loginFormKeyProvider);
final usernameController = ref.watch(loginUsernameControllerProvider);
final passwordController = ref.watch(loginPasswordControllerProvider);
final usernameFocusNode = ref.watch(loginUsernameFocusNodeProvider);
final passwordFocusNode = ref.watch(loginPasswordFocusNodeProvider);
final obscurePassword = ref.watch(loginObscurePasswordProvider);
final attemptedSubmit = ref.watch(loginAttemptedSubmitProvider);
2026-02-13 19:46:02 +00:00
return Scaffold(
2026-03-30 09:40:06 +00:00
body: AnnotatedRegion<SystemUiOverlayStyle>(
value: Theme.of(context).brightness == Brightness.dark
? SystemUiOverlayStyle.light
: SystemUiOverlayStyle.dark,
child: LoginBackground(
child: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 24,
),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: Card(
elevation: 0,
color: colorScheme.surface.withOpacity(
Theme.of(context).brightness == Brightness.dark
? 0.75
: 0.92,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
side: BorderSide(
color: colorScheme.outlineVariant.withOpacity(0.35),
2026-02-14 10:16:13 +00:00
),
2026-03-30 09:40:06 +00:00
),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 22, 20, 18),
child: AutofillGroup(
child: Form(
key: formKey,
autovalidateMode: attemptedSubmit
? AutovalidateMode.onUserInteraction
: AutovalidateMode.disabled,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Container(
height: 44,
width: 44,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
colorScheme.primary,
colorScheme.tertiary,
],
),
),
child: Icon(
Icons.receipt_long,
color: colorScheme.onPrimary,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
'E-Receipt',
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(
fontWeight: FontWeight.w700,
),
),
Text(
'Sign in to continue',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: colorScheme
.onSurfaceVariant,
),
),
],
),
),
],
),
const SizedBox(height: 18),
LoginErrorBanner(message: state.errorMessage),
if (state.errorMessage != null)
const SizedBox(height: 14),
TextFormField(
controller: usernameController,
focusNode: usernameFocusNode,
enabled: !state.isLoading,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.username],
onFieldSubmitted: (_) =>
passwordFocusNode.requestFocus(),
validator: (value) {
if ((value ?? '').trim().isEmpty) {
return 'Username is required';
}
return null;
},
decoration: InputDecoration(
labelText: 'Username',
hintText: 'Enter your username',
prefixIcon: const Icon(Icons.person_outline),
filled: true,
fillColor: colorScheme.surfaceContainerHighest
.withOpacity(
Theme.of(context).brightness ==
Brightness.dark
? 0.55
: 0.9,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
),
2026-02-13 19:46:02 +00:00
),
2026-03-30 09:40:06 +00:00
),
const SizedBox(height: 12),
TextFormField(
controller: passwordController,
focusNode: passwordFocusNode,
enabled: !state.isLoading,
textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.password],
obscureText: obscurePassword,
onFieldSubmitted: (_) => _submit(
ref: ref,
state: state,
formKey: formKey,
usernameController: usernameController,
passwordController: passwordController,
),
validator: (value) {
if ((value ?? '').isEmpty) {
return 'Password is required';
}
if (value!.length < 6) {
return 'Password must be at least 6 characters';
}
return null;
},
decoration: InputDecoration(
labelText: 'Password',
hintText: 'Enter your password',
prefixIcon: const Icon(Icons.lock_outline),
filled: true,
fillColor: colorScheme.surfaceContainerHighest
.withOpacity(
Theme.of(context).brightness ==
Brightness.dark
? 0.55
: 0.9,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
),
suffixIcon: IconButton(
tooltip: obscurePassword
? 'Show password'
: 'Hide password',
onPressed: state.isLoading
? null
: () => ref
.read(
loginObscurePasswordProvider
.notifier,
)
.state =
!obscurePassword,
icon: Icon(
obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
),
),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: state.isLoading
? null
: () => _submit(
ref: ref,
state: state,
formKey: formKey,
usernameController: usernameController,
passwordController: passwordController,
),
icon: state.isLoading
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.login),
label: Padding(
padding: const EdgeInsets.symmetric(
vertical: 12,
),
child: Text(
state.isLoading
? 'Signing in...'
: 'Sign in',
style: const TextStyle(
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(height: 12),
Text(
"By continuing, you agree to your organization's policies.",
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
2026-02-13 19:46:02 +00:00
),
2026-03-30 09:40:06 +00:00
),
2026-02-13 19:46:02 +00:00
),
),
),
),
),
),
),
);
}
}
2026-03-30 09:40:06 +00:00