Testing on Flutter

|

Testing

Flutter에서 testing은 3가지로 나눌 수 있다.

  1. Unit Test 하나의 function, method, class를 test. 정해진 condition에서 logic이 제대로 돌아가는 확인하는 것이 목표.
  2. Widget Test 하나의 widget을 test. Widget이 원하는 대로 작동하는지 UI도 원하는 대로 나타나는지 확인하는 것이 목표.
  3. Integration Test 전체 앱이나 앱의 큰 부분을 test. 전체 widget과 service가 합쳐져서 잘 돌아가는지 확인하는 것이 목표. 또한 전체 앱의 퍼포먼스도 확인해 볼 수 있다.

Unit이 모여 Widget이 되고 Widget이 모여 Integration이 된다.

Unit testing example

Counter

counter.dart파일을 lib 폴더에, counter_test.dart파일을 test폴더에 넣고 counter_test.dart를 run시킴.

# add test dependency
dev_dependencies:
  test:
// counter.dart
class Counter {
  int value = 0;

  void increment() => value++;

  void decrement() => value--;
}
// counter_test.dart
import 'package:test/test.dart';
import 'package:your_project_name/counter.dart';

void main() {
  group('Counter', () {
    // expect에서 앞에 해당하는 것과 뒤에 해당하는 것이 일치할 경우 success, 아니면 fail
    test('value should start at 0', () {
      expect(Counter().value, 0);
    });

    test('value should be incremented', () {
      final counter = Counter();

      counter.increment();

      expect(counter.value, 1);
    });

    test('value should be decremented', () {
      final counter = Counter();

      counter.decrement();

      expect(counter.value, -1);
    });
  });
}

expect가 제대로 안되었을 경우 Expected값과 Actual값을 보여준다.

Mock

unit test의 경우 web service나 DB에서 데이터 가져올 경우 test가 느려지기도 하고 데이터 가져오는게 실패할 수도 있는 등 여러 변수가 있는데 이를 mock를 사용해서 해결. Mock는 service나 DB를 모방해서 상황에 따라 특정 결과 값을 반환하도록 함. Mock은 훌륭한 대체재!

(근데 이 예제 안돌아감…)

# add package dependencies
dependencies:
  http:
dev_dependencies:
  flutter_test:
    sdk: flutter
  mockito:
// main
class Post {
  dynamic data;
  Post.fromJson(this.data);
}

Future<Post> fetchPost(http.Client client) async {
  final response =
      await client.get('https://jsonplaceholder.typicode.com/posts/1');

  if (response.statusCode == 200) {
    // If the call to the server was successful, parse the JSON.
    return Post.fromJson(json.decode(response.body));
  } else {
    // If that call was not successful, throw an error.
    throw Exception('Failed to load post');
  }
}

// test
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:http/http.dart' as http;

// Create a MockClient using the Mock class provided by the Mockito package.
// Create new instances of this class in each test.
class MockClient extends Mock implements http.Client {}

main() {
  group('fetchPost', () {
    test('returns a Post if the http call completes successfully', () async {
      final client = MockClient();

      // Use Mockito to return a successful response when it calls the
      // provided http.Client.
      when(client.get('https://jsonplaceholder.typicode.com/posts/1'))
          .thenAnswer((_) async => http.Response('{"title": "Test"}', 200));

      expect(await fetchPost(client), isA<Post>());
    });

    test('throws an exception if the http call completes with an error', () {
      final client = MockClient();

      // Use Mockito to return an unsuccessful response when it calls the
      // provided http.Client.
      when(client.get('https://jsonplaceholder.typicode.com/posts/1'))
          .thenAnswer((_) async => http.Response('Not Found', 404));

      expect(fetchPost(client), throwsException);
    });
  });
}

위 예제를 cookbook에서는 dart test/fetch_post_test.dart를 command에서 돌리라고 하는데 이러면 안돌아간다… ㅠ 근데 run 시키면 제대로 돌아가는 것은 확인

Widget testing example

# add package dependency
dev_dependencies:
  flutter_test:
    sdk: flutter
// widget test
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  // Define a test. The TestWidgets function also provides a WidgetTester
  // to work with. The WidgetTester allows building and interacting
  // with widgets in the test environment.
  testWidgets('MyWidget has a title and message', (WidgetTester tester) async {
    // Create the widget by telling the tester to build it.
    // widget을 pumb시켜 가볍게 하나 만든다는 느낌.
    await tester.pumpWidget(MyWidget(title: 'T', message: 'M'));

    // Create the Finders.
    final titleFinder = find.text('T');
    final messageFinder = find.text('M');

    // Use the `findsOneWidget` matcher provided by flutter_test to
    // verify that the Text widgets appear exactly once in the widget tree.
    expect(titleFinder, findsOneWidget);
    expect(messageFinder, findsOneWidget);

    /*
    findsOneWidget: Verifies that one widget is found.
    findsNothing: Verifies that no widgets are found.
    findsWidgets: Verifies that one or more widgets are found.
    findsNWidgets: Verifies that a specific number of widgets are found.
    */
    
  	// 다음과 같이하면 titleFinder에 해당하는 위젯이 1개 있는데 nothing이라고 했으니 맞지 않으므로 fail
    expect(titleFinder, findsNothing);
  });
}
class MyWidget extends StatelessWidget {
  final String title;
  final String message;

  const MyWidget({
    Key key,
    @required this.title,
    @required this.message,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      home: Scaffold(
        appBar: AppBar(
          title: Text(title),
        ),
        body: Center(
          child: Text(message),
        ),
      ),
    );
  }
}
// another test
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('finds a Text widget', (WidgetTester tester) async {
    // Build an App with a Text widget that displays the letter 'H'.
    await tester.pumpWidget(MaterialApp(
      home: Scaffold(
        body: Text('H'),
      ),
    ));

    // Find a widget that displays the letter 'H'.
    expect(find.text('H'), findsOneWidget);
  });

  testWidgets('finds a widget using a Key', (WidgetTester tester) async {
    // Define the test key.
    final testKey = Key('K');

    // Build a MaterialApp with the testKey.
    await tester.pumpWidget(MaterialApp(key: testKey, home: Container()));

    // Find the MaterialApp widget using the testKey.
    expect(find.byKey(testKey), findsOneWidget);
  });

  testWidgets('finds a specific instance', (WidgetTester tester) async {
    final childWidget = Padding(padding: EdgeInsets.zero);

    // Provide the childWidget to the Container.
    await tester.pumpWidget(Container(child: childWidget));

    // Search for the childWidget in the tree and verify it exists.
    expect(find.byWidget(childWidget), findsOneWidget);
  });
}

Integration testing example

Counter

# Add the flutter_driver dependency
dev_dependencies:
  flutter_driver:
    sdk: flutter
  test: any

다음과 같이 파일을 구성

counter_app/

lib/

​ main.dart

test_driver/

​ app.dart

​ app_test.dart

main.dart

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Counter App',
      home: MyHomePage(title: 'Counter App Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

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

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              // Provide a Key to this specific Text widget. This allows
              // identifing the widget from inside the test suite,
              // and reading the text.
              key: Key('counter'),
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        // Provide a Key to this button. This allows finding this
        // specific button inside the test suite, and tapping it.
        key: Key('increment'),
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ),
    );
  }
}

app.dart

import 'package:flutter_driver/driver_extension.dart';
import 'package:counter_app/main.dart' as app;

void main() {
  // This line enables the extension.
  enableFlutterDriverExtension();

  // Call the `main()` function of the app, or call `runApp` with
  // any widget you are interested in testing.
  app.main();
}

app_test.dart

// Imports the Flutter Driver API.
import 'package:flutter_driver/flutter_driver.dart';
import 'package:test/test.dart';

void main() {
  group('Counter App', () {
    // First, define the Finders and use them to locate widgets from the
    // test suite. Note: the Strings provided to the `byValueKey` method must
    // be the same as the Strings we used for the Keys in step 1.
    final counterTextFinder = find.byValueKey('counter');
    final buttonFinder = find.byValueKey('increment');

    FlutterDriver driver;

    // Connect to the Flutter driver before running any tests.
    setUpAll(() async {
      driver = await FlutterDriver.connect();
    });

    // Close the connection to the driver after the tests have completed.
    tearDownAll(() async {
      if (driver != null) {
        driver.close();
      }
    });

    test('starts at 0', () async {
      // Use the `driver.getText` method to verify the counter starts at 0.
      expect(await driver.getText(counterTextFinder), "0");
    });

    test('increments the counter', () async {
      // First, tap the button.
      await driver.tap(buttonFinder);

      // Then, verify the counter text is incremented by 1.
      expect(await driver.getText(counterTextFinder), "1");
    });
  });
}

Flutter로 Web page 만들기

|

Make Webpage Using Flutter

flutter에는 android, ios 뿐만 아니라 web에도 동시에 적용이 가능하다. 사실상 1개 만들면 3개가 동시에 만들어 지는 셈이다. 그래도 모바일 앱과 웹은 다를 수 밖에 없는데 모바일 앱은 pubspec.yaml에서 여러 플러그인을 설정하고 가져오는 반면, 웹은 index.html에서 설정하고 들어가는게 차이점이다.(firebase 연동 밖에 안해봐서 index.html말고 다른 곳에서 설정을 추가로 할수도 있다)

그리고 가장 중요한 부분은 실제 webpage처럼 구현이 되는 것이 아니라 블루스택을 통해 모바일 앱을 플레이 하는 것처럼 웹에서 어플을 사용할 수 있도록 구현이 되는 것이다. 그렇기에 android studio에서 flutter project를 다룰 때 있는 flutter inspector도 적용이 가능하다.

즉, flutter를 통해 웹을 구현하는 것은 webpage에 구현한 모바일 앱을 띄우는 것 뿐이다.

Web

webimage

Mobile App

image

T H E E N D

Async Coding With Dart

|

Async Coding With Dart

이 글은 flutter 공식 youtube에서 5개의 영상으로 이루어진 Async Coding With Dart를 보고 정리한 글이다.

Isolates and Event loops

Isolate는 multithread처럼 각각 돌아가게 하는데 memory공유는 안하고 각각이 memory를 가지고 있음

Dart의 event는 항상 Isolate에서 일어나고 처리

같이 작동시키려면 서로 메시지 보내면서 해야하는 단점이 있지만 장점이 있음. lock을 안해도 됨!

Event 중 어떤 일이 먼저 일어날지 모름

Event loop를 queue로 해서 event가 발생하면 하나씩 하나씩 처리함.

onPressed나 Network에서 뭐를 future로 가져오는 것도 모두 하나의 event일 뿐.

영상: Isolates and Event loops

Future

uncompleted(상자만 제공 아직 안열음)

completed with data(상자 열엇는데 내용이 data)

completed with error(상자 열엇는데 내용이 error)

RaisedButton(
	onPressed: () {
    final myFuture = http.get('https://my.image.url');
    myFuture.then((resp) {
      setImage(resp);
    });
  },
  child: Text

)
  
//Future를 직접 만들일은 거의 없을것
  
void main() {
  Future.delayed(Duration(seconds: 2), () {
    print('Can i go to dorm?');
  });

  final myFuture = Future(() {
    print('Creating the future');
    return 12;
  });

  print('Sleepy');
}

/*
결과창
Sleepy
Creating the future
Can i go to dorm?
*/

// error가 생기면 catchError로~
void main(){
  Future<int>.delayed(Duration(seconds: 3), () {
    throw 'Error!';
  }).then((value) {
    print(value);
  }).catchError((err) {
    print('Caught $err');
    // 이걸 쓰면 err를 더 자세히 알 수 있음!
    test: (err) => err.runtimeType == String,
  });

  print('waiting...');
  
}

영상: Dart Future

Dart Stream

  Single value Zero or more values
Sync: int Iterator
Async: Future Stream
final myStream = NumberCreator().stream;

// listen을 통해 stream을 받아볼 수 있음
final subscription = myStream.listen(
	(data) => print('Data: $data'),
  onError: (err){
    // error발생
  },
  // default: true, 이거로도 error 잡을수 있다
  canceelOnError: false
  // stream이 data전송 끝내면 실행
  onDone: (){
    // 대충 이런거 하는 코드
  }
);

// stream에서 각각의 value를 가져옴
NumberCreator().stream.map((value) => function)

// 조건에 해당하는 value만 가져옴
NumberCreator().stream.where(조건).map((value) => function).listen(action)
  
// future 상태에 따라 widget을 형성
StreamBuilder<type>(
	stream: NumberCreator().stream.map((value) => function),
  builder: (context, snapshot){
    /* 
    snapshot의 state에 따른 widget 형성
    */
  }
)
  
  
class NumberCreator{
  NumberCreator(){
    Timer.periodic(Duration(seconds: 1), (t) {
      // 새로운 데이터가 추가되는 sink
      _controller.sink.add(_count);
      _count++;
    });
    
    var _count = 1;
    
    final _controller = StreamController<int>();
    
    Stream<int> get stream => _controller.stream;
  }  
}

영상: Dart Stream

Async/Await

Future<int> _loadFromDisk() {}

Future<String> _fetchNetworkData(int ad) {}

// 아래 코드에 future가 없을 경우 여기서 
ProcessedData createData() {
  final id = _loadFromDisk();
  final data = _fetchNetworkData(id);
  return ProcessedData(data);
}

// async가 있으면 무조건 await도 있음
Future<ProcessedData> createData() async {
  final id = await _loadFromDisk();
  final data = await _fetchNetworkData(id);
  return ProcessedData(data);
}

// 위 코드와 같음
Future<ProcessedData> createData() {
  return _loadFromDisk().then((id) {
    return _fetchNetworkData(id);
  }).then((data){
    return ProcessedData(data);
  })
}

// 코드를 기능 기준으로 3곳으로 분할 가능
// 1. 함수가 시작하면서 loadFromDisk를 call하고 wait
Future<ProcessedData> createData() {
  return _loadFromDisk()
    
    // wait해서 가져온 data를 써서 networkData를 call하고 wait
    .then((id) {
    return _fetchNetworkData(id);
  })
    
    // 그 다음 ProcessedData를 networkData에서 가져온 데이터를 사용해 return
    .then((data){
    return ProcessedData(data);
  })
}

// 마찬가지로 3분할 가능한데 위에 코드보다 분할이 깰끔~
Future<ProcessedData> createData() async {
  final id = await _loadFromDisk();

  final data = await _fetchNetworkData(id);
  
  return ProcessedData(data);
}

// error를 잡는건 try catch로~

// for loop 쓰고싶음! very simple
// async달아주고 return type Future로 해주고 data를 기다렸다가 해야하는 작업에 await 붙여주면 done.
Future<int> getTotal(List<int> numbers) async {
  int total = 0;
  
  await for (final value in numbers){
    total += value;
  }
  
  return total;
}

class ProcessedData {
  ProcessedData(this.data);
  final String data;
  
}

영상: Async/Await

Generator

Generator를 많이 쓸일은 없을건데 쓰면 편해질 순간이 올거임ㅋ

  Single value Zero or more values
Sync: int Iterator
Async: Future Stream

이 표에서 오른쪽 열인 Zero or more values가 generator를 써서 iterate 시키는 부분

// Iterator. 한번에 하나씩 하게 해서 반복을 가능하게 함
abstract class Iterator<E> {
  bool moveNext();
  E get current
}

// 특정한 부류의 iterator를 쓸 수 있는 Iterable
class MyString extends Iterable<String> {
  Mystring(this.strings);
  final List<String> strings;
  
  Iterator<String> get iterator => strings.iterator;
}

void main(){
  final myStrings = MyStrings([
    'One',
    'Two',
    'Three',
  ]);
  
  final lengths = myStrings.map((s) => s.length);
  
	// Iterable를 for/in에 넣을 수 있음
  for (final length in lengths){
    print(length);
  }
}

//위와 같은 iterable을 내보내는 generator 만들기
//function의 return type이 iterable로 설정해서 선언하면 됨
Iterable<int> getRange(int start, int finish) sync* {
  for (int i = start; i <= finish; i++){
    // yield는 return과 비슷한데 function을 끝내지는 않음
    // 대신 single value 주고 caller가 다음거 줄때까지 기다림. 이걸 반복
    yield i;
  }
}

// recursive
Iterable<int> getRange(int start, int finish) sync* {
  if (start <= finish){
    yield start;
    for (final val in getRange(start + 1, finish)) {
      yield val;
    }
  }
}

// same code
Iterable<int> getRange(int start, int finish) sync* {
  if (start <= finish){
    yield start;
		yield* getRange(start + 1, finish);
  }
}

void main() {
  final numbers = getRangee(1, 10);
  
  for(int val in numbers){
    print(val);
  }
  
  // 위에꺼 대신 이렇게 사용도 가능
  numbers.forEach(print);
}

/// Stream의 경우
// 서버에서 숫자 2배로 하는 함수가 있다고 치자
Future<int> fetchDouble(int val){
  // Fetch val * 2 from the server
}

// Stream으로 return type 설정해서 함수 선언하고
// async* 붙여주고 yield 써서 값 받고 저장
Stream<int> fetchDoubles(int start, int finish) async* {
  for(int i = start; i <= finish; i++){
    yield await fetchDouble(i);
  }
}

// recursive
Stream<int> fetchDoubles(int start, int finish) async* {
  if(start <= finish){
		yield await fetchDouble(start);
    yield* fetchDoubles(start + 1, finish);
  }
}

void main() {
  fetchDoubles(1, 10).listen(print);
  
}

영상: Generator Function

Simple BLoC Tutorial - Firebase Login

|

Tutorial: Firebase Login

Firebase와 연동시켜 회원가입, 로그인 등을 구현한 코드이다.

firebase 연동과 같은 부분은 제외하고 코드 설명

Setup

Pubspec.yaml

name: flutter_firebase_login
description: A new Flutter project.

version: 1.0.0+1

environment:
  sdk: ">=2.6.0 <3.0.0"

dependencies:
  flutter:
    sdk: flutter
  firebase_core: ^0.4.0+8
  google_sign_in: ^4.0.0
  firebase_auth: ^0.15.0+1
  flutter_bloc: ^4.0.0
  equatable: ^1.0.0
  meta: ^1.1.6
  rxdart: ^0.23.1
  font_awesome_flutter: ^8.4.0

flutter:
  uses-material-design: true
  assets:
    - assets/

User repository

Authentication과 User의 정보에 대해 다루는 부분이다. Bloc과 큰 관계는 없는 부분.

user_repository.dart

import 'dart:async';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:google_sign_in/google_sign_in.dart';

class UserRepository {
  final FirebaseAuth _firebaseAuth;
  final GoogleSignIn _googleSignIn;

  UserRepository({FirebaseAuth firebaseAuth, GoogleSignIn googleSignin})
      : _firebaseAuth = firebaseAuth ?? FirebaseAuth.instance,
        _googleSignIn = googleSignin ?? GoogleSignIn();

  Future<FirebaseUser> signInWithGoogle() async {
    final GoogleSignInAccount googleUser = await _googleSignIn.signIn();
    final GoogleSignInAuthentication googleAuth =
    await googleUser.authentication;
    final AuthCredential credential = GoogleAuthProvider.getCredential(
      accessToken: googleAuth.accessToken,
      idToken: googleAuth.idToken,
    );
    await _firebaseAuth.signInWithCredential(credential);
    return _firebaseAuth.currentUser();
  }

  Future<void> signInWithCredentials(String email, String password) {
    return _firebaseAuth.signInWithEmailAndPassword(
      email: email,
      password: password,
    );
  }

  Future<void> signUp({String email, String password}) async {
    return await _firebaseAuth.createUserWithEmailAndPassword(
      email: email,
      password: password,
    );
  }

  Future<void> signOut() async {
    return Future.wait([
      _firebaseAuth.signOut(),
      _googleSignIn.signOut(),
    ]);
  }

  Future<bool> isSignedIn() async {
    final currentUser = await _firebaseAuth.currentUser();
    return currentUser != null;
  }

  Future<String> getUser() async {
    return (await _firebaseAuth.currentUser()).email;
  }
}

Authentication

Authentication에는 다음과 같은 파일이 필요하다.

authentication_bloc 폴더를 만들어 아래의 파일들을 만들어 주자.

├── authentication_bloc
│   ├── App(main.dart)
│   ├── simple_bloc_delegate.dart
│   ├── splash_screen.dart
│   ├── home_screen.dart
│   ├── authentication_bloc.dart
│   ├── authentication_event.dart
│   └── authentication_state.dart

authentication_state.dart

Authentication의 event에 따른 각각의 state에 대해 설정하는 곳이다.

Equatable은 AuthenticationState2개가 서로 같을 때에만 true를 return하는 package다.

part of 'authentication_bloc.dart';

abstract class AuthenticationState extends Equatable {
  const AuthenticationState();

  @override
  List<Object> get props => [];
}

class AuthenticationInitial extends AuthenticationState {}

class AuthenticationSuccess extends AuthenticationState {
  final String displayName;

  const AuthenticationSuccess(this.displayName);

  @override
  List<Object> get props => [displayName];

  @override
  String toString() => 'AuthenticationSuccess { displayName: $displayName }';
}

class AuthenticationFailure extends AuthenticationState {}

authentication_event.dart

Authentication에서 일어날 수 있는 event들에 대해 선언하고 각 event class들에 대해 설정하는 부분이다.(변수나 함수 등)

part of 'authentication_bloc.dart';

abstract class AuthenticationEvent extends Equatable {
  @override
  List<Object> get props => [];
}

class AuthenticationStarted extends AuthenticationEvent {}

class AuthenticationLoggedIn extends AuthenticationEvent {}

class AuthenticationLoggedOut extends AuthenticationEvent {}

authentication_bloc.dart

Authentication에서 event가 발생하면 그 event에 맞게 state와 연결하는 부분이다.

import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:meta/meta.dart';
import 'package:flutter_firebase_login/user_repository.dart';

part 'authentication_event.dart';
part 'authentication_state.dart';

class AuthenticationBloc
    extends Bloc<AuthenticationEvent, AuthenticationState> {
  final UserRepository _userRepository;

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

  @override
  AuthenticationState get initialState => AuthenticationInitial();

  @override
  Stream<AuthenticationState> mapEventToState(
      AuthenticationEvent event,
      ) async* {
    if (event is AuthenticationStarted) {
      yield* _mapAuthenticationStartedToState();
    } else if (event is AuthenticationLoggedIn) {
      yield* _mapAuthenticationLoggedInToState();
    } else if (event is AuthenticationLoggedOut) {
      yield* _mapAuthenticationLoggedOutToState();
    }
  }

  Stream<AuthenticationState> _mapAuthenticationStartedToState() async* {
    final isSignedIn = await _userRepository.isSignedIn();
    if (isSignedIn) {
      final name = await _userRepository.getUser();
      yield AuthenticationSuccess(name);
    } else {
      yield AuthenticationFailure();
    }
  }

  Stream<AuthenticationState> _mapAuthenticationLoggedInToState() async* {
    yield AuthenticationSuccess(await _userRepository.getUser());
  }

  Stream<AuthenticationState> _mapAuthenticationLoggedOutToState() async* {
    yield AuthenticationFailure();
    _userRepository.signOut();
  }
}

main.dart

기본 of 기본인 곳이다. 여기서 authentication bloc을 만든다. bloc부분은 따로 분리해도 괜찮다.

import 'package:flutter/material.dart';
import 'package:bloc/bloc.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_firebase_login/authentication_bloc/authentication_bloc.dart';
import 'package:flutter_firebase_login/user_repository.dart';
import 'package:flutter_firebase_login/home_screen.dart';
import 'package:flutter_firebase_login/login/login.dart';
import 'package:flutter_firebase_login/splash_screen.dart';
import 'package:flutter_firebase_login/simple_bloc_delegate.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  BlocSupervisor.delegate = SimpleBlocDelegate();
  final UserRepository userRepository = UserRepository();
  runApp(
    BlocProvider(
      create: (context) => AuthenticationBloc(userRepository: userRepository)
        ..add(AuthenticationStarted()),
      child: App(userRepository: userRepository),
    ),
  );
}

class App extends StatelessWidget {
  final UserRepository _userRepository;

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: BlocBuilder<AuthenticationBloc, AuthenticationState>(
        builder: (context, state) {
          if (state is AuthenticationInitial) {
            return SplashScreen();
          }
          if (state is AuthenticationFailure) {
            return LoginScreen(userRepository: _userRepository);
          }
          if (state is AuthenticationSuccess) {
            return HomeScreen(name: state.displayName);
          }
        },
      ),
    );
  }
}

simple_bloc_delegate.dart

bloc에서 일어날 수 있는 일들에 대해 한꺼번에 다룬다. Event, Error, Transition이 일어날 때 마다 print만 시키는 간단한 일이다. BlocDelegate에 대해 어느정도 이해하고 감을 잡을 수 있다.

import 'package:bloc/bloc.dart';

class SimpleBlocDelegate extends BlocDelegate {
  @override
  void onEvent(Bloc bloc, Object event) {
    print('Event');
    print(event);
    super.onEvent(bloc, event);
  }

  @override
  void onError(Bloc bloc, Object error, StackTrace stackTrace) {
    print('Error');
    print(error);
    super.onError(bloc, error, stackTrace);
  }

  @override
  void onTransition(Bloc bloc, Transition transition) {
    print('Transition');
    print(transition);
    super.onTransition(bloc, transition);
  }
}

splash_screen.dart

authtication의 inital screen이다.

import 'package:flutter/material.dart';

class SplashScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(child: Text('Splash Screen')),
    );
  }
}

home_screen.dart

authentication이 잘 되었을 시 나타나는 페이지이다. 로그인이 완료되면 여기로 온다.

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_firebase_login/authentication_bloc/authentication_bloc.dart';

class HomeScreen extends StatelessWidget {
  final String name;

  HomeScreen({Key key, @required this.name}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Home'),
        actions: <Widget>[
          IconButton(
            icon: Icon(Icons.exit_to_app),
            onPressed: () {
              BlocProvider.of<AuthenticationBloc>(context).add(
                AuthenticationLoggedOut(),
              );
            },
          )
        ],
      ),
      body: Column(
        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
        children: <Widget>[
          Center(child: Text('Welcome $name!')),
        ],
      ),
    );
  }
}

Login

Login에는 다음과 같은 파일이 필요하다.

├── lib
│   ├── login
│   │   ├── bloc
│   │   │   ├── bloc.dart
│   │   │   ├── login_bloc.dart
│   │   │   ├── login_event.dart
│   │   │   └── login_state.dart
│   │   ├── login.dart
│   │   ├── login_screen.dart
│   │   ├── login_form.dart
│   │   ├── login_button.dart
│   │   ├── google_login_button.dart
│   │   └── create_account_button.dart
│   └── validator.dart

bloc.dart

Barrel file로 사용된다. Barrel file은 import를 해놓은 파일인데 폴더처럼 하나에다 넣고 관리하는 느낌을 생각하면 된다.

예를 들어 login_screen.dart에서 login_bloc.dart, login_event.dart를 import해야 하고 login_form.dart에서는 login_*.dart 파일 3개를 다 import해야한다 했을 때, bloc.dart만 import 시키면 bloc.dart에서 export 시킨 애들을 다 import하는 것과 같은 효과다.(사실 확실하지는… 않다ㅎ)

export 'login_bloc.dart';
export 'login_event.dart';
export 'login_state.dart';

login_state.dart

Login의 event에 따른 각각의 state에 대해 설정하는 곳이다.

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.initial() {
    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 isSubmitEnabled,
    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,
    }''';
  }
}

login_event.dart

Login에서 일어날 수 있는 event들에 대해 선언하고 각 event class들에 대해 설정하는 부분이다.

import 'package:meta/meta.dart';
import 'package:equatable/equatable.dart';

abstract class LoginEvent extends Equatable {
  const LoginEvent();

  @override
  List<Object> get props => [];
}

class LoginEmailChanged extends LoginEvent {
  final String email;

  const LoginEmailChanged({@required this.email});

  @override
  List<Object> get props => [email];

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

class LoginPasswordChanged extends LoginEvent {
  final String password;

  const LoginPasswordChanged({@required this.password});

  @override
  List<Object> get props => [password];

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

class LoginWithGooglePressed extends LoginEvent {}

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

  const LoginWithCredentialsPressed({
    @required this.email,
    @required this.password,
  });

  @override
  List<Object> get props => [email, password];

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

login_bloc.dart

Login에서 event가 발생하면 그 event에 맞게 state와 연결하는 부분이다.

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

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

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

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

  @override
  Stream<Transition<LoginEvent, LoginState>> transformEvents(
      Stream<LoginEvent> events,
      TransitionFunction<LoginEvent, LoginState> transitionFn,
      ) {
    final nonDebounceStream = events.where((event) {
      return (event is! LoginEmailChanged && event is! LoginPasswordChanged);
    });
    final debounceStream = events.where((event) {
      return (event is LoginEmailChanged || event is LoginPasswordChanged);
    }).debounceTime(Duration(milliseconds: 300));
    return super.transformEvents(
      nonDebounceStream.mergeWith([debounceStream]),
      transitionFn,
    );
  }

  @override
  Stream<LoginState> mapEventToState(LoginEvent event) async* {
    if (event is LoginEmailChanged) {
      yield* _mapLoginEmailChangedToState(event.email);
    } else if (event is LoginPasswordChanged) {
      yield* _mapLoginPasswordChangedToState(event.password);
    } else if (event is LoginWithGooglePressed) {
      yield* _mapLoginWithGooglePressedToState();
    } else if (event is LoginWithCredentialsPressed) {
      yield* _mapLoginWithCredentialsPressedToState(
        email: event.email,
        password: event.password,
      );
    }
  }

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

  Stream<LoginState> _mapLoginPasswordChangedToState(String password) async* {
    yield state.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();
    }
  }
}

login.dart

Login bloc을 제외한 부분(presentation layer)의 barrel file이다.

export './create_account_button.dart';
export './bloc/bloc.dart';
export './login_form.dart';
export './google_login_button.dart';
export './login_button.dart';
export './login_screen.dart';

validators.dart

Email은 email형식을 따랐는지, Password는 비밀번호 조건을 만족하는지 확인하는 곳이다.

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_screen.dart

login 화면을 나타내는 부분이다. 여기서 LoginBloc을 만들어 준다.

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_firebase_login/user_repository.dart';
import 'package:flutter_firebase_login/login/login.dart';

class LoginScreen extends StatelessWidget {
  final UserRepository _userRepository;

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Login')),
      // LoginBloc을 만들고 LoginForm에 userRepository를 넘겨줌.
      body: BlocProvider<LoginBloc>(
        create: (context) => LoginBloc(userRepository: _userRepository),
        child: LoginForm(userRepository: _userRepository),
      ),
    );
  }
}

login_form.dart

login을 할 때 필요한 form에 대한 부분이다. email, password에 대한 입력을 받고 이를 state로 bloc에 넘겨준다. 그리고 state 변화에 따라 UI가 같이 변하는 부분이다.

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_firebase_login/user_repository.dart';
import 'package:flutter_firebase_login/authentication_bloc/authentication_bloc.dart';
import 'package:flutter_firebase_login/login/login.dart';

class LoginForm extends StatefulWidget {
  final UserRepository _userRepository;

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

  State<LoginForm> 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() {
    super.initState();
    // LoginScreen에서 만든 LoginBloc을 사용하겠다고 선언
    _loginBloc = BlocProvider.of<LoginBloc>(context);
    _emailController.addListener(_onLoginEmailChanged);
    _passwordController.addListener(_onLoginPasswordChanged);
  }

  @override
  Widget build(BuildContext context) {
    return BlocListener<LoginBloc, LoginState>(
      // State의 변화에 따른 현재 상태의 변화에 대한 알림. void 함수느낌 print를 하거나 widget에서 할 수 없는 일들을 처리
      listener: (context, state) {
        if (state.isFailure) {
          Scaffold.of(context)
            ..hideCurrentSnackBar()
            ..showSnackBar(
              SnackBar(
                content: Row(
                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
                  children: [Text('Login Failure'), Icon(Icons.error)],
                ),
                backgroundColor: Colors.red,
              ),
            );
        }
        if (state.isSubmitting) {
          Scaffold.of(context)
            ..hideCurrentSnackBar()
            ..showSnackBar(
              SnackBar(
                content: Row(
                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
                  children: [
                    Text('Logging In...'),
                    CircularProgressIndicator(),
                  ],
                ),
              ),
            );
        }
        if (state.isSuccess) {
          BlocProvider.of<AuthenticationBloc>(context).add(AuthenticationLoggedIn());
        }
      },
      // State 변화에 따른 widget의 변화
      child: BlocBuilder<LoginBloc, LoginState>(
        builder: (context, state) {
          return Padding(
            padding: EdgeInsets.all(20.0),
            child: Form(
              child: ListView(
                children: <Widget>[
                  Padding(
                    padding: EdgeInsets.symmetric(vertical: 20),
                    child: Image.asset('assets/flutter_logo.png', height: 200),
                  ),
                  TextFormField(
                    controller: _emailController,
                    decoration: InputDecoration(
                      icon: Icon(Icons.email),
                      labelText: 'Email',
                    ),
                    keyboardType: TextInputType.emailAddress,
                    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),
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.stretch,
                      children: <Widget>[
                        LoginButton(
                          onPressed: isLoginButtonEnabled(state)
                              ? _onFormSubmitted
                              : null,
                        ),
                        GoogleLoginButton(),
                        CreateAccountButton(userRepository: _userRepository),
                      ],
                    ),
                  ),
                ],
              ),
            ),
          );
        },
      ),
    );
  }

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

  void _onLoginEmailChanged() {
    _loginBloc.add(
      LoginEmailChanged(email: _emailController.text),
    );
  }

  void _onLoginPasswordChanged() {
    _loginBloc.add(
      LoginPasswordChanged(password: _passwordController.text),
    );
  }

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

login_button.dart

그냥 button만 구현되어 있다. 눌렀을 때 login_form에 선언되어 있는 함수를 따른다.

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'),
    );
  }
}

google_login_ button.dart

위랑 별 차이 없다. 하나 있다면 요놈은 form을 안거치고 여기서 바로 bloc으로 간다는 점.

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_firebase_login/login/login.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';

class GoogleLoginButton extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return RaisedButton.icon(
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(30.0),
      ),
      icon: Icon(FontAwesomeIcons.google, color: Colors.white),
      onPressed: () {
        // state가 다음과 같이 변할 것이다. login_bloc.dart에서 해당하는 event와 대응되는 transition이 생길 것.
        BlocProvider.of<LoginBloc>(context).add(
          LoginWithGooglePressed(),
        );
      },
      label: Text('Sign in with Google', style: TextStyle(color: Colors.white)),
      color: Colors.redAccent,
    );
  }
}
create_account_button.dart

역시 버튼일 뿐인데 register와 연결이 되는 것이 차이점이다.

import 'package:flutter/material.dart';
import 'package:flutter_firebase_login/user_repository.dart';
import 'package:flutter_firebase_login/register/register.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 an Account',
      ),
      onPressed: () {
        Navigator.of(context).push(
          MaterialPageRoute(builder: (context) {
            return RegisterScreen(userRepository: _userRepository);
          }),
        );
      },
    );
  }
}

Register

Register에는 다음과 같은 파일들이 필요하다.

├── lib
│   ├── register
│   │   ├── bloc
│   │   │   ├── bloc.dart
│   │   │   ├── register_bloc.dart
│   │   │   ├── register_event.dart
│   │   │   └── register_state.dart
│   │   ├── register.dart
│   │   ├── register_screen.dart
│   │   ├── register_form.dart
│   │   └── regitser_button.dart 

bloc.dart

login에서의 bloc.dart와 역할의 차이가 없다.

export 'register_bloc.dart';
export 'register_event.dart';
export 'register_state.dart';

register_state.dart

Register의 event에 따른 각각의 state에 대해 설정하는 곳이다.

import 'package:meta/meta.dart';

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

  bool get isFormValid => isEmailValid && isPasswordValid;

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

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

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

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

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

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

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

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

register_event.dart

Register에서 일어날 수 있는 event들에 대해 선언하고 각 event class들에 대해 설정하는 부분이다.

import 'package:equatable/equatable.dart';
import 'package:meta/meta.dart';

// Equatable은 AuthenticationState2개가 서로 같을 때에만 true를 return함.
abstract class RegisterEvent extends Equatable {
  const RegisterEvent();

  @override
  List<Object> get props => [];
}

class RegisterEmailChanged extends RegisterEvent {
  final String email;

  const RegisterEmailChanged({@required this.email});

  @override
  List<Object> get props => [email];

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

class RegisterPasswordChanged extends RegisterEvent {
  final String password;

  const RegisterPasswordChanged({@required this.password});

  @override
  List<Object> get props => [password];

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

class RegisterSubmitted extends RegisterEvent {
  final String email;
  final String password;

  const RegisterSubmitted({
    @required this.email,
    @required this.password,
  });

  @override
  List<Object> get props => [email, password];

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

register_bloc.dart

Register에서 event가 발생하면 그 event에 맞게 state와 연결하는 부분이다.

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

class RegisterBloc extends Bloc<RegisterEvent, RegisterState> {
  final UserRepository _userRepository;

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

  @override
  RegisterState get initialState => RegisterState.initial();

  @override
  Stream<Transition<RegisterEvent, RegisterState>> transformEvents(
      Stream<RegisterEvent> events,
      TransitionFunction<RegisterEvent, RegisterState> transitionFn,
      ) {
    final nonDebounceStream = events.where((event) {
      return (event is! RegisterEmailChanged && event is! RegisterPasswordChanged);
    });
    final debounceStream = events.where((event) {
      return (event is RegisterEmailChanged || event is RegisterPasswordChanged);
    }).debounceTime(Duration(milliseconds: 300));
    return super.transformEvents(
      nonDebounceStream.mergeWith([debounceStream]),
      transitionFn,
    );
  }

  // 여기서는 state의 변화를 줄 뿐 state의 변화에 의한 행동은 register_form과 같은 presentation layer에서 담당
  @override
  Stream<RegisterState> mapEventToState(
      RegisterEvent event,
      ) async* {
    if (event is RegisterEmailChanged) {
      yield* _mapRegisterEmailChangedToState(event.email);
    } else if (event is RegisterPasswordChanged) {
      yield* _mapRegisterPasswordChangedToState(event.password);
    } else if (event is RegisterSubmitted) {
      yield* _mapRegisterSubmittedToState(event.email, event.password);
    }
  }

  Stream<RegisterState> _mapRegisterEmailChangedToState(String email) async* {
    yield state.update(
      // validators.dart에 있는 함수의 return값이 isEmailValid안에 들어감.
      isEmailValid: Validators.isValidEmail(email),
    );
  }

  Stream<RegisterState> _mapRegisterPasswordChangedToState(String password) async* {
    yield state.update(
      isPasswordValid: Validators.isValidPassword(password),
    );
  }

  Stream<RegisterState> _mapRegisterSubmittedToState(
      String email,
      String password,
      ) async* {
    yield RegisterState.loading();
    try {
      await _userRepository.signUp(
        email: email,
        password: password,
      );
      yield RegisterState.success();
    } catch (_) {
      yield RegisterState.failure();
    }
  }
}

register.dart

presentation layer에 해당하는 부분의 barrel file이다.

export './bloc/bloc.dart';
export './register_button.dart';
export './register_screen.dart';
export './register_form.dart';

register_screen.dart

Scaffold를 만들어서 screen 화면을 띄우고 Bloc을 만드는 부분이다.

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_firebase_login/user_repository.dart';
import 'package:flutter_firebase_login/register/register.dart';

class RegisterScreen extends StatelessWidget {
  final UserRepository _userRepository;

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Register')),
      body: Center(
        // userRepository로 RegisterBloc을 생성하고 이를 RegisterForm에 넘겨줌
        child: BlocProvider<RegisterBloc>(
          create: (context) => RegisterBloc(userRepository: _userRepository),
          child: RegisterForm(),
        ),
      ),
    );
  }
}

register_form.dart

email과 password에 대한 정보를 state로 RegisterScreen에서 만든 bloc에 전달해주고 state에 따른 widget과 상태의 변화를 적용하는 곳이다.

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_firebase_login/authentication_bloc/authentication_bloc.dart';
import 'package:flutter_firebase_login/register/register.dart';

class RegisterForm extends StatefulWidget {
  State<RegisterForm> createState() => _RegisterFormState();
}

class _RegisterFormState extends State<RegisterForm> {
  final TextEditingController _emailController = TextEditingController();
  final TextEditingController _passwordController = TextEditingController();

  RegisterBloc _registerBloc;

  bool get isPopulated =>
      _emailController.text.isNotEmpty && _passwordController.text.isNotEmpty;

  bool isRegisterButtonEnabled(RegisterState state) {
    return state.isFormValid && isPopulated && !state.isSubmitting;
  }

  @override
  void initState() {
    super.initState();
    // screen에서 만든 RegisterBloc을 child인 RegisterForm에서 사용하기 위해 선언
    _registerBloc = BlocProvider.of<RegisterBloc>(context);
    // RegisterBloc에 현재 text controller에 있는 state를 전달
    // register_bloc.dart에 있는 event중 RegisterEmailChanged가 발생되었고 그 내용을 전달.
    _emailController.addListener(_onEmailChanged);
    _passwordController.addListener(_onPasswordChanged);
  }

  @override
  Widget build(BuildContext context) {
    // State의 변화가 생기면 state를 check. state에 따라 각기 다른 행동을 하게 함.
    return BlocListener<RegisterBloc, RegisterState>(
      // Listener가 state의 변화에 따라 특정 일을 하는 곳 함수느낌
      listener: (context, state) {
        if (state.isSubmitting) {
          Scaffold.of(context)
            ..hideCurrentSnackBar()
            ..showSnackBar(
              SnackBar(
                content: Row(
                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
                  children: [
                    Text('Registering...'),
                    CircularProgressIndicator(),
                  ],
                ),
              ),
            );
        }
        if (state.isSuccess) {
          BlocProvider.of<AuthenticationBloc>(context).add(AuthenticationLoggedIn());
          Navigator.of(context).pop();
        }
        if (state.isFailure) {
          Scaffold.of(context)
            ..hideCurrentSnackBar()
            ..showSnackBar(
              SnackBar(
                content: Row(
                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
                  children: [
                    Text('Registration Failure'),
                    Icon(Icons.error),
                  ],
                ),
                backgroundColor: Colors.red,
              ),
            );
        }
      },
      // RegisterBloc의 state에 따라 widget을 return
      child: BlocBuilder<RegisterBloc, RegisterState>(
        builder: (context, state) {
          return Padding(
            padding: EdgeInsets.all(20),
            child: Form(
              child: ListView(
                children: <Widget>[
                  TextFormField(
                    controller: _emailController,
                    decoration: InputDecoration(
                      icon: Icon(Icons.email),
                      labelText: 'Email',
                    ),
                    keyboardType: TextInputType.emailAddress,
                    autocorrect: false,
                    autovalidate: true,
                    validator: (_) {
                      return !state.isEmailValid ? 'Invalid Email' : null;
                    },
                  ),
                  TextFormField(
                    controller: _passwordController,
                    decoration: InputDecoration(
                      icon: Icon(Icons.lock),
                      labelText: 'Password',
                    ),
                    obscureText: true,
                    autocorrect: false,
                    autovalidate: true,
                    validator: (_) {
                      return !state.isPasswordValid ? 'Invalid Password' : null;
                    },
                  ),
                  RegisterButton(
                    onPressed: isRegisterButtonEnabled(state)
                        ? _onFormSubmitted
                        : null,
                  ),
                ],
              ),
            ),
          );
        },
      ),
    );
  }

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

  void _onEmailChanged() {
    _registerBloc.add(
      RegisterEmailChanged(email: _emailController.text),
    );
  }

  void _onPasswordChanged() {
    _registerBloc.add(
      RegisterPasswordChanged(password: _passwordController.text),
    );
  }

  void _onFormSubmitted() {
    _registerBloc.add(
      RegisterSubmitted(
        email: _emailController.text,
        password: _passwordController.text,
      ),
    );
  }
}

register_button.dart

그냥 버튼하나 딸랑

import 'package:flutter/material.dart';

// 그냥 버튼을 누르면 생긴 event를 전해줄 뿐.
class RegisterButton extends StatelessWidget {
  final VoidCallback _onPressed;

  RegisterButton({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('Register'),
    );
  }
}

이 글은 아래의 글을 참고하여 작성되었습니다.

Firebase Login Tutorial Using Bloc

What is Bloc?

|

This is BLoC

Getting Started

BLoC에 대해 알아보기 전에 준비해야 할 것.

OverView

Bloc은 다음과 같은 pub package로 구성이 되어 있다.

  • bloc - Core bloc library
  • flutter_bloc - Powerful Flutter Widgets built to work with bloc in order to build fast, reactive mobile applications.
  • angular_bloc - Powerful Angular Components built to work with bloc in order to build fast, reactive web applications.

우리는 모바일을 통해 앱을 개발할 것이기 때문에 위의 2개만 사용하면 된다.

Setting

다음의 dependency들을 pubspec.yaml에 추가해주자.

dependencies:
  bloc: ^4.0.0
  flutter_bloc: ^4.0.0

그리고 main.dart에 import를 시켜주자.

import 'package:bloc/bloc.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

이러면 사용 준비는 끝!

Why BLoC?

BLoC(Business Logic Component)

UI와 Logic을 따로 분리해서 사용하는 기법으로 효율적으로 개발할 수 있게 도와준다.

개발자는 보통 어플이 어떤 방식으로 돌아가고 있고 잘 돌아가는지 알고 싶어하고, 최대한 효율적으로 관리하고 싶어 한다.

Bloc에 있는 다음과 같은 3가지 특징들은 개발자에게 큰 도움이 된다.

  • Simple: 이해하기 쉽고 사용도 어렵지 않음
  • Powerful: 복잡한 어플을 더 작은 components들로 쪼개서 관리할 수 있게 해줌
  • Testable: 어플의 모든 부분을 쉽게 테스트 가능

이런이런 어서 빨리 Bloc을 써봐야 겠는걸?

이 글은 아래의 글을 참고하여 작성되었습니다.

Getting Started

Why Bloc?