Created
June 15, 2021 16:58
-
-
Save zamahaka/2c8b57724f752ef3cbe4888b2dff2523 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'package:firebase_crashlytics/firebase_crashlytics.dart'; | |
import 'package:flutter/foundation.dart'; | |
import 'package:flutter/gestures.dart'; | |
import 'package:flutter/material.dart'; | |
import 'package:flutter/services.dart'; | |
import 'package:flutter_bloc/flutter_bloc.dart'; | |
import 'package:mobile/analytics.dart'; | |
import 'package:mobile/auth/password/reset_password_screen.dart'; | |
import 'package:mobile/dimens.dart'; | |
import 'package:mobile/general/extensions/scaffold_extension.dart'; | |
import 'package:mobile/general/utils/biometrics_provider.dart'; | |
import 'package:mobile/general/widgets/progress_button.dart'; | |
import 'package:mobile/keys.dart'; | |
import 'package:mobile/localization.dart'; | |
import 'package:mobile/main/main_screen.dart'; | |
import 'package:mobile/model/user.dart'; | |
import 'package:mobile/repository/api/api_failure_handler.dart'; | |
import 'package:mobile/repository/credentials_store.dart'; | |
import 'package:mobile/repository/user_repository.dart'; | |
import 'package:mobile/styles.dart'; | |
import 'package:tuple/tuple.dart'; | |
import 'package:url_launcher/url_launcher.dart'; | |
import 'input_validation/input_validators_factory.dart'; | |
import 'titled_textfield.dart'; | |
enum LoginButtonState { idle, progress } | |
const double LOWER_LOGO_INSETS_TRIGGER = 690; | |
class LoginScreen extends StatefulWidget { | |
static const String ANALYTICS_NAME = 'LoginScreen'; | |
const LoginScreen({Key key}) : super(key: key); | |
@override | |
LoginScreenState createState() => LoginScreenState(); | |
} | |
class LoginScreenState extends State<LoginScreen> | |
with TickerProviderStateMixin { | |
LoginButtonState loginButtonState = LoginButtonState.idle; | |
final TextEditingController usernameController = TextEditingController(); | |
final TextEditingController passwordController = TextEditingController(); | |
final GlobalKey<TitledTextFieldState> usernameKey = GlobalKey(); | |
final GlobalKey<TitledTextFieldState> passwordKey = GlobalKey(); | |
final GlobalKey<ProgressButtonState> loginKey = GlobalKey(); | |
final GlobalKey<ScaffoldState> scaffoldKey = GlobalKey(); | |
UserRepository get repo => context.repository<UserRepository>(); | |
BiometricsProvider get biometricsProvider => | |
context.repository<BiometricsProvider>(); | |
CredentialsStore get credentialsStore => | |
context.repository<CredentialsStore>(); | |
@override | |
void initState() { | |
super.initState(); | |
if (widget.key != null) scheduleAutoLogin(); | |
} | |
scheduleAutoLogin() async { | |
await Future.delayed(const Duration(seconds: 2), () {}); | |
if (mounted) performAutoLogin(); | |
} | |
void resetPassword() { | |
analytics.logEvent(name: PASSWORD_RESET); | |
Navigator.of(context).push( | |
MaterialPageRoute( | |
settings: RouteSettings( | |
name: ResetPasswordScreen.ANALYTICS_NAME, | |
), | |
builder: (_) => ResetPasswordScreen(userRepo: repo), | |
), | |
); | |
} | |
bool useBiometrics = false; | |
Future<void> test() async => | |
useBiometrics = await credentialsStore.isAutoLoginActive(); | |
Future<void> performAutoLogin() async { | |
if (useBiometrics) { | |
if (await biometricsProvider | |
.canCheckBiometrics()) if (await biometricsProvider.authorize()) { | |
await _prefillUserData(); | |
loginKey.currentState.simulateClick(); | |
final user = await _performLogin(); | |
if (user != null) await _saveUserData(); | |
if (user != null) _navigateApp(); | |
} | |
} | |
} | |
@override | |
Widget build(BuildContext context) => Scaffold( | |
key: scaffoldKey, | |
resizeToAvoidBottomInset: false, | |
body: GestureDetector( | |
behavior: HitTestBehavior.translucent, | |
onTap: _unfocus, | |
child: SafeArea( | |
child: FutureBuilder<void>( | |
future: test(), | |
builder: (BuildContext context, AsyncSnapshot<void> snapshot) { | |
return _buildBody(); | |
}, | |
)), | |
), | |
); | |
_prefillUserData() async { | |
usernameController.text = await credentialsStore.getUsername(); | |
passwordController.text = await credentialsStore.getPassword(); | |
} | |
Widget _buildBody() { | |
return Column( | |
children: <Widget>[ | |
_animatedLogoSpacing( | |
_shouldShrinkSpacings(context) | |
? _logoSpacing(context).item1 | |
: INSET_ON_OPEN_KEYBOARD, | |
), | |
GestureDetector( | |
onDoubleTap: !kReleaseMode | |
? () { | |
usernameController.text = 'brandthuseman@gmail.com'; | |
passwordController.text = '21201'; | |
} | |
: null, | |
child: Image.asset( | |
"images/login/logo.png", | |
height: LOGO_HEIGHT, | |
), | |
), | |
_animatedLogoSpacing( | |
_shouldShrinkSpacings(context) | |
? _logoSpacing(context).item2 | |
: INSET_ON_OPEN_KEYBOARD, | |
), | |
Padding( | |
key: ValueKey(Keys.LOGIN_USERNAME_CONTAINER_KEY), | |
padding: EdgeInsets.symmetric(horizontal: APP_HORIZONTAL_SPACING), | |
child: TitledTextField( | |
key: usernameKey, | |
title: context.appLocalization.Username, | |
hint: context.appLocalization.username_TextField_placeholder, | |
inputAction: TextInputAction.next, | |
inputType: TextInputType.text, | |
nextCallback: _moveToPassword, | |
inputValidator: | |
InputValidatorsFactory(context).validator(InputType.USERNAME), | |
controller: usernameController, | |
), | |
), | |
Padding( | |
key: ValueKey(Keys.LOGIN_PASSWORD_CONTAINER_KEY), | |
padding: EdgeInsets.symmetric(horizontal: APP_HORIZONTAL_SPACING), | |
child: TitledTextField( | |
key: passwordKey, | |
title: context.appLocalization.Password, | |
hint: context.appLocalization.password_TextField_placeholder, | |
inputAction: TextInputAction.done, | |
inputType: TextInputType.visiblePassword, | |
// TODO: Proper progress management | |
nextCallback: () => loginKey.currentState.simulateClick(), | |
inputValidator: | |
InputValidatorsFactory(context).validator(InputType.PASSWORD), | |
controller: passwordController, | |
), | |
), | |
Align( | |
alignment: Alignment.centerRight, | |
child: FlatButton( | |
onPressed: resetPassword, | |
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, | |
child: Text( | |
context.appLocalization.forgot_password_question, | |
style: context.textTheme.bodyExtraSmall.copyWith( | |
decoration: TextDecoration.underline, | |
), | |
), | |
), | |
), | |
SizedBox(height: LOGIN_BUTTON_TOP_INSET), | |
Padding( | |
padding: EdgeInsets.symmetric( | |
horizontal: APP_HORIZONTAL_SPACING, | |
), | |
child: ProgressButton( | |
key: loginKey, | |
width: double.infinity, | |
height: LOGIN_BUTTON_HEIGHT, | |
type: ProgressButtonType.Raised, | |
inProgress: loginButtonState == LoginButtonState.progress, | |
borderRadius: APP_HORIZONTAL_SPACING / 2, | |
color: context.theme.primaryColor, | |
defaultWidget: Text( | |
loginButtonState == LoginButtonState.idle | |
? context.appLocalization.LOG_IN | |
: context.appLocalization.WELCOME_BACK, | |
style: context.primaryTextTheme.heading5, | |
), | |
progressWidget: const CircularProgressIndicator( | |
valueColor: AlwaysStoppedAnimation( | |
AppColors.white // TODO: Proper dark theme color | |
), | |
), | |
onPressed: () async { | |
final user = await _performLogin(); | |
if (user != null) await _saveUserData(); | |
return user != null ? _navigateApp : () {}; | |
}, | |
), | |
), | |
SizedBox(height: HELP_TOP_INSET / 2), | |
Align( | |
alignment: Alignment.centerRight, | |
child: Row( | |
mainAxisAlignment: MainAxisAlignment.center, | |
children: <Widget>[ | |
Text( | |
context.appLocalization.Biometrics_login_hint, | |
style: context.textTheme.bodyExtraSmall, | |
), | |
Checkbox( | |
value: useBiometrics, | |
onChanged: (newValue) async { | |
var autologin = false; | |
if (newValue) { | |
autologin = await biometricsProvider.authorize(); | |
} | |
await credentialsStore.storeAutologin(autologin.toString()); | |
setState(() {}); | |
}, | |
), | |
], | |
), | |
), | |
SizedBox(height: HELP_TOP_INSET / 4), | |
Visibility( | |
// Not for release 1.0 | |
visible: false, | |
child: FlatButton( | |
child: Text( | |
context.appLocalization.need_help_question, | |
style: context.textTheme.bodyExtraSmall.copyWith( | |
decoration: TextDecoration.underline, | |
), | |
), | |
onPressed: () { | |
analytics.logEvent(name: NEED_HELP_LOGGING_IN); | |
scaffoldKey.currentState.showSnackBar(SnackBar( | |
content: Text('Sending email'), | |
duration: Duration(seconds: 1), | |
)); | |
}, | |
), | |
), | |
Expanded( | |
child: Align( | |
alignment: Alignment.bottomCenter, | |
child: Container( | |
height: BOTTOM_MENU_INSET, | |
margin: EdgeInsets.only( | |
bottom: BOTTOM_MENU_BOTTOM_INSET, | |
left: APP_HORIZONTAL_SPACING, | |
right: APP_HORIZONTAL_SPACING, | |
), | |
child: Column( | |
children: <Widget>[ | |
Text( | |
context.appLocalization.policy_conditions_title, | |
style: context.textTheme.bodyExtraSmall, | |
), | |
RichText( | |
text: TextSpan( | |
style: context.textTheme.bodyExtraSmall, | |
children: <TextSpan>[ | |
TextSpan( | |
text: context.appLocalization.Privacy_Policy, | |
style: context.textTheme.heading6, | |
recognizer: TapGestureRecognizer() | |
..onTap = () => _openPrivacy(), | |
), | |
TextSpan( | |
text: ' ${context.appLocalization.and} ', | |
), | |
TextSpan( | |
text: context.appLocalization.Terms_and_Conditions, | |
style: context.textTheme.heading6, | |
recognizer: TapGestureRecognizer() | |
..onTap = () => _openTerms(), | |
), | |
], | |
), | |
), | |
], | |
), | |
), | |
), | |
), | |
], | |
); | |
} | |
_saveUserData() async { | |
if (!useBiometrics) return; | |
var u = useBiometrics ? usernameController.text : null; | |
var p = useBiometrics ? passwordController.text : null; | |
await credentialsStore.storeUsername(u); | |
await credentialsStore.storePassword(p); | |
} | |
void _moveToPassword() => passwordKey.currentState.becomeFocused(); | |
void _unfocus() { | |
usernameKey.currentState.becomeUnfocused(); | |
passwordKey.currentState.becomeUnfocused(); | |
} | |
bool _validateInput() { | |
_unfocus(); | |
final usernameValid = usernameKey.currentState.validate(); | |
final passwordValid = passwordKey.currentState.validate(); | |
return usernameValid && passwordValid; | |
} | |
Future<User> _performLogin() async { | |
analytics.logEvent(name: LOG_IN); | |
final isValidInput = _validateInput(); | |
if (!isValidInput) return null; | |
setState(() => loginButtonState = LoginButtonState.progress); | |
try { | |
final user = await repo.logIn( | |
username: usernameController.text, | |
password: passwordController.text, | |
); | |
analytics.logLogin(); | |
reportUser(user); | |
return user; | |
} catch (e, s) { | |
setState(() => loginButtonState = LoginButtonState.idle); | |
Crashlytics.instance.recordError(e, s, context: 'User login failure'); | |
_showLoginError(e); | |
return null; | |
} | |
} | |
void _showLoginError(error) => scaffoldKey.currentState.showErrorSnackBar( | |
error: error is ApiMessageException && | |
error.statusCode != null && | |
error.statusCode >= 400 && | |
error.statusCode < 500 | |
? context.appLocalization.loginErrorMessage | |
: context.appLocalization.unexpectedErrorMessage, | |
); | |
void _navigateApp() { | |
Navigator.of(context).pushReplacement(MaterialPageRoute( | |
settings: RouteSettings(name: MainScreen.ANALYTICS_NAME), | |
builder: (_) => MainScreen(), | |
)); | |
} | |
_openPrivacy() { | |
analytics.logEvent(name: PRIVACY_POLICY); | |
return _launchURL('https://banyanhill.com/privacy-policy/'); | |
} | |
_openTerms() { | |
analytics.logEvent(name: TERMS_AND_CONDITIONS); | |
return _launchURL('https://banyanhill.com/disclaimer/'); | |
} | |
_launchURL(String url) async { | |
if (await canLaunch(url)) { | |
await launch(url); | |
} else { | |
logCantOpenUrl(); | |
Scaffold.of(context).showErrorSnackBar( | |
error: context.appLocalization.Cant_open_url, | |
); | |
} | |
} | |
Tuple2<double, double> _logoSpacing(BuildContext context) => | |
MediaQuery.of(context).size.height > LOWER_LOGO_INSETS_TRIGGER | |
? Tuple2(TOP_INSET, LOGO_BOTTOM_INSET) | |
: Tuple2(INSET_ON_OPEN_KEYBOARD, INSET_ON_OPEN_KEYBOARD); | |
bool _shouldShrinkSpacings(BuildContext context) => | |
MediaQuery.of(context).viewInsets.bottom < KEYBOARD_TRIGGER_HEIGHT; | |
Widget _animatedLogoSpacing(double height) => AnimatedSize( | |
vsync: this, | |
duration: Duration(milliseconds: 150), | |
child: SizedBox( | |
height: height, | |
), | |
); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment