Skip to content

5.4 State-Management: Async BLoC (Real World) #8

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: state_management_bloc
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/main.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import 'package:counter_workshop/src/app.dart';
import 'package:counter_workshop/src/features/counter/data/datasources/local/counter.db.dart';
import 'package:counter_workshop/src/features/counter/data/datasources/local/counter.database.dart';
import 'package:counter_workshop/src/features/counter/data/datasources/remote/src/mock/counter_fake.api.dart';
import 'package:counter_workshop/src/features/counter/data/repositories/counter.repository.dart';
import 'package:flutter/material.dart';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import 'package:counter_workshop/src/features/counter/domain/counter.model.dart';

/// Locale app database like SqlLite that providers a [CounterModel]
class CounterDatabase {
CounterModel _counter = CounterModel(value: 0);
final int databaseDelay = 200;

Future<CounterModel> getCounter() {
// Pretend it's a db call
return Future.delayed(Duration(milliseconds: databaseDelay), () => _counter);
}

Future<void> storeCounter(CounterModel counter) {
_counter = counter;
if (_counter.value == 10) {
throw Exception('Database read lock while updating Counter to ${_counter.value}.');
} else {
// Pretend it's a db call
return Future.delayed(Duration(milliseconds: databaseDelay));
}
}
}
14 changes: 0 additions & 14 deletions lib/src/features/counter/data/datasources/local/counter.db.dart

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import 'dart:async';

import 'package:counter_workshop/src/features/counter/data/datasources/local/counter.db.dart';
import 'package:counter_workshop/src/features/counter/data/datasources/local/counter.database.dart';
import 'package:counter_workshop/src/features/counter/data/datasources/remote/counter.api.dart';
import 'package:counter_workshop/src/features/counter/data/datasources/remote/converters/counter_response.converter.dart';
import 'package:counter_workshop/src/features/counter/data/datasources/remote/dtos/counter_response.dto.dart';
Expand All @@ -25,16 +25,20 @@ class CounterRepository {
CounterModel counterModel = CounterResponseConverter().toModel(counterResponseDto);

// store model in database
counterDatabase.storeCounter(counterModel);
await counterDatabase.storeCounter(counterModel);
}

CounterModel getCounter() {
return counterDatabase.getCounter();
Future<CounterModel> getCounter() async {
return await counterDatabase.getCounter();
}

Future<void> updateCounter({required CounterModel counterModel}) async {
log('updating counter: ${counterModel.id} with value: $counterModel');

// store model in database
await counterDatabase.storeCounter(counterModel);

// store model in api
await counterApi.updateCounter(counterModel.id, counterModel.value);
return;
}
}
46 changes: 38 additions & 8 deletions lib/src/features/counter/presentation/bloc/counter.bloc.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:async';

import 'package:counter_workshop/src/features/counter/data/repositories/counter.repository.dart';
import 'package:counter_workshop/src/features/counter/presentation/bloc/counter.event.dart';
import 'package:counter_workshop/src/features/counter/presentation/bloc/counter.state.dart';
Expand All @@ -7,20 +9,48 @@ import 'package:flutter_bloc/flutter_bloc.dart';
class CounterBloc extends Bloc<CounterEvent, CounterState> {
final CounterRepository counterRepository;

CounterBloc({required this.counterRepository}) : super(const CounterState(value: 0)) {
CounterBloc({required this.counterRepository}) : super(CounterLoadingState()) {
on<CounterFetchData>(_onFetchData);
on<CounterIncrementPressed>(_onIncrement);
on<CounterDecrementPressed>(_onDecrement);
}

void _onIncrement(CounterIncrementPressed event, Emitter<CounterState> emit) {
debugPrint('INCREMENT: ${state.value.toString()}');
emit(CounterState(value: state.value + 1));
Future<FutureOr<void>> _onFetchData(CounterFetchData event, Emitter<CounterState> emit) async {
emit(CounterLoadingState());
try {
final counter = await counterRepository.getCounter();
emit(CounterDataState(counter));
} catch (e) {
emit(CounterErrorState(e.toString()));
}
}

Future<void> _onIncrement(CounterIncrementPressed event, Emitter<CounterState> emit) async {
debugPrint('INCREMENT: ${event.counterModel.toString()}');
emit(CounterLoadingState());
try {
event.counterModel.value += 1;
await counterRepository.updateCounter(counterModel: event.counterModel);
emit(CounterDataState(event.counterModel));
} catch (e) {
emit(CounterErrorState(e.toString()));
}
}

void _onDecrement(CounterDecrementPressed event, Emitter<CounterState> emit) {
debugPrint('DECREMENT: ${state.value.toString()}');
if (state.value > 0) {
emit(CounterState(value: state.value - 1));
Future<void> _onDecrement(CounterDecrementPressed event, Emitter<CounterState> emit) async {
debugPrint('DECREMENT: ${event.counterModel.toString()}');

if (event.counterModel.value == 0) {
return;
}

emit(CounterLoadingState());
try {
event.counterModel.value -= 1;
await counterRepository.updateCounter(counterModel: event.counterModel);
emit(CounterDataState(event.counterModel));
} catch (e) {
emit(CounterErrorState(e.toString()));
}
}
}
26 changes: 22 additions & 4 deletions lib/src/features/counter/presentation/bloc/counter.event.dart
Original file line number Diff line number Diff line change
@@ -1,12 +1,30 @@
import 'package:counter_workshop/src/features/counter/domain/counter.model.dart';
import 'package:equatable/equatable.dart';

abstract class CounterEvent extends Equatable {
const CounterEvent();

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

/// Notifies bloc to increment state.
class CounterIncrementPressed extends CounterEvent {}
/// Load data from repository
class CounterFetchData extends CounterEvent {}

/// Notifies bloc to increment state
class CounterIncrementPressed extends CounterEvent {
const CounterIncrementPressed(this.counterModel);
final CounterModel counterModel;

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

/// Notifies bloc to decrement state
class CounterDecrementPressed extends CounterEvent {
const CounterDecrementPressed(this.counterModel);
final CounterModel counterModel;

/// Notifies bloc to decrement state.
class CounterDecrementPressed extends CounterEvent {}
@override
List<Object> get props => [counterModel];
}
30 changes: 26 additions & 4 deletions lib/src/features/counter/presentation/bloc/counter.state.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,31 @@
import 'package:counter_workshop/src/features/counter/domain/counter.model.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';

class CounterState extends Equatable {
const CounterState({required this.value});
final int value;
@immutable
abstract class CounterState extends Equatable {
@override
List<Object> get props => [];
}

/// Loading counter State
class CounterLoadingState extends CounterState {}

/// Data counter State
class CounterDataState extends CounterState {
final CounterModel counterModel;
CounterDataState(this.counterModel);

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

/// Error counter State
class CounterErrorState extends CounterState {
final String error;

CounterErrorState(this.error);

@override
List<Object?> get props => [value];
List<Object> get props => [error];
}
78 changes: 58 additions & 20 deletions lib/src/features/counter/presentation/view/counter.page.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'package:counter_workshop/src/features/counter/data/repositories/counter.repository.dart';
import 'package:counter_workshop/src/features/counter/presentation/bloc/counter.bloc.dart';
import 'package:counter_workshop/src/features/counter/presentation/bloc/counter.event.dart';
import 'package:counter_workshop/src/features/counter/presentation/bloc/counter.state.dart';
import 'package:counter_workshop/src/features/counter/presentation/view/widgets/counter_text.widget.dart';
import 'package:counter_workshop/src/features/counter/presentation/view/widgets/custom_circular_button.widget.dart';
import 'package:flutter/material.dart';
Expand All @@ -13,7 +14,10 @@ class CounterPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final counterRepository = context.read<CounterRepository>();
return BlocProvider(create: (_) => CounterBloc(counterRepository: counterRepository), child: const _CounterView());
return BlocProvider(
create: (_) => CounterBloc(counterRepository: counterRepository)..add(CounterFetchData()),
child: const _CounterView(),
);
}
}

Expand All @@ -24,35 +28,69 @@ class _CounterView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final counterBloc = context.read<CounterBloc>();
// final showButton = false;

return Scaffold(
extendBodyBehindAppBar: true,
appBar: AppBar(
title: const Text('Counter Page'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
CounterText(),
],
),
body: BlocBuilder<CounterBloc, CounterState>(
builder: (context, state) {
if (state is CounterLoadingState) {
// loading
return const Center(
child: CircularProgressIndicator(strokeWidth: 3),
);
} else if (state is CounterDataState) {
// data
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CounterText(counterValue: state.counterModel.value),
],
),
);
} else if (state is CounterErrorState) {
// error
return Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'Es ist ein Fehler aufgetreten: ${state.error}',
style: const TextStyle(color: Colors.red),
),
),
);
}
// state unknown, fallback to empty or return a common error
return const SizedBox();
},
),
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
floatingActionButton: Container(
padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 40.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
CustomCircularButton(
icon: Icons.remove,
onPressed: () => counterBloc.add(CounterDecrementPressed()),
),
CustomCircularButton(
icon: Icons.add,
onPressed: () => counterBloc.add(CounterIncrementPressed()),
),
],
child: BlocBuilder<CounterBloc, CounterState>(
builder: (context, state) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
CustomCircularButton(
icon: Icons.remove,
onPressed: state is CounterDataState
? () => counterBloc.add(CounterDecrementPressed(state.counterModel))
: null,
),
CustomCircularButton(
icon: Icons.add,
onPressed: state is CounterDataState
? () => counterBloc.add(CounterIncrementPressed(state.counterModel))
: null,
),
],
);
},
),
),
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import 'package:counter_workshop/src/features/counter/presentation/bloc/counter.bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

class CounterText extends StatelessWidget {
const CounterText({super.key});
const CounterText({super.key, required this.counterValue});
final int counterValue;

@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final counterValue = context.select((CounterBloc bloc) => bloc.state.value);
return Text(
'$counterValue',
style: theme.textTheme.headlineLarge,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class CustomCircularButton extends StatelessWidget {
style: OutlinedButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(15),
disabledForegroundColor: Colors.black12,
),
onPressed: onPressed,
child: Icon(
Expand Down
6 changes: 1 addition & 5 deletions test/widget_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// tree, read text, and verify that the values of widget properties are correct.

import 'package:counter_workshop/src/app.dart';
import 'package:counter_workshop/src/features/counter/data/datasources/local/counter.db.dart';
import 'package:counter_workshop/src/features/counter/data/datasources/local/counter.database.dart';
import 'package:counter_workshop/src/features/counter/data/datasources/remote/src/mock/counter_fake.api.dart';
import 'package:counter_workshop/src/features/counter/data/repositories/counter.repository.dart';
import 'package:flutter/material.dart';
Expand All @@ -20,10 +20,6 @@ void main() {
const Duration(milliseconds: 300), // Because of FakeApi delay
);

// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);

// Tap the '-' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.remove));
await tester.pumpAndSettle();
Expand Down