diff --git a/lib/src/blocs/event_bloc.dart b/lib/src/blocs/event_bloc.dart index c3b8da4..56d1759 100644 --- a/lib/src/blocs/event_bloc.dart +++ b/lib/src/blocs/event_bloc.dart @@ -4,18 +4,18 @@ import 'dart:io'; import 'package:meta/meta.dart'; import 'package:rxdart/rxdart.dart'; +import '../mixins/upload_status_mixin.dart'; import '../models/event_model.dart'; import '../models/task_model.dart'; import '../models/user_model.dart'; import '../resources/firestore_provider.dart'; import '../resources/firebase_storage_provider.dart'; import '../services/auth_service.dart'; -import '../services/upload_status_service.dart'; import '../utils.dart' show kTaskListPriorityTransforemer, getImageThumbnailPath; /// 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. final String eventName; @@ -28,9 +28,6 @@ class EventBloc { /// An instace of the auth service. final AuthService _auth = authService; - /// An instance of the upload status service. - final UploadStatusService _uploadStatus = uploadStatusService; - /// A subject of list of task model. final _tasks = BehaviorSubject>(); @@ -49,9 +46,6 @@ class EventBloc { /// A subject of a cache that contains the image files. final _images = BehaviorSubject>>(); - /// A subject of a flag that indicates if there is a snack bar showing. - final _snackBarStatus = BehaviorSubject(seedValue: false); - /// The event being managed by this bloc. EventModel _event; @@ -74,13 +68,6 @@ class EventBloc { /// An observable of a cache of the images files. Observable>> get images => _images.stream; - /// An observable of a flag that indicates whether or not a snackBar is - /// currently showing. - ValueObservable get snackBarStatus => _snackBarStatus.stream; - - /// An observable of the status of files being uploaded. - Observable get uploadStatus => _uploadStatus.status; - // Sinks getters. /// Starts the fetching process for an image given its path. Function(String) get fetchImage => _imagesFetcher.sink.add; @@ -88,12 +75,10 @@ class EventBloc { /// Starts the fetching process for an image thumbail given its path. Function(String) get fetchThumbnail => _imagesThumbnailsFetcher.sink.add; - /// Updates the snack bar status. - Function(bool) get updateSnackBarStatus => _snackBarStatus.sink.add; - EventBloc({ @required this.eventName, }) { + initializeSnackBarStatus(); _ready = _initUserAndEvent(); _imagesFetcher.transform(_imagesTransformer()).pipe(_images); _imagesThumbnailsFetcher @@ -159,8 +144,7 @@ class EventBloc { } void dispose() async { - await _snackBarStatus.drain(); - _snackBarStatus.close(); + await disposeUploadStatusMixin(); await _imagesThumbnailsFetcher.drain(); _imagesThumbnailsFetcher.close(); await _imagesFetcher.drain(); diff --git a/lib/src/blocs/home_bloc.dart b/lib/src/blocs/home_bloc.dart index 559cfc8..534b1e1 100644 --- a/lib/src/blocs/home_bloc.dart +++ b/lib/src/blocs/home_bloc.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:rxdart/rxdart.dart'; import '../utils.dart' show kTaskListPriorityTransforemer; +import '../mixins/upload_status_mixin.dart'; import '../models/task_model.dart'; import '../resources/firestore_provider.dart'; import '../services/auth_service.dart'; @@ -10,7 +11,7 @@ import '../services/auth_service.dart'; export '../services/auth_service.dart' show FirebaseUser; /// 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. final AuthService _auth = authService; @@ -47,6 +48,10 @@ class HomeBloc { /// An observable of the current logged in user. Observable get userStream => _auth.userStream; + HomeBloc() { + initializeSnackBarStatus(); + } + // TODO: Include the priority in the filtering. /// Returns a stream transformer that filters the task with the text from /// the search box. @@ -106,6 +111,7 @@ class HomeBloc { } void dispose() async { + await disposeUploadStatusMixin(); await _searchBoxText.drain(); _searchBoxText.close(); await _tasks.drain(); diff --git a/lib/src/mixins/upload_status_mixin.dart b/lib/src/mixins/upload_status_mixin.dart new file mode 100644 index 0000000..b60ac8b --- /dev/null +++ b/lib/src/mixins/upload_status_mixin.dart @@ -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(seedValue: false); + + /// An observable of a flag that indicates whether or not a snackBar is + /// currently showing. + ValueObservable 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 get uploadStatus => uploadStatusSer.status; + + Future disposeUploadStatusMixin() async { + await snackBarStatusSubject.drain(); + snackBarStatusSubject.close(); + } + + void initializeSnackBarStatus() { + uploadStatusService.status + .transform(StreamTransformer.fromHandlers( + handleData: (value, sink) { + sink.add(value.numberOfFiles != 0); + }, + )) + .transform(DistinctStreamTransformer()) + .pipe(snackBarStatusSubject); + } +} diff --git a/lib/src/screens/event_screen.dart b/lib/src/screens/event_screen.dart index 88b2423..88f32a2 100644 --- a/lib/src/screens/event_screen.dart +++ b/lib/src/screens/event_screen.dart @@ -41,12 +41,25 @@ class _EventScreenState extends State /// Needed for showing snackbars. BuildContext _scaffoldContext; + StreamSubscription _snackBarStatusSubscription; + initState() { super.initState(); bloc = EventBloc(eventName: widget.eventName); bloc.fetchTasks(); bloc.fetchImagesPaths(); _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) { @@ -164,7 +177,8 @@ class _EventScreenState extends State Widget buildAddPictureButton() { return GradientTouchableContainer( radius: 8, - onTap: onAddPicturePressed, + onTap: () => + Navigator.of(context).pushNamed('newImage/${bloc.eventName}'), child: Icon( Icons.camera_alt, color: Colors.white, @@ -190,19 +204,8 @@ class _EventScreenState extends State ); } - // TODO: use a block provider instead of passing callbacks - Future onAddPicturePressed() async { - await Navigator.of(context).pushNamed('newImage/${bloc.eventName}'); - if (bloc.snackBarStatus.value == false) { - showUploadStatusSnackBar( - _scaffoldContext, - bloc.uploadStatus, - bloc.updateSnackBarStatus, - ); - } - } - void dispose() { + _snackBarStatusSubscription.cancel(); bloc.dispose(); super.dispose(); } diff --git a/lib/src/screens/home_screen.dart b/lib/src/screens/home_screen.dart index bb25fce..38808c3 100644 --- a/lib/src/screens/home_screen.dart +++ b/lib/src/screens/home_screen.dart @@ -1,6 +1,9 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import '../utils.dart' show showUploadStatusSnackBar; import '../models/task_model.dart'; import '../blocs/home_bloc.dart'; import '../widgets/home_app_bar.dart'; @@ -20,10 +23,28 @@ class _HomeScreenState extends State { /// An instance of the bloc for this screen. final HomeBloc bloc = HomeBloc(); + /// The context of the scaffold being shown. + /// + /// Needed for showing snackbars. + BuildContext _scaffoldContext; + + StreamSubscription _snackBarStatusSubscription; + @override initState() { super.initState(); 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) { @@ -48,38 +69,43 @@ class _HomeScreenState extends State { floatingActionButton: FloatingActionButton( child: Icon(FontAwesomeIcons.plus), backgroundColor: Color(0xFF707070), - onPressed: () => _showDialog(context), + onPressed: () => Navigator.of(context).push(NewItemDialogRoute()), ), floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, appBar: HomeAppBar( avatarUrl: userAvatarUrl, subtitle: 'Hello $userDisplayName!', ), - body: StreamBuilder( - stream: bloc.userTasks, - builder: - (BuildContext context, AsyncSnapshot> snap) { - if (!snap.hasData) { - return Center( - child: LoadingIndicator(), - ); - } - return Stack( - overflow: Overflow.visible, - children: [ - _buildTasksList(snap.data), - // This container is needed to make it seem like the search box is - // part of the app bar. - Container( - height: _searchBoxHeight / 2, - width: double.infinity, - color: Theme.of(context).cardColor, - ), - SearchBox( - onChanged: bloc.updateSearchBoxText, - height: 50.0, - ), - ], + body: Builder( + builder: (BuildContext context) { + _scaffoldContext = context; + return StreamBuilder( + stream: bloc.userTasks, + builder: (BuildContext context, + AsyncSnapshot> snap) { + if (!snap.hasData) { + return Center( + child: LoadingIndicator(), + ); + } + return Stack( + overflow: Overflow.visible, + children: [ + _buildTasksList(snap.data), + // This container is needed to make it seem like the search box is + // part of the app bar. + Container( + height: _searchBoxHeight / 2, + width: double.infinity, + color: Theme.of(context).cardColor, + ), + SearchBox( + onChanged: bloc.updateSearchBoxText, + height: 50.0, + ), + ], + ); + }, ); }, ), @@ -88,10 +114,6 @@ class _HomeScreenState extends State { ); } - void _showDialog(BuildContext context) { - Navigator.of(context).push(NewItemDialogRoute()); - } - Widget _buildTasksList(List tasks) { return ListView( padding: EdgeInsets.only(top: _searchBoxHeight + 15), @@ -118,6 +140,7 @@ class _HomeScreenState extends State { @override void dispose() { + _snackBarStatusSubscription.cancel(); bloc.dispose(); super.dispose(); } diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 9b6b5b7..c106b54 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:collection'; import 'package:flutter/material.dart'; import 'package:rxdart/rxdart.dart'; @@ -97,14 +98,12 @@ void showUploadStatusSnackBar( } print('Number of files: ${snap.data.numberOfFiles}'); if (snap.data.numberOfFiles == 0) { - onSuccessfullyClosed(false); - scaffoldState.hideCurrentSnackBar(); - return Text(''); + return Text('Done!'); } return Row( children: [ Spacer(), - Text('${snap.data.numberOfFiles} pending files'), + Text('${snap.data.numberOfFiles} pending'), Spacer(flex: 5), Text(snap.data.percentage + '%'), 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 extends StreamTransformerBase { + final StreamTransformer transformer; + + DistinctStreamTransformer() : transformer = _buildTransformer(); + + @override + Stream bind(Stream stream) => transformer.bind(stream); + + static StreamTransformer _buildTransformer() { + return StreamTransformer((Stream input, bool cancelOnError) { + T last; + StreamController controller; + StreamSubscription subscription; + + controller = StreamController( + 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 resumeSignal]) => + subscription.pause(resumeSignal), + onResume: () => subscription.resume(), + onCancel: () { + return subscription.cancel(); + }); + + return controller.stream.listen(null); + }); + } +} diff --git a/lib/src/widgets/new_item_dialog_route.dart b/lib/src/widgets/new_item_dialog_route.dart index bb73bac..4873a95 100644 --- a/lib/src/widgets/new_item_dialog_route.dart +++ b/lib/src/widgets/new_item_dialog_route.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/cupertino.dart'; diff --git a/test/src/utils_test.dart b/test/src/utils_test.dart new file mode 100644 index 0000000..096cac8 --- /dev/null +++ b/test/src/utils_test.dart @@ -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]))); + }); + }); +}