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:
parent
28a112779a
commit
10aabed07e
8 changed files with 186 additions and 68 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
40
lib/src/mixins/upload_status_mixin.dart
Normal file
40
lib/src/mixins/upload_status_mixin.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,17 +69,20 @@ 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(
|
||||
body: Builder(
|
||||
builder: (BuildContext context) {
|
||||
_scaffoldContext = context;
|
||||
return StreamBuilder(
|
||||
stream: bloc.userTasks,
|
||||
builder:
|
||||
(BuildContext context, AsyncSnapshot<List<TaskModel>> snap) {
|
||||
builder: (BuildContext context,
|
||||
AsyncSnapshot<List<TaskModel>> snap) {
|
||||
if (!snap.hasData) {
|
||||
return Center(
|
||||
child: LoadingIndicator(),
|
||||
|
|
@ -82,16 +106,14 @@ 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
|
|
|
|||
15
test/src/utils_test.dart
Normal file
15
test/src/utils_test.dart
Normal 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])));
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue