diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c71b78..d1c12e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 7.3.0 + +* Added Biometrics support to the LoginOptions + ## 7.2.0 * Added CustomSemantics widget that is used to wrap all the inputfields and buttons to make the component accessible for e2e testing. diff --git a/README.md b/README.md index db33954..eca0c54 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,44 @@ A package facilitating the basic ingredients for creating functional yet customi To use this package, add `flutter_login` as a dependency in your pubspec.yaml file. + +### Biometrics authentication +You can use faceID or fingerprint by adding: +```dart + loginWithBiometrics: true, + triggerBiometricsAutomatically: true, +``` +to the `LoginOptions` object. This will trigger the biometrics authentication immediately when the EmailPasswordLoginForm is shown. + +For the full biometrics setup you can follow the instructions in the [local_auth](https://pub.dev/packages/local_auth) package. + +You need to add the following permissions to your AndroidManifest.xml file: + +```xml + +``` + +```java +import io.flutter.embedding.android.FlutterFragmentActivity; + +public class MainActivity extends FlutterFragmentActivity { + // ... +} +``` + +or MainActivity.kt: + +```kotlin +import io.flutter.embedding.android.FlutterFragmentActivity + +class MainActivity: FlutterFragmentActivity() { + // ... +} +``` + +to inherit from `FlutterFragmentActivity`. + + ## How to use ```dart diff --git a/assets/2.0x/ios_fingerprint.png b/assets/2.0x/ios_fingerprint.png new file mode 100644 index 0000000..24c7976 Binary files /dev/null and b/assets/2.0x/ios_fingerprint.png differ diff --git a/assets/3.0x/ios_fingerprint.png b/assets/3.0x/ios_fingerprint.png new file mode 100644 index 0000000..6fb33ba Binary files /dev/null and b/assets/3.0x/ios_fingerprint.png differ diff --git a/assets/ios_fingerprint.png b/assets/ios_fingerprint.png new file mode 100644 index 0000000..6422783 Binary files /dev/null and b/assets/ios_fingerprint.png differ diff --git a/example/.gitignore b/example/.gitignore index 954366b..d75307a 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -5,9 +5,11 @@ *.swp .DS_Store .atom/ +.build/ .buildlog/ .history .svn/ +.swiftpm/ migrate_working_dir/ # IntelliJ related @@ -32,6 +34,7 @@ migrate_working_dir/ .pub/ /build/ .metadata +pubspec.lock # Symbolication related app.*.symbols diff --git a/example/README.md b/example/README.md new file mode 120000 index 0000000..32d46ee --- /dev/null +++ b/example/README.md @@ -0,0 +1 @@ +../README.md \ No newline at end of file diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml index 61b6c4d..2443488 100644 --- a/example/analysis_options.yaml +++ b/example/analysis_options.yaml @@ -1,29 +1,12 @@ -# This file configures the analyzer, which statically analyzes Dart code to -# check for errors, warnings, and lints. -# -# The issues identified by the analyzer are surfaced in the UI of Dart-enabled -# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be -# invoked from the command line by running `flutter analyze`. +include: package:flutter_iconica_analysis/analysis_options.yaml -# The following line activates a set of recommended lints for Flutter apps, -# packages, and plugins designed to encourage good coding practices. -include: package:flutter_lints/flutter.yaml +# https://dart-lang.github.io/linter/lints/index.html + +# Possible to overwrite the rules from the package + +analyzer: + plugins: + exclude: linter: - # The lint rules applied to this project can be customized in the - # section below to disable rules from the `package:flutter_lints/flutter.yaml` - # included above or to enable additional rules. A list of all available lints - # and their documentation is published at - # https://dart-lang.github.io/linter/lints/index.html. - # - # Instead of disabling a lint rule for the entire project in the - # section below, it can also be suppressed for a single line of code - # or a specific dart file by using the `// ignore: name_of_lint` and - # `// ignore_for_file: name_of_lint` syntax on the line or in the file - # producing the lint. - rules: - # avoid_print: false # Uncomment to disable the `avoid_print` rule - # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options + rules: \ No newline at end of file diff --git a/example/lib/main.dart b/example/lib/main.dart index 7ccd378..a42f7fd 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,7 +1,7 @@ // ignore_for_file: avoid_print -import 'package:flutter/material.dart'; -import 'package:flutter_login/flutter_login.dart'; +import "package:flutter/material.dart"; +import "package:flutter_login/flutter_login.dart"; final loginOptions = LoginOptions( emailDecoration: const InputDecoration( @@ -15,21 +15,24 @@ final loginOptions = LoginOptions( image: const FlutterLogo( size: 200, ), + biometricsOptions: const LoginBiometricsOptions( + loginWithBiometrics: true, + triggerBiometricsAutomatically: false, + ), requestForgotPasswordButtonBuilder: ( context, onPressed, isDisabled, onDisabledPress, translations, - ) { - return Opacity( - opacity: isDisabled ? 0.5 : 1.0, - child: ElevatedButton( - onPressed: isDisabled ? onDisabledPress : onPressed, - child: const Text('Send request'), - ), - ); - }, + ) => + Opacity( + opacity: isDisabled ? 0.5 : 1.0, + child: ElevatedButton( + onPressed: isDisabled ? onDisabledPress : onPressed, + child: const Text("Send request"), + ), + ), ); void main() { @@ -40,54 +43,46 @@ class LoginExample extends StatelessWidget { const LoginExample({super.key}); @override - Widget build(BuildContext context) { - return MaterialApp( - theme: ThemeData.dark(), - home: const LoginScreen(), - ); - } + Widget build(BuildContext context) => MaterialApp( + theme: ThemeData.dark(), + home: const LoginScreen(), + ); } class LoginScreen extends StatelessWidget { const LoginScreen({super.key}); @override - Widget build(BuildContext context) { - return Scaffold( - body: EmailPasswordLoginForm( - title: const Text('Login Demo'), - options: loginOptions, - onLogin: (email, password) => print('$email:$password'), - onRegister: (email, password, ctx) => print('Register!'), - onForgotPassword: (email, ctx) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) { - return const ForgotPasswordScreen(); - }, - ), - ); - }, - ), - ); - } + Widget build(BuildContext context) => Scaffold( + body: EmailPasswordLoginForm( + title: const Text("Login Demo"), + options: loginOptions, + onLogin: (email, password) => print("$email:$password"), + onRegister: (email, password, ctx) => print("Register!"), + onForgotPassword: (email, ctx) async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const ForgotPasswordScreen(), + ), + ); + }, + ), + ); } class ForgotPasswordScreen extends StatelessWidget { const ForgotPasswordScreen({super.key}); @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(), - body: ForgotPasswordForm( - options: loginOptions, - title: const Text('Forgot password'), - description: const Text('Hello world'), - onRequestForgotPassword: (email) { - print('Forgot password email sent to $email'); - }, - ), - ); - } + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(), + body: ForgotPasswordForm( + options: loginOptions, + title: const Text("Forgot password"), + description: const Text("Hello world"), + onRequestForgotPassword: (email) { + print("Forgot password email sent to $email"); + }, + ), + ); } diff --git a/example/pubspec.lock b/example/pubspec.lock deleted file mode 100644 index d1ab254..0000000 --- a/example/pubspec.lock +++ /dev/null @@ -1,228 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - async: - dependency: transitive - description: - name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" - url: "https://pub.dev" - source: hosted - version: "2.11.0" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - characters: - dependency: transitive - description: - name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" - url: "https://pub.dev" - source: hosted - version: "1.3.0" - clock: - dependency: transitive - description: - name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf - url: "https://pub.dev" - source: hosted - version: "1.1.1" - collection: - dependency: transitive - description: - name: collection - sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf - url: "https://pub.dev" - source: hosted - version: "1.19.0" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" - url: "https://pub.dev" - source: hosted - version: "1.3.1" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - sha256: a25a15ebbdfc33ab1cd26c63a6ee519df92338a9c10f122adda92938253bef04 - url: "https://pub.dev" - source: hosted - version: "2.0.3" - flutter_login: - dependency: "direct main" - description: - path: ".." - relative: true - source: path - version: "7.2.0" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" - url: "https://pub.dev" - source: hosted - version: "10.0.7" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" - url: "https://pub.dev" - source: hosted - version: "3.0.8" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" - url: "https://pub.dev" - source: hosted - version: "3.0.1" - lints: - dependency: transitive - description: - name: lints - sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - matcher: - dependency: transitive - description: - name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb - url: "https://pub.dev" - source: hosted - version: "0.12.16+1" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec - url: "https://pub.dev" - source: hosted - version: "0.11.1" - meta: - dependency: transitive - description: - name: meta - sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 - url: "https://pub.dev" - source: hosted - version: "1.15.0" - path: - dependency: transitive - description: - name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" - url: "https://pub.dev" - source: hosted - version: "1.9.0" - pinput: - dependency: transitive - description: - name: pinput - sha256: "8a73be426a91fefec90a7f130763ca39772d547e92f19a827cf4aa02e323d35a" - url: "https://pub.dev" - source: hosted - version: "5.0.1" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.99" - source_span: - dependency: transitive - description: - name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" - url: "https://pub.dev" - source: hosted - version: "1.10.0" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" - url: "https://pub.dev" - source: hosted - version: "1.12.0" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 - url: "https://pub.dev" - source: hosted - version: "2.1.2" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" - url: "https://pub.dev" - source: hosted - version: "1.3.0" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 - url: "https://pub.dev" - source: hosted - version: "1.2.1" - test_api: - dependency: transitive - description: - name: test_api - sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" - url: "https://pub.dev" - source: hosted - version: "0.7.3" - universal_platform: - dependency: transitive - description: - name: universal_platform - sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b - url: "https://pub.dev" - source: hosted - version: "14.3.0" -sdks: - dart: ">=3.4.0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index b873dba..6a9254b 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,14 +1,12 @@ name: example description: A new Flutter project. -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +publish_to: "none" version: 1.0.0+1 environment: - sdk: '>=2.18.1 <3.0.0' + sdk: ">=2.18.1 <3.0.0" dependencies: flutter: @@ -16,11 +14,12 @@ dependencies: flutter_login: path: ../ - dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^2.0.0 + flutter_iconica_analysis: + hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub + version: ^7.0.0 flutter: - uses-material-design: true \ No newline at end of file + uses-material-design: true diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart new file mode 100644 index 0000000..090f3f2 --- /dev/null +++ b/example/test/widget_test.dart @@ -0,0 +1,7 @@ +import "package:flutter_test/flutter_test.dart"; + +void main() { + test("blank test", () { + expect(true, true); + }); +} diff --git a/lib/src/config/login_options.dart b/lib/src/config/login_options.dart index eddeeff..984f998 100644 --- a/lib/src/config/login_options.dart +++ b/lib/src/config/login_options.dart @@ -75,6 +75,7 @@ class LoginOptions { this.forgotPasswordCustomAppBar, this.suffixIconSize, this.suffixIconPadding, + this.biometricsOptions = const LoginBiometricsOptions(), }); /// Builds the login button. @@ -164,6 +165,41 @@ class LoginOptions { /// forgot password custom AppBar final AppBar? forgotPasswordCustomAppBar; + + /// Options for enabling and customizing biometrics login + final LoginBiometricsOptions biometricsOptions; +} + +class LoginBiometricsOptions { + const LoginBiometricsOptions({ + this.loginWithBiometrics = false, + this.triggerBiometricsAutomatically = false, + this.allowBiometricsAlternative = true, + this.onBiometricsSuccess, + this.onBiometricsError, + this.onBiometricsFail, + }); + + /// Ask the user to login with biometrics instead of email and password. + final bool loginWithBiometrics; + + /// Allow the user to login with biometrics even if they have no biometrics + /// set up on their device. This will use their device native login methods. + final bool allowBiometricsAlternative; + + /// Automatically open the native biometrics UI instead of waiting for the + /// user to press the biometrics button + final bool triggerBiometricsAutomatically; + + /// The callback function to be called when the biometrics login is + /// successful. + final OptionalAsyncCallback? onBiometricsSuccess; + + /// The callback function to be called when the biometrics login fails. + final OptionalAsyncCallback? onBiometricsFail; + + /// The callback function to be called when the biometrics login errors. + final OptionalAsyncCallback? onBiometricsError; } /// Translations for all the texts in the component @@ -177,6 +213,7 @@ class LoginTranslations { this.forgotPasswordButton = "Forgot password?", this.requestForgotPasswordButton = "Send link", this.registrationButton = "Create account", + this.biometricsLoginMessage = "Log in with biometrics", }); final String emailInvalid; @@ -186,6 +223,7 @@ class LoginTranslations { final String forgotPasswordButton; final String requestForgotPasswordButton; final String registrationButton; + final String biometricsLoginMessage; } /// Accessibility identifiers for the standard widgets in the component. diff --git a/lib/src/service/local_auth_service.dart b/lib/src/service/local_auth_service.dart new file mode 100644 index 0000000..0363918 --- /dev/null +++ b/lib/src/service/local_auth_service.dart @@ -0,0 +1,35 @@ +import "package:flutter/services.dart"; +import "package:flutter_login/src/config/login_options.dart"; +import "package:local_auth/local_auth.dart"; + +class LocalAuthService { + final LocalAuthentication _localAuth = LocalAuthentication(); + + Future authenticate(LoginOptions loginOptions) async { + var biometricsOptions = loginOptions.biometricsOptions; + + try { + if (!await _localAuth.isDeviceSupported()) { + biometricsOptions.onBiometricsError?.call(); + return; + } + var didAuthenticate = await _localAuth.authenticate( + localizedReason: loginOptions.translations.biometricsLoginMessage, + options: AuthenticationOptions( + biometricOnly: !biometricsOptions.allowBiometricsAlternative, + stickyAuth: true, + sensitiveTransaction: false, + ), + ); + if (didAuthenticate) { + biometricsOptions.onBiometricsSuccess?.call(); + } + + if (!didAuthenticate) { + biometricsOptions.onBiometricsFail?.call(); + } + } on PlatformException catch (_) { + biometricsOptions.onBiometricsError?.call(); + } + } +} diff --git a/lib/src/widgets/biometrics_button.dart b/lib/src/widgets/biometrics_button.dart new file mode 100644 index 0000000..897b2d5 --- /dev/null +++ b/lib/src/widgets/biometrics_button.dart @@ -0,0 +1,45 @@ +import "dart:async"; +import "dart:io"; + +import "package:flutter/foundation.dart"; +import "package:flutter/material.dart"; + +class BiometricsButton extends StatelessWidget { + const BiometricsButton({ + required this.onPressed, + super.key, + }); + + static const Size buttonSize = Size(40, 40); + + final FutureOr Function() onPressed; + + @override + Widget build(BuildContext context) { + // handle unsupported platforms + if (kIsWeb || Platform.isLinux) return SizedBox(width: buttonSize.width); + Widget biometricsWidget; + + if (Platform.isIOS || Platform.isMacOS) { + biometricsWidget = Image( + image: const AssetImage( + "assets/ios_fingerprint.png", + package: "flutter_login", + ), + width: buttonSize.width, + height: buttonSize.height, + ); + } else { + biometricsWidget = Icon( + Icons.fingerprint, + size: buttonSize.width, + ); + } + + return InkWell( + borderRadius: BorderRadius.circular(20), + onTap: onPressed, + child: biometricsWidget, + ); + } +} diff --git a/lib/src/widgets/email_password_login.dart b/lib/src/widgets/email_password_login.dart index a2cfab5..c1a5421 100644 --- a/lib/src/widgets/email_password_login.dart +++ b/lib/src/widgets/email_password_login.dart @@ -2,6 +2,8 @@ import "dart:async"; import "package:flutter/material.dart"; import "package:flutter_login/flutter_login.dart"; +import "package:flutter_login/src/service/local_auth_service.dart"; +import "package:flutter_login/src/widgets/biometrics_button.dart"; import "package:flutter_login/src/widgets/custom_semantics.dart"; class EmailPasswordLoginForm extends StatefulWidget { @@ -49,6 +51,8 @@ class _EmailPasswordLoginFormState extends State { String _currentEmail = ""; String _currentPassword = ""; + final LocalAuthService _localAuthService = LocalAuthService(); + void _updateCurrentEmail(String email) { _currentEmail = email; _validate(); @@ -87,6 +91,13 @@ class _EmailPasswordLoginFormState extends State { _currentEmail = widget.options.initialEmail; _currentPassword = widget.options.initialPassword; _validate(); + + if (widget.options.biometricsOptions.loginWithBiometrics && + widget.options.biometricsOptions.triggerBiometricsAutomatically) { + WidgetsBinding.instance.addPostFrameCallback((_) async { + await _localAuthService.authenticate(widget.options); + }); + } } @override @@ -195,6 +206,10 @@ class _EmailPasswordLoginFormState extends State { ), ); + var biometricsButton = BiometricsButton( + onPressed: () async => LocalAuthService().authenticate(options), + ); + return Scaffold( backgroundColor: options.loginBackgroundColor, body: CustomScrollView( @@ -235,7 +250,22 @@ class _EmailPasswordLoginFormState extends State { if (options.spacers.spacerAfterForm != null) ...[ Spacer(flex: options.spacers.spacerAfterForm!), ], - loginButton, + if (options + .biometricsOptions.loginWithBiometrics) ...[ + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + SizedBox( + width: BiometricsButton.buttonSize.width, + ), + loginButton, + biometricsButton, + ], + ), + ] else ...[ + loginButton, + ], if (widget.onRegister != null) ...[ registrationButton, ], diff --git a/pubspec.yaml b/pubspec.yaml index 4465171..50d9308 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_login description: Flutter Login Component -version: 7.2.0 +version: 7.3.0 publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub @@ -11,12 +11,18 @@ environment: dependencies: flutter: sdk: flutter + local_auth: ^2.3.0 pinput: ^5.0.1 dev_dependencies: flutter_test: sdk: flutter flutter_iconica_analysis: - git: - url: https://github.com/Iconica-Development/flutter_iconica_analysis - ref: 7.0.0 + hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub + version: ^7.0.0 + +flutter: + assets: + - assets/ + - assets/2.0x/ + - assets/3.0x/ diff --git a/test/flutter_login_widget_test.dart b/test/flutter_login_widget_test.dart new file mode 100644 index 0000000..090f3f2 --- /dev/null +++ b/test/flutter_login_widget_test.dart @@ -0,0 +1,7 @@ +import "package:flutter_test/flutter_test.dart"; + +void main() { + test("blank test", () { + expect(true, true); + }); +}