diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a061278..e2c3bb3 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,7 +1,10 @@ { + "okText": "OK", "emailInputHint": "Email", "passwordInputHint": "Password", "loginButton": "Log in", "invalidEmailError": "Please enter the valid email format.", - "invalidPasswordError": "Password must be at least 8 characters long." + "invalidPasswordError": "Password must be at least 8 characters long.", + "unauthenticatedError": "Your email or password is incorrect.\nPlease try again.", + "loginFailAlertTitle": "Unable to log in" } diff --git a/lib/screens/login/login_form.dart b/lib/screens/login/login_form.dart index fdb12a3..0e34f22 100644 --- a/lib/screens/login/login_form.dart +++ b/lib/screens/login/login_form.dart @@ -12,21 +12,25 @@ class LoginForm extends ConsumerStatefulWidget { const LoginForm({Key? key}) : super(key: key); @override - ConsumerState createState() => _LoginFormState(); + ConsumerState createState() => _LoginFormState(); } class _LoginFormState extends ConsumerState { final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + bool _isFormSubmitted = false; TextFormField get _emailTextField => TextFormField( keyboardType: TextInputType.emailAddress, autocorrect: false, decoration: PrimaryTextFieldDecoration( - hintText: context.localizations?.emailInputHint, + hintText: context.localizations.emailInputHint, hintTextStyle: context.textTheme.bodyMedium, ), style: context.textTheme.bodyMedium, + controller: _emailController, validator: _validateEmail, autovalidateMode: _isFormSubmitted ? AutovalidateMode.onUserInteraction @@ -37,10 +41,11 @@ class _LoginFormState extends ConsumerState { autocorrect: false, obscureText: true, decoration: PrimaryTextFieldDecoration( - hintText: context.localizations?.passwordInputHint, + hintText: context.localizations.passwordInputHint, hintTextStyle: context.textTheme.bodyMedium, ), style: context.textTheme.bodyMedium, + controller: _passwordController, validator: _validatePassword, autovalidateMode: _isFormSubmitted ? AutovalidateMode.onUserInteraction @@ -50,29 +55,36 @@ class _LoginFormState extends ConsumerState { ElevatedButton get _loginButton => ElevatedButton( style: PrimaryButtonStyle(hintTextStyle: context.textTheme.labelMedium), onPressed: _submit, - child: Text(context.localizations?.loginButton ?? ''), + child: Text(context.localizations.loginButton), ); String? _validateEmail(String? email) { if (!ref.read(loginViewModelProvider.notifier).isValidEmail(email)) { - return context.localizations?.invalidEmailError; + return context.localizations.invalidEmailError; } return null; } String? _validatePassword(String? password) { if (!ref.read(loginViewModelProvider.notifier).isValidPassword(password)) { - return context.localizations?.invalidPasswordError; + return context.localizations.invalidPasswordError; } return null; } void _submit() { setState(() => _isFormSubmitted = true); - if (_formKey.currentState?.validate() == true) { - context.dismissKeyboard(); - // TODO: Integrate with API + bool isFormValidated = _formKey.currentState?.validate() ?? false; + + if (!isFormValidated) { + return; } + + context.dismissKeyboard(); + ref.read(loginViewModelProvider.notifier).login( + email: _emailController.text, + password: _passwordController.text, + ); } @override diff --git a/lib/screens/login/login_screen.dart b/lib/screens/login/login_screen.dart index 74e845b..849e6c0 100644 --- a/lib/screens/login/login_screen.dart +++ b/lib/screens/login/login_screen.dart @@ -1,21 +1,25 @@ import 'dart:ui'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:survey_flutter/gen/assets.gen.dart'; import 'package:survey_flutter/screens/login/login_form.dart'; +import 'package:survey_flutter/screens/login/login_view_model.dart'; import 'package:survey_flutter/theme/app_constants.dart'; +import 'package:survey_flutter/uimodels/app_error.dart'; import 'package:survey_flutter/utils/build_context_ext.dart'; +import 'package:survey_flutter/widgets/alert_dialog.dart'; const routePathLoginScreen = '/login'; -class LoginScreen extends StatefulWidget { +class LoginScreen extends ConsumerStatefulWidget { const LoginScreen({Key? key}) : super(key: key); @override - State createState() => _LoginScreenState(); + ConsumerState createState() => _LoginScreenState(); } -class _LoginScreenState extends State +class _LoginScreenState extends ConsumerState with TickerProviderStateMixin { final _animationDuration = const Duration(milliseconds: 600); @@ -110,6 +114,31 @@ class _LoginScreenState extends State @override Widget build(BuildContext context) { + ref.listen>(loginViewModelProvider, (_, next) { + next.maybeWhen( + data: (_) { + // TODO: Navigate to the Home screen + }, + error: (error, _) { + showAlertDialog( + context: context, + title: context.localizations.loginFailAlertTitle, + message: (error as AppError?)?.description(context) ?? '', + actions: [ + TextButton( + style: ButtonStyle( + foregroundColor: MaterialStateProperty.all(Colors.black), + ), + child: Text(context.localizations.okText), + onPressed: () => Navigator.pop(context), + ) + ], + ); + }, + orElse: () {}, + ); + }); + return GestureDetector( onTap: () => context.dismissKeyboard(), child: Scaffold( diff --git a/lib/screens/login/login_view_model.dart b/lib/screens/login/login_view_model.dart index b998798..5649cf4 100644 --- a/lib/screens/login/login_view_model.dart +++ b/lib/screens/login/login_view_model.dart @@ -1,13 +1,12 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:survey_flutter/uimodels/app_error.dart'; final loginViewModelProvider = - StateNotifierProvider.autoDispose((_) { - return LoginViewModel(); -}); - -class LoginViewModel extends StateNotifier { - LoginViewModel() : super([]); + AsyncNotifierProvider.autoDispose(LoginViewModel.new); +class LoginViewModel extends AutoDisposeAsyncNotifier { bool isValidEmail(String? email) { // Just use a simple rule, no fancy Regex! return !(email == null || !email.contains('@')); @@ -16,4 +15,15 @@ class LoginViewModel extends StateNotifier { bool isValidPassword(String? password) { return !(password == null || password.length < 8); } + + login({required String email, required String password}) async { + // TODO: Integrate with API + state = const AsyncError( + AppError.unauthenticated, + StackTrace.empty, + ); + } + + @override + FutureOr build() {} } diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index b5c0f03..65357b1 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -15,6 +15,11 @@ class AppTheme { fontSize: 17, fontWeight: FontWeight.bold, ), + labelLarge: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), ), textSelectionTheme: const TextSelectionThemeData( cursorColor: Colors.white, diff --git a/lib/uimodels/app_error.dart b/lib/uimodels/app_error.dart new file mode 100644 index 0000000..4fdede1 --- /dev/null +++ b/lib/uimodels/app_error.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; +import 'package:survey_flutter/utils/build_context_ext.dart'; + +enum AppError { unauthenticated } + +extension AppErrorExtension on AppError { + String description(BuildContext context) { + switch (this) { + case AppError.unauthenticated: + return context.localizations.unauthenticatedError; + } + } +} diff --git a/lib/utils/build_context_ext.dart b/lib/utils/build_context_ext.dart index d5b84f6..ea3ac4e 100644 --- a/lib/utils/build_context_ext.dart +++ b/lib/utils/build_context_ext.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations_en.dart'; extension BuildContextExtension on BuildContext { TextTheme get textTheme => Theme.of(this).textTheme; - AppLocalizations? get localizations => AppLocalizations.of(this); + AppLocalizations get localizations => + AppLocalizations.of(this) ?? AppLocalizationsEn(); dismissKeyboard() { FocusNode currentFocusNode = FocusScope.of(this); diff --git a/lib/widgets/alert_dialog.dart b/lib/widgets/alert_dialog.dart new file mode 100644 index 0000000..0224be7 --- /dev/null +++ b/lib/widgets/alert_dialog.dart @@ -0,0 +1,39 @@ +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:survey_flutter/utils/build_context_ext.dart'; + +Future showAlertDialog({ + required BuildContext context, + required String title, + required String message, + required List actions, +}) { + if (Platform.isIOS) { + return showCupertinoDialog( + context: context, + builder: (BuildContext context) => CupertinoAlertDialog( + title: Text(title), + content: Text(message), + actions: actions, + ), + ); + } else if (Platform.isAndroid) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) => AlertDialog( + title: Text(title), + titleTextStyle: + context.textTheme.labelLarge?.copyWith(color: Colors.black), + content: Text(message), + contentTextStyle: + context.textTheme.labelMedium?.copyWith(color: Colors.black), + actions: actions, + ), + ); + } else { + return Future(() => {}); + } +}