Implemented the upload status snackbar on the home screen, created the [DistinctStreamTransformer] and refactored how screens decide when to show a snackbar

This commit is contained in:
Mariano Uvalle 2019-04-24 15:11:39 -05:00
parent 28a112779a
commit 10aabed07e
8 changed files with 186 additions and 68 deletions

View file

@ -4,18 +4,18 @@ import 'dart:io';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'package:rxdart/rxdart.dart'; import 'package:rxdart/rxdart.dart';
import '../mixins/upload_status_mixin.dart';
import '../models/event_model.dart'; import '../models/event_model.dart';
import '../models/task_model.dart'; import '../models/task_model.dart';
import '../models/user_model.dart'; import '../models/user_model.dart';
import '../resources/firestore_provider.dart'; import '../resources/firestore_provider.dart';
import '../resources/firebase_storage_provider.dart'; import '../resources/firebase_storage_provider.dart';
import '../services/auth_service.dart'; import '../services/auth_service.dart';
import '../services/upload_status_service.dart';
import '../utils.dart' import '../utils.dart'
show kTaskListPriorityTransforemer, getImageThumbnailPath; show kTaskListPriorityTransforemer, getImageThumbnailPath;
/// A business logic component that manages the state for an event screen. /// A business logic component that manages the state for an event screen.
class EventBloc { class EventBloc extends Object with UploadStatusMixin {
/// The name of the event being shown. /// The name of the event being shown.
final String eventName; final String eventName;
@ -28,9 +28,6 @@ class EventBloc {
/// An instace of the auth service. /// An instace of the auth service.
final AuthService _auth = authService; final AuthService _auth = authService;
/// An instance of the upload status service.
final UploadStatusService _uploadStatus = uploadStatusService;
/// A subject of list of task model. /// A subject of list of task model.
final _tasks = BehaviorSubject<List<TaskModel>>(); final _tasks = BehaviorSubject<List<TaskModel>>();
@ -49,9 +46,6 @@ class EventBloc {
/// A subject of a cache that contains the image files. /// A subject of a cache that contains the image files.
final _images = BehaviorSubject<Map<String, Future<File>>>(); final _images = BehaviorSubject<Map<String, Future<File>>>();
/// A subject of a flag that indicates if there is a snack bar showing.
final _snackBarStatus = BehaviorSubject<bool>(seedValue: false);
/// The event being managed by this bloc. /// The event being managed by this bloc.
EventModel _event; EventModel _event;
@ -74,13 +68,6 @@ class EventBloc {
/// An observable of a cache of the images files. /// An observable of a cache of the images files.
Observable<Map<String, Future<File>>> get images => _images.stream; Observable<Map<String, Future<File>>> get images => _images.stream;
/// An observable of a flag that indicates whether or not a snackBar is
/// currently showing.
ValueObservable<bool> get snackBarStatus => _snackBarStatus.stream;
/// An observable of the status of files being uploaded.
Observable<UploadStatus> get uploadStatus => _uploadStatus.status;
// Sinks getters. // Sinks getters.
/// Starts the fetching process for an image given its path. /// Starts the fetching process for an image given its path.
Function(String) get fetchImage => _imagesFetcher.sink.add; Function(String) get fetchImage => _imagesFetcher.sink.add;
@ -88,12 +75,10 @@ class EventBloc {
/// Starts the fetching process for an image thumbail given its path. /// Starts the fetching process for an image thumbail given its path.
Function(String) get fetchThumbnail => _imagesThumbnailsFetcher.sink.add; Function(String) get fetchThumbnail => _imagesThumbnailsFetcher.sink.add;
/// Updates the snack bar status.
Function(bool) get updateSnackBarStatus => _snackBarStatus.sink.add;
EventBloc({ EventBloc({
@required this.eventName, @required this.eventName,
}) { }) {
initializeSnackBarStatus();
_ready = _initUserAndEvent(); _ready = _initUserAndEvent();
_imagesFetcher.transform(_imagesTransformer()).pipe(_images); _imagesFetcher.transform(_imagesTransformer()).pipe(_images);
_imagesThumbnailsFetcher _imagesThumbnailsFetcher
@ -159,8 +144,7 @@ class EventBloc {
} }
void dispose() async { void dispose() async {
await _snackBarStatus.drain(); await disposeUploadStatusMixin();
_snackBarStatus.close();
await _imagesThumbnailsFetcher.drain(); await _imagesThumbnailsFetcher.drain();
_imagesThumbnailsFetcher.close(); _imagesThumbnailsFetcher.close();
await _imagesFetcher.drain(); await _imagesFetcher.drain();

View file

@ -3,6 +3,7 @@ import 'dart:async';
import 'package:rxdart/rxdart.dart'; import 'package:rxdart/rxdart.dart';
import '../utils.dart' show kTaskListPriorityTransforemer; import '../utils.dart' show kTaskListPriorityTransforemer;
import '../mixins/upload_status_mixin.dart';
import '../models/task_model.dart'; import '../models/task_model.dart';
import '../resources/firestore_provider.dart'; import '../resources/firestore_provider.dart';
import '../services/auth_service.dart'; import '../services/auth_service.dart';
@ -10,7 +11,7 @@ import '../services/auth_service.dart';
export '../services/auth_service.dart' show FirebaseUser; export '../services/auth_service.dart' show FirebaseUser;
/// A business logic component that manages the state of the home screen. /// A business logic component that manages the state of the home screen.
class HomeBloc { class HomeBloc extends Object with UploadStatusMixin {
/// An instance of the auth service. /// An instance of the auth service.
final AuthService _auth = authService; final AuthService _auth = authService;
@ -47,6 +48,10 @@ class HomeBloc {
/// An observable of the current logged in user. /// An observable of the current logged in user.
Observable<FirebaseUser> get userStream => _auth.userStream; Observable<FirebaseUser> get userStream => _auth.userStream;
HomeBloc() {
initializeSnackBarStatus();
}
// TODO: Include the priority in the filtering. // TODO: Include the priority in the filtering.
/// Returns a stream transformer that filters the task with the text from /// Returns a stream transformer that filters the task with the text from
/// the search box. /// the search box.
@ -106,6 +111,7 @@ class HomeBloc {
} }
void dispose() async { void dispose() async {
await disposeUploadStatusMixin();
await _searchBoxText.drain(); await _searchBoxText.drain();
_searchBoxText.close(); _searchBoxText.close();
await _tasks.drain(); await _tasks.drain();

View file

@ -0,0 +1,40 @@
import 'dart:async';
import 'package:rxdart/rxdart.dart';
import '../utils.dart';
import '../services/upload_status_service.dart';
mixin UploadStatusMixin {
/// An instance of the upload status service.
final UploadStatusService uploadStatusSer = uploadStatusService;
/// A subject of a flag that indicates if there is a snack bar showing.
final snackBarStatusSubject = BehaviorSubject<bool>(seedValue: false);
/// An observable of a flag that indicates whether or not a snackBar is
/// currently showing.
ValueObservable<bool> get snackBarStatus => snackBarStatusSubject.stream;
/// Updates the snack bar status.
Function(bool) get updateSnackBarStatus => snackBarStatusSubject.sink.add;
/// An observable of the status of files being uploaded.
Observable<UploadStatus> get uploadStatus => uploadStatusSer.status;
Future<void> disposeUploadStatusMixin() async {
await snackBarStatusSubject.drain();
snackBarStatusSubject.close();
}
void initializeSnackBarStatus() {
uploadStatusService.status
.transform(StreamTransformer<UploadStatus, bool>.fromHandlers(
handleData: (value, sink) {
sink.add(value.numberOfFiles != 0);
},
))
.transform(DistinctStreamTransformer())
.pipe(snackBarStatusSubject);
}
}

View file

@ -41,12 +41,25 @@ class _EventScreenState extends State<EventScreen>
/// Needed for showing snackbars. /// Needed for showing snackbars.
BuildContext _scaffoldContext; BuildContext _scaffoldContext;
StreamSubscription _snackBarStatusSubscription;
initState() { initState() {
super.initState(); super.initState();
bloc = EventBloc(eventName: widget.eventName); bloc = EventBloc(eventName: widget.eventName);
bloc.fetchTasks(); bloc.fetchTasks();
bloc.fetchImagesPaths(); bloc.fetchImagesPaths();
_tabController = TabController(vsync: this, length: 2); _tabController = TabController(vsync: this, length: 2);
_snackBarStatusSubscription = bloc.snackBarStatus.listen((bool visible) {
if (visible) {
showUploadStatusSnackBar(
_scaffoldContext,
bloc.uploadStatus,
bloc.updateSnackBarStatus,
);
} else {
Scaffold.of(_scaffoldContext).hideCurrentSnackBar();
}
});
} }
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -164,7 +177,8 @@ class _EventScreenState extends State<EventScreen>
Widget buildAddPictureButton() { Widget buildAddPictureButton() {
return GradientTouchableContainer( return GradientTouchableContainer(
radius: 8, radius: 8,
onTap: onAddPicturePressed, onTap: () =>
Navigator.of(context).pushNamed('newImage/${bloc.eventName}'),
child: Icon( child: Icon(
Icons.camera_alt, Icons.camera_alt,
color: Colors.white, color: Colors.white,
@ -190,19 +204,8 @@ class _EventScreenState extends State<EventScreen>
); );
} }
// TODO: use a block provider instead of passing callbacks
Future<void> onAddPicturePressed() async {
await Navigator.of(context).pushNamed('newImage/${bloc.eventName}');
if (bloc.snackBarStatus.value == false) {
showUploadStatusSnackBar(
_scaffoldContext,
bloc.uploadStatus,
bloc.updateSnackBarStatus,
);
}
}
void dispose() { void dispose() {
_snackBarStatusSubscription.cancel();
bloc.dispose(); bloc.dispose();
super.dispose(); super.dispose();
} }

View file

@ -1,6 +1,9 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import '../utils.dart' show showUploadStatusSnackBar;
import '../models/task_model.dart'; import '../models/task_model.dart';
import '../blocs/home_bloc.dart'; import '../blocs/home_bloc.dart';
import '../widgets/home_app_bar.dart'; import '../widgets/home_app_bar.dart';
@ -20,10 +23,28 @@ class _HomeScreenState extends State<HomeScreen> {
/// An instance of the bloc for this screen. /// An instance of the bloc for this screen.
final HomeBloc bloc = HomeBloc(); final HomeBloc bloc = HomeBloc();
/// The context of the scaffold being shown.
///
/// Needed for showing snackbars.
BuildContext _scaffoldContext;
StreamSubscription _snackBarStatusSubscription;
@override @override
initState() { initState() {
super.initState(); super.initState();
bloc.fetchTasks(); bloc.fetchTasks();
_snackBarStatusSubscription = bloc.snackBarStatus.listen((bool visible) {
if (visible) {
showUploadStatusSnackBar(
_scaffoldContext,
bloc.uploadStatus,
bloc.updateSnackBarStatus,
);
} else {
Scaffold.of(_scaffoldContext).hideCurrentSnackBar();
}
});
} }
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -48,38 +69,43 @@ class _HomeScreenState extends State<HomeScreen> {
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
child: Icon(FontAwesomeIcons.plus), child: Icon(FontAwesomeIcons.plus),
backgroundColor: Color(0xFF707070), backgroundColor: Color(0xFF707070),
onPressed: () => _showDialog(context), onPressed: () => Navigator.of(context).push(NewItemDialogRoute()),
), ),
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
appBar: HomeAppBar( appBar: HomeAppBar(
avatarUrl: userAvatarUrl, avatarUrl: userAvatarUrl,
subtitle: 'Hello $userDisplayName!', subtitle: 'Hello $userDisplayName!',
), ),
body: StreamBuilder( body: Builder(
stream: bloc.userTasks, builder: (BuildContext context) {
builder: _scaffoldContext = context;
(BuildContext context, AsyncSnapshot<List<TaskModel>> snap) { return StreamBuilder(
if (!snap.hasData) { stream: bloc.userTasks,
return Center( builder: (BuildContext context,
child: LoadingIndicator(), AsyncSnapshot<List<TaskModel>> snap) {
); if (!snap.hasData) {
} return Center(
return Stack( child: LoadingIndicator(),
overflow: Overflow.visible, );
children: <Widget>[ }
_buildTasksList(snap.data), return Stack(
// This container is needed to make it seem like the search box is overflow: Overflow.visible,
// part of the app bar. children: <Widget>[
Container( _buildTasksList(snap.data),
height: _searchBoxHeight / 2, // This container is needed to make it seem like the search box is
width: double.infinity, // part of the app bar.
color: Theme.of(context).cardColor, Container(
), height: _searchBoxHeight / 2,
SearchBox( width: double.infinity,
onChanged: bloc.updateSearchBoxText, color: Theme.of(context).cardColor,
height: 50.0, ),
), SearchBox(
], onChanged: bloc.updateSearchBoxText,
height: 50.0,
),
],
);
},
); );
}, },
), ),
@ -88,10 +114,6 @@ class _HomeScreenState extends State<HomeScreen> {
); );
} }
void _showDialog(BuildContext context) {
Navigator.of(context).push(NewItemDialogRoute());
}
Widget _buildTasksList(List<TaskModel> tasks) { Widget _buildTasksList(List<TaskModel> tasks) {
return ListView( return ListView(
padding: EdgeInsets.only(top: _searchBoxHeight + 15), padding: EdgeInsets.only(top: _searchBoxHeight + 15),
@ -118,6 +140,7 @@ class _HomeScreenState extends State<HomeScreen> {
@override @override
void dispose() { void dispose() {
_snackBarStatusSubscription.cancel();
bloc.dispose(); bloc.dispose();
super.dispose(); super.dispose();
} }

View file

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:rxdart/rxdart.dart'; import 'package:rxdart/rxdart.dart';
@ -97,14 +98,12 @@ void showUploadStatusSnackBar(
} }
print('Number of files: ${snap.data.numberOfFiles}'); print('Number of files: ${snap.data.numberOfFiles}');
if (snap.data.numberOfFiles == 0) { if (snap.data.numberOfFiles == 0) {
onSuccessfullyClosed(false); return Text('Done!');
scaffoldState.hideCurrentSnackBar();
return Text('');
} }
return Row( return Row(
children: <Widget>[ children: <Widget>[
Spacer(), Spacer(),
Text('${snap.data.numberOfFiles} pending files'), Text('${snap.data.numberOfFiles} pending'),
Spacer(flex: 5), Spacer(flex: 5),
Text(snap.data.percentage + '%'), Text(snap.data.percentage + '%'),
Spacer(), Spacer(),
@ -114,3 +113,49 @@ void showUploadStatusSnackBar(
), ),
)); ));
} }
/// A transformer that only emits a value if it is different than the last one
/// emitted.
///
/// The [==] operator is to check for inequality, be sure that the objects
/// passing through the stream behave correctly with theses operator.
class DistinctStreamTransformer<T> extends StreamTransformerBase<T, T> {
final StreamTransformer<T, T> transformer;
DistinctStreamTransformer() : transformer = _buildTransformer();
@override
Stream<T> bind(Stream<T> stream) => transformer.bind(stream);
static StreamTransformer<T, T> _buildTransformer<T>() {
return StreamTransformer<T, T>((Stream<T> input, bool cancelOnError) {
T last;
StreamController<T> controller;
StreamSubscription<T> subscription;
controller = StreamController<T>(
sync: true,
onListen: () {
subscription = input.listen(
(T value) {
if (!(last == value)) {
last = value;
controller.add(value);
}
},
onError: controller.addError,
onDone: controller.close,
cancelOnError: cancelOnError,
);
},
onPause: ([Future<dynamic> resumeSignal]) =>
subscription.pause(resumeSignal),
onResume: () => subscription.resume(),
onCancel: () {
return subscription.cancel();
});
return controller.stream.listen(null);
});
}
}

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';

15
test/src/utils_test.dart Normal file
View file

@ -0,0 +1,15 @@
import 'dart:async';
import 'package:do_more/src/utils.dart';
import 'package:flutter_test/flutter_test.dart';
main() {
group('DistinctStreamTransformer', () {
test('should only emit non repeated elements', () {
final stream = Stream.fromIterable([1, 1, 1, 2, 2, 2, 1, 1, 1, 1]);
final transformedStream = stream.transform(DistinctStreamTransformer());
expect(transformedStream.toList(), completion(equals([1, 2, 1])));
});
});
}