iT邦幫忙

第 11 屆 iThome 鐵人賽

DAY 14
1
Mobile Development

Flutter---Google推出的跨平台框架,Android、iOS一起搞定系列 第 14

【Flutter基礎概念與實作】 Day14–實作Login Bloc、Firebase Authentication

繼續來實作專案,經過昨天實作完AuthenticationBloc後,對於Bloc的概念跟實作流程應該都比較熟悉了。今天的LoginBLoc就不會講解的那麼詳細,不過有任何問題還是歡迎發問喔。

LoginBloc

在login資料夾中新增一個「bloc」資料夾,同樣使用bloc generator產生loginBloc模板。

fluttube
└───lib
│   └───authentication_bloc
│   └───firebase
│   │   └───user_repository.dart
│   └───login
│   │   └───bloc
│   │   │   └───bloc.dart
│   │   │   └───login_bloc.dart
│   │   │   └───login_event.dart
│   │   │   └───login_state.dart
│   │   └───login_page.dart
│   └───main.dart
│     ...
│

Login State

一樣先從設計State開始,登入的State有以下幾種:

  • Success: 登入成功
  • Failure: 登入失敗
  • loading: 登入驗證中
  • empty: 初始階段

由於登入需要檢查使用者輸入的Email和Password是否符合規定,不能像authentication_bloc一樣建立簡單的State class就好,在這的State也要記錄其他資訊。

login_state.dart的程式碼:

import 'package:meta/meta.dart';

@immutable
class LoginState {
  final bool isEmailValid;
  final bool isPasswordValid;
  final bool isSubmitting;
  final bool isSuccess;
  final bool isFailure;

  bool get isFormValid => isEmailValid && isPasswordValid;

  LoginState({
    @required this.isEmailValid,
    @required this.isPasswordValid,
    @required this.isSubmitting,
    @required this.isSuccess,
    @required this.isFailure,
  });

  factory LoginState.empty() {
    return LoginState(
      isEmailValid: true,
      isPasswordValid: true,
      isSubmitting: false,
      isSuccess: false,
      isFailure: false,
    );
  }

  factory LoginState.loading() {
    return LoginState(
      isEmailValid: true,
      isPasswordValid: true,
      isSubmitting: true,
      isSuccess: false,
      isFailure: false,
    );
  }

  factory LoginState.failure() {
    return LoginState(
      isEmailValid: true,
      isPasswordValid: true,
      isSubmitting: false,
      isSuccess: false,
      isFailure: true,
    );
  }

  factory LoginState.success() {
    return LoginState(
      isEmailValid: true,
      isPasswordValid: true,
      isSubmitting: false,
      isSuccess: true,
      isFailure: false,
    );
  }

  LoginState update({
    bool isEmailValid,
    bool isPasswordValid,
  }) {
    return copyWith(
      isEmailValid: isEmailValid,
      isPasswordValid: isPasswordValid,
      isSubmitting: false,
      isSuccess: false,
      isFailure: false,
    );
  }

  LoginState copyWith({
    bool isEmailValid,
    bool isPasswordValid,
    bool isSubmitting,
    bool isSuccess,
    bool isFailure,
  }) {
    return LoginState(
      isEmailValid: isEmailValid ?? this.isEmailValid,
      isPasswordValid: isPasswordValid ?? this.isPasswordValid,
      isSubmitting: isSubmitting ?? this.isSubmitting,
      isSuccess: isSuccess ?? this.isSuccess,
      isFailure: isFailure ?? this.isFailure,
    );
  }

  @override
  String toString() {
    return '''LoginState {
      isEmailValid: $isEmailValid,
      isPasswordValid: $isPasswordValid,
      isSubmitting: $isSubmitting,
      isSuccess: $isSuccess,
      isFailure: $isFailure,
    }''';
  }
}

開頭引入meta.dart是為了使用@required註解(annotation)。
在這使用了factory constructor,是為了取得同一個物件實體(instance)。

Use the factory keyword when implementing a constructor that doesn’t always create a new instance of its class. For example, a factory constructor might return an instance from a cache, or it might return an instance of a subtype.

並且在每個State紀錄對應的資訊。

Login Event

event比較簡單一點,觸發事件會有以下幾種:

  • EmailChanged:偵測到信箱輸入有變化
  • PasswordChanged:偵測到密碼輸入有變化
  • LoginWithGooglePressed:使用Google帳號登入
  • LoginWithCredentialsPressed:使用信箱密碼登入
import 'package:meta/meta.dart';
import 'package:equatable/equatable.dart';

@immutable
abstract class LoginEvent extends Equatable {
  LoginEvent([List props = const []]) : super(props);
}

class EmailChanged extends LoginEvent {
  final String email;

  EmailChanged({@required this.email}) : super([email]);

  @override
  String toString() => 'EmailChanged { email :$email }';
}

class PasswordChanged extends LoginEvent {
  final String password;

  PasswordChanged({@required this.password}) : super([password]);

  @override
  String toString() => 'PasswordChanged { password: $password }';
}

class LoginWithGooglePressed extends LoginEvent {
  @override
  String toString() => 'LoginWithGooglePressed';
}

class LoginWithCredentialsPressed extends LoginEvent {
  final String email;
  final String password;

  LoginWithCredentialsPressed({@required this.email, @required this.password})
      : super([email, password]);

  @override
  String toString() {
    return 'LoginWithCredentialsPressed { email: $email, password: $password }';
  }
}

Login Bloc

登入和驗證相同都需要使用到UserRepository內的函式,所以在建構子的地方要代入UserRepository的實體。其他一樣是要修改initialState以及設計mapEventToState。

import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:meta/meta.dart';
import 'package:rxdart/rxdart.dart';
import 'bloc.dart';
import '../../firebase/user_repository.dart';
import '../../validators.dart';

class LoginBloc extends Bloc<LoginEvent, LoginState> {
  UserRepository _userRepository;

  LoginBloc({
    @required UserRepository userRepository,
  })  : assert(userRepository != null),
        _userRepository = userRepository;

  @override
  LoginState get initialState => LoginState.empty();

  @override
  Stream<LoginState> transformEvents(
      Stream<LoginEvent> events,
      Stream<LoginState> Function(LoginEvent event) next,
      ) {
    final observableStream = events as Observable<LoginEvent>;
    final nonDebounceStream = observableStream.where((event) {
      return (event is! EmailChanged && event is! PasswordChanged);
    });
    final debounceStream = observableStream.where((event) {
      return (event is EmailChanged || event is PasswordChanged);
    }).debounceTime(Duration(milliseconds: 300));
    return super.transformEvents(nonDebounceStream.mergeWith([debounceStream]), next);
  }

  @override
  Stream<LoginState> mapEventToState(LoginEvent event) async* {
    if (event is EmailChanged) {
      yield* _mapEmailChangedToState(event.email);
    } else if (event is PasswordChanged) {
      yield* _mapPasswordChangedToState(event.password);
    } else if (event is LoginWithGooglePressed) {
      yield* _mapLoginWithGooglePressedToState();
    } else if (event is LoginWithCredentialsPressed) {
      yield* _mapLoginWithCredentialsPressedToState(
        email: event.email,
        password: event.password,
      );
    }
  }

  Stream<LoginState> _mapEmailChangedToState(String email) async* {
    yield currentState.update(
      isEmailValid: Validators.isValidEmail(email),
    );
  }

  Stream<LoginState> _mapPasswordChangedToState(String password) async* {
    yield currentState.update(
      isPasswordValid: Validators.isValidPassword(password),
    );
  }

  Stream<LoginState> _mapLoginWithGooglePressedToState() async* {
    try {
      await _userRepository.signInWithGoogle();
      yield LoginState.success();
    } catch (_) {
      yield LoginState.failure();
    }
  }

  Stream<LoginState> _mapLoginWithCredentialsPressedToState({
    String email,
    String password,
  }) async* {
    yield LoginState.loading();
    try {
      await _userRepository.signInWithCredentials(email, password);
      yield LoginState.success();
    } catch (_) {
      yield LoginState.failure();
    }
  }
}

我想其中比較陌生的是transformEvents這一個method。
transformEvents是當你想對Stream內的Events在進入到mapEventToState前做處理,就可以override它。

Observable是rxdart套件提供的class,把它當作成Stream即可,它內建許多操作Stream內數值的方法。
把原本的events轉型成Observable就是為了使用observableStream.where,where是用來filter Stream內的值,只有滿足條件的值才會被篩選。
根據條件式可以知道:
nonDebounceStream裡可能會是LoginWithGooglePressed和LoginWithCredentialsPressed Events
debounceStream則會是EmailChanged和PasswordChanged Events

debounceTime的用途是它會在設定的時間內暫存值,如果時間過了就將它發出;但如果在時間內接收到了同樣的值,時間會重新計算並把前一個丟棄。
通常在驗證使用者輸入的時候就會使用到debounceTime來緩衝,畢竟沒必要使用者每打一個字元就去檢查一次輸入。
例如設定0.3秒的意思就是當使用者打字停止了0.3秒後才會去觸發Email或Password改變的Event。

Validators

看到login_bloc顯示錯誤應該就知道還少了validators來幫忙驗證信箱和密碼的格式是否正確吧。
在lib目錄新增validators.dart,貼上以下程式碼。

class Validators {
  static final RegExp _emailRegExp = RegExp(
    r'^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$',
  );
  static final RegExp _passwordRegExp = RegExp(
    r'^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$',
  );

  static isValidEmail(String email) {
    return _emailRegExp.hasMatch(email);
  }

  static isValidPassword(String password) {
    return _passwordRegExp.hasMatch(password);
  }
}

LoginPage

修改原本的login_page,使用BlocProvider讓它之下的子widget也能使用到bloc實體。

import 'package:flutter/material.dart';
import '../firebase/user_repository.dart';
import 'bloc/bloc.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

class LoginPage extends StatelessWidget {
  final UserRepository _userRepository;

  LoginPage({Key key, @required UserRepository userRepository})
      : assert(userRepository != null),
        _userRepository = userRepository,
        super(key: key);

  Widget build(BuildContext context) {
    return BlocProvider(
      builder: (BuildContext context) => LoginBloc(userRepository: _userRepository),
      child: LoginForm(userRepository: _userRepository),
    );
  }
}

LoginForm

在login資料夾新增login_form.dart,LoginForm widget會包含信箱密碼的輸入欄位以及登入和註冊的按鈕。

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'login.dart';
import '../firebase/user_repository.dart';
import 'package:fluttube/authentication_bloc/bloc.dart';
import 'package:flutter_auth_buttons/flutter_auth_buttons.dart';

class LoginForm extends StatefulWidget {
  final UserRepository _userRepository;
  LoginForm({Key key, @required UserRepository userRepository})
      : assert(userRepository != null),
        _userRepository = userRepository,
        super(key: key);

  @override
  _LoginFormState createState() => _LoginFormState();
}

class _LoginFormState extends State<LoginForm> {
  final TextEditingController _emailController = TextEditingController();
  final TextEditingController _passwordController = TextEditingController();
  LoginBloc _loginBloc;

  UserRepository get _userRepository => widget._userRepository;
  bool get isPopulated =>
      _emailController.text.isNotEmpty && _passwordController.text.isNotEmpty;
  bool isLoginButtonEnabled(LoginState state) {
    return state.isFormValid && isPopulated && !state.isSubmitting;
  }

  @override
  void initState() {
    _loginBloc = BlocProvider.of<LoginBloc>(context);
    _emailController.addListener(_onEmailChanged);
    _passwordController.addListener(_onPasswordChanged);
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return BlocListener<LoginBloc, LoginState>(
      listener: (BuildContext context, LoginState state) {
        if (state.isFailure) {
          Scaffold.of(context)
            ..hideCurrentSnackBar()
            ..showSnackBar(SnackBar(
              content: Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: <Widget>[Text('Login Failure'), Icon(Icons.error)],
              ),
              backgroundColor: Colors.red,
            ));
        }
        if (state.isSubmitting) {
          Scaffold.of(context)
            ..hideCurrentSnackBar()
            ..showSnackBar(SnackBar(
                content: Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: <Widget>[
                Text('Logging In...'),
                CircularProgressIndicator(),
              ],
            )));
        }
        if (state.isSuccess) {
          BlocProvider.of<AuthenticationBloc>(context).dispatch(LoggedIn());
        }
      },
      child: BlocBuilder<LoginBloc, LoginState>(
          builder: (BuildContext context, LoginState state) {
        return Padding(
            padding: EdgeInsets.all(20.0),
            child: Form(
              child: ListView(
                children: <Widget>[
                  Padding(
                    padding: EdgeInsets.symmetric(vertical: 20.0),
                    child: Image.asset(
                      'assets/logo.png',
                      height: 200,
                    ),
                  ),
                  TextFormField(
                    controller: _emailController,
                    decoration: InputDecoration(
                        icon: Icon(Icons.email), labelText: 'Email'),
                    autovalidate: true,
                    autocorrect: false,
                    validator: (_) {
                      return !state.isEmailValid ? 'Invalid Email' : null;
                    },
                  ),
                  TextFormField(
                    controller: _passwordController,
                    decoration: InputDecoration(
                        icon: Icon(Icons.lock), labelText: 'Password'),
                    obscureText: true,
                    autovalidate: true,
                    autocorrect: false,
                    validator: (_) {
                      return !state.isPasswordValid ? 'Invalid Password' : null;
                    },
                  ),
                  Padding(
                    padding: EdgeInsets.symmetric(vertical: 20.0),
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.stretch,
                      children: <Widget>[
                        LoginButton(
                            onPressed: isLoginButtonEnabled(state)
                                ? _onFormSubmitted
                                : null),

                        GoogleSignInButton(
                          onPressed: () =>
                              _loginBloc.dispatch(LoginWithGooglePressed()),
                        ),
                        CreateAccountButton(userRepository: _userRepository)
                      ],
                    ),
                  )
                ],
              ),
            ));
      }),
    );
  }

  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }

  void _onEmailChanged() {
    _loginBloc.dispatch(EmailChanged(email: _emailController.text));
  }

  void _onPasswordChanged() {
    _loginBloc.dispatch(PasswordChanged(password: _passwordController.text));
  }

  void _onFormSubmitted() {
    _loginBloc.dispatch(LoginWithCredentialsPressed(
        email: _emailController.text, password: _passwordController.text));
  }
}

看到程式碼可能覺得很嚇人,不過其實裡面的內容非常簡單。

在這使用_emailController_passwordController去監聽使用者輸入的情形,當監聽到有變化就觸發Email/PasswordChanged的Event。

BlocListener除了用來監聽State顯示對應SnackBar之外,當監聽到state.isSuccess就會觸發AuthenticationBloc的LoggedIn 並跳轉到主頁面。

要注意的是dispose(),之前有說過使用BlocProvider會自動幫我們關閉Bloc,但是這裡用到的Controller就要由我們自己關閉囉。

接著來把顯示紅字的其他元件補上吧。

LoginButton

新增login_button.dart
貼上以下程式碼:

import 'package:flutter/material.dart';

class LoginButton extends StatelessWidget {
  final VoidCallback _onPressed;

  LoginButton({Key key, VoidCallback onPressed})
      : _onPressed = onPressed,
        super(key: key);

  @override
  Widget build(BuildContext context) {
    return RaisedButton(
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(30.0),
      ),
      onPressed: _onPressed,
      child: Text('Login'),
    );
  }
}

CreateAccountButton

新增create_account_button.dart
貼上以下程式碼:

import 'package:flutter/material.dart';
import '../firebase/user_repository.dart';
import '../register/register_page.dart';

class CreateAccountButton extends StatelessWidget {
  final UserRepository _userRepository;

  CreateAccountButton({Key key, @required UserRepository userRepository})
      : assert(userRepository != null),
        _userRepository = userRepository,
        super(key: key);

  @override
  Widget build(BuildContext context) {
    return FlatButton(
      child: Text(
          'Create a Account'
      ),
      onPressed: (){
        Navigator.of(context).push(
            MaterialPageRoute(builder: (context){
              return RegisterPage(userRepository: _userRepository);
            })
        );
      },
    );
  }
}

引入整個login資料夾

新增login.dart
貼上以下程式碼:

export './create_account_button.dart';
export './bloc/bloc.dart';
export './login_form.dart';
export './login_button.dart';
export './login_page.dart';

RegisterPage

在lib目錄下新增「register」資料夾,並新增register_page.dart。

import 'package:flutter/material.dart';
import '../firebase/user_repository.dart';

class RegisterPage extends StatelessWidget {
  final UserRepository _userRepository;
  RegisterPage({Key key, @required UserRepository userRepository})
      : assert(userRepository != null),
        _userRepository = userRepository,
        super(key: key);
  @override
  Widget build(BuildContext context) {
    return Scaffold(body: Center(child: Text('Register Page')));
  }
}

今天的目錄架構

fluttube
└───lib
│   └───login
│   │   └───bloc
│   │   │   └───bloc.dart
│   │   │   └───login_bloc.dart
│   │   │   └───login_event.dart
│   │   │   └───login_state.dart
│   │   └───create_account_button.dart
│   │   └───login.dart
│   │   └───login_button.dart
│   │   └───login_form.dart
│   │   └───login_page.dart
│   └───register
│   │   └───register_page.dart
│   ...
│   └─── validators.dart

今日總結

今天完成了LoginBloc的實作,和昨天的相比程式碼稍微複雜了些,但我想只要反覆多看幾次就能理解每行程式碼的意思,有任何疑問也可以留言~
另外也終於把只有「Login page」字樣的偽登入頁面變成真正能使用的登入頁,不過你會發現當按下「Sign in with Google」的按鈕程式竟然會Crash,這是因為要使用Google帳號驗證我們還缺了兩個部分沒有做到,這部分就留到明天再處理吧,大家明天見。

完整程式碼在這裡-> FlutTube Github


上一篇
【Flutter基礎概念與實作】 Day13–實作Authentication Bloc
下一篇
【Flutter基礎概念與實作】 Day15–實作Register Bloc、Firebase Authentication
系列文
Flutter---Google推出的跨平台框架,Android、iOS一起搞定30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言