在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
│ ...
│
一樣先從設計State開始,登入的State有以下幾種:
由於登入需要檢查使用者輸入的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紀錄對應的資訊。
event比較簡單一點,觸發事件會有以下幾種:
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 }';
}
}
登入和驗證相同都需要使用到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 EventsdebounceStream
則會是EmailChanged和PasswordChanged Events
debounceTime
的用途是它會在設定的時間內暫存值,如果時間過了就將它發出;但如果在時間內接收到了同樣的值,時間會重新計算並把前一個丟棄。
通常在驗證使用者輸入的時候就會使用到debounceTime
來緩衝,畢竟沒必要使用者每打一個字元就去檢查一次輸入。
例如設定0.3秒的意思就是當使用者打字停止了0.3秒後才會去觸發Email或Password改變的Event。
看到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);
}
}
修改原本的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),
);
}
}
在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就要由我們自己關閉囉。
接著來把顯示紅字的其他元件補上吧。
新增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'),
);
}
}
新增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.dart
貼上以下程式碼:
export './create_account_button.dart';
export './bloc/bloc.dart';
export './login_form.dart';
export './login_button.dart';
export './login_page.dart';
在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