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: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<List<TaskModel>>();
@ -49,9 +46,6 @@ class EventBloc {
/// A subject of a cache that contains the image files.
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.
EventModel _event;
@ -74,13 +68,6 @@ class EventBloc {
/// An observable of a cache of the images files.
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.
/// 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();

View file

@ -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<FirebaseUser> 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();

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.
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<EventScreen>
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<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() {
_snackBarStatusSubscription.cancel();
bloc.dispose();
super.dispose();
}

View file

@ -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<HomeScreen> {
/// 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<HomeScreen> {
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<List<TaskModel>> snap) {
if (!snap.hasData) {
return Center(
child: LoadingIndicator(),
);
}
return Stack(
overflow: Overflow.visible,
children: <Widget>[
_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<List<TaskModel>> snap) {
if (!snap.hasData) {
return Center(
child: LoadingIndicator(),
);
}
return Stack(
overflow: Overflow.visible,
children: <Widget>[
_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<HomeScreen> {
);
}
void _showDialog(BuildContext context) {
Navigator.of(context).push(NewItemDialogRoute());
}
Widget _buildTasksList(List<TaskModel> tasks) {
return ListView(
padding: EdgeInsets.only(top: _searchBoxHeight + 15),
@ -118,6 +140,7 @@ class _HomeScreenState extends State<HomeScreen> {
@override
void dispose() {
_snackBarStatusSubscription.cancel();
bloc.dispose();
super.dispose();
}

View file

@ -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: <Widget>[
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<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/cupertino.dart';