Skip to content

Instantly share code, notes, and snippets.

@zamahaka
Created June 15, 2021 16:58
Show Gist options
  • Save zamahaka/2c8b57724f752ef3cbe4888b2dff2523 to your computer and use it in GitHub Desktop.
Save zamahaka/2c8b57724f752ef3cbe4888b2dff2523 to your computer and use it in GitHub Desktop.
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