diff --git a/lib/src/App.dart b/lib/src/App.dart index 1718897..5b97b79 100644 --- a/lib/src/App.dart +++ b/lib/src/App.dart @@ -16,6 +16,9 @@ class App extends StatelessWidget { theme: ThemeData( // Accent color is set to be used by the floating action button. accentColor: Color(0xFF707070), + iconTheme: IconThemeData( + color: Colors.white, + ), canvasColor: Color.fromRGBO(23, 25, 29, 1.0), cardColor: Color.fromRGBO(36, 39, 44, 1.0), cursorColor: Color.fromRGBO(112, 112, 112, 1), @@ -55,9 +58,15 @@ class App extends StatelessWidget { }, ); } else if (routeTokens.first == 'newImage') { + String eventName; + if (routeTokens.length > 1) { + eventName = routeTokens[1]; + } return MaterialPageRoute( builder: (BuildContext context) { - return NewImageScreen(); + return NewImageScreen( + defaultEventName: eventName, + ); }, ); } else if (routeTokens.first == 'event') { diff --git a/lib/src/blocs/event_bloc.dart b/lib/src/blocs/event_bloc.dart index 2779472..dc3d618 100644 --- a/lib/src/blocs/event_bloc.dart +++ b/lib/src/blocs/event_bloc.dart @@ -103,6 +103,7 @@ class EventBloc { if (isThumbnail) { path = getImageThumbnailPath(path); } + // Do not re-fetch the image if it resolved already. if (cache.containsKey(path)) { return cache; } diff --git a/lib/src/blocs/new_image_bloc.dart b/lib/src/blocs/new_image_bloc.dart index 142f766..caa5246 100644 --- a/lib/src/blocs/new_image_bloc.dart +++ b/lib/src/blocs/new_image_bloc.dart @@ -29,7 +29,13 @@ class NewImageBloc { /// A subject of the current media event name. final _eventName = BehaviorSubject(); - NewImageBloc() { + /// Default event name. + final String defaultEventName; + + NewImageBloc({this.defaultEventName}) { + if (defaultEventName != null && defaultEventName != '') { + _eventName.sink.add(defaultEventName); + } setCurrentUser(); } diff --git a/lib/src/resources/firebase_storage_provider.dart b/lib/src/resources/firebase_storage_provider.dart index 49ad6cc..ccda071 100644 --- a/lib/src/resources/firebase_storage_provider.dart +++ b/lib/src/resources/firebase_storage_provider.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'dart:typed_data'; import 'package:firebase_storage/firebase_storage.dart'; import 'package:path_provider/path_provider.dart'; @@ -47,17 +48,47 @@ class FirebaseStorageProvider { // TODO: Add tests for this method. // TODO: Delete the tmp file when the app closes. + // TODO: Add support for requests taking too long not only errors. /// Returns a future of the file from the path. /// /// Downloads the raw bytes from the sotrage buckets and converts it to a file. - Future getFile(String path) async { - final fileName = path.split('/').last; - final fileReference = _storage.child(path); - final metadata = await fileReference.getMetadata(); - final bytes = await fileReference.getData(metadata.sizeBytes); - + /// The fetching process is repeated up to [retries] times with a [retryDelay] + /// delay in between tries. + Future getFile( + String path, { + int retries = 3, + Duration retryDelay = const Duration(seconds: 2), + }) async { final Directory tmp = await getTemporaryDirectory(); + final fileName = path.split('/').last; final file = File('${tmp.path}/$fileName'); + + // Don't re-fetch if the file is already in the temp directory. + if (await file.exists()) { + return file; + } + Uint8List bytes; + // Repeat until the fetching process is successful or the number of retries + // is excceded. + while (bytes == null && retries > 0) { + final fileReference = _storage.child(path); + // Assing null to metadata if there's an error during the fetching process. + // aka the file potentially doesn't exist. + final metadata = + await fileReference.getMetadata().catchError((error) => null); + // Restart the fetching process if there was an error. + if (metadata == null) { + print('retrying'); + await Future.delayed(retryDelay); + retries -= 1; + continue; + } + bytes = await fileReference.getData(metadata.sizeBytes); + print('done getting data'); + } + if (bytes == null) { + return null; + } return file.writeAsBytes(bytes); } } diff --git a/lib/src/screens/event_screen.dart b/lib/src/screens/event_screen.dart index 97b12e1..98e96dd 100644 --- a/lib/src/screens/event_screen.dart +++ b/lib/src/screens/event_screen.dart @@ -1,16 +1,16 @@ import 'dart:async'; -import 'dart:io'; import 'package:flutter/material.dart'; import '../utils.dart' show getImageThumbnailPath; import '../blocs/event_bloc.dart'; +import '../screens/gallery_screen.dart'; import '../models/task_model.dart'; +import '../widgets/async_thumbnail.dart'; import '../widgets/custom_app_bar.dart'; import '../widgets/gradient_touchable_container.dart'; import '../widgets/loading_indicator.dart'; import '../widgets/task_list_tile.dart'; -import '../widgets/async_thumbnail.dart'; /// A screen that shows all the items linked to an event. class EventScreen extends StatefulWidget { @@ -128,9 +128,12 @@ class _EventScreenState extends State final imagePath = listSnap.data[index - 1]; bloc.fetchThumbnail(imagePath); - return AsyncThumbnail( - cacheStream: bloc.thumbnails, - cacheId: getImageThumbnailPath(imagePath), + return GestureDetector( + onTap: () => openGallery(imageIndex: index - 1), + child: AsyncThumbnail( + cacheStream: bloc.thumbnails, + cacheId: getImageThumbnailPath(imagePath), + ), ); }, gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( @@ -143,9 +146,12 @@ class _EventScreenState extends State ); } + // TODO: Change the navigation call whent the new package is ready. Widget buildAddPictureButton() { return GradientTouchableContainer( radius: 8, + onTap: () => + Navigator.of(context).pushNamed('newImage/${bloc.eventName}'), child: Icon( Icons.camera_alt, color: Colors.white, @@ -154,6 +160,22 @@ class _EventScreenState extends State ); } + /// Pushes a new screen that shows the pictures in full size. + void openGallery({int imageIndex}) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return GalleryScreen( + fetchImage: bloc.fetchImage, + cacheStream: bloc.images, + pathsStream: bloc.imagesPaths, + initialScreen: imageIndex, + ); + }, + ), + ); + } + void dispose() { bloc.dispose(); super.dispose(); diff --git a/lib/src/screens/gallery_screen.dart b/lib/src/screens/gallery_screen.dart new file mode 100644 index 0000000..222aca6 --- /dev/null +++ b/lib/src/screens/gallery_screen.dart @@ -0,0 +1,169 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:rxdart/rxdart.dart'; + +import '../widgets/async_image.dart'; +import '../widgets/fractionally_screen_sized_box.dart'; +import '../widgets/loading_indicator.dart'; + +/// A screen that shows a series of puctures in full resolution with zoom +/// capability and navigation controls. +class GalleryScreen extends StatelessWidget { + /// An observable that emits a list of all the paths of the images the gallery + /// will show. + final Observable> pathsStream; + + /// An observable that emits a map that caches the files corresponding to the + /// images to be shown. + final Observable>> cacheStream; + + /// A function to be called when an image needs to be fetched. + final Function(String) fetchImage; + + /// The initial screen (picture) to be shown. + /// + /// If provided, the gallery will automatically show this image upon opening. + final int initialScreen; + + /// A controller for the underlaying [PageView]. + final PageController _controller; + + /// Creates a screen that shows a series of puctures in full resolution with zoom + /// capability and navigation controls + /// + /// [patshStream], [cacheStream] and [fetchImage] must not be null. + GalleryScreen({ + @required this.pathsStream, + @required this.cacheStream, + this.initialScreen = 0, + @required this.fetchImage, + }) : assert(pathsStream != null), + assert(cacheStream != null), + assert(fetchImage != null), + _controller = PageController( + initialPage: initialScreen, + keepPage: false, + ); + + Widget build(BuildContext context) { + return Scaffold( + body: Stack( + children: [ + StreamBuilder( + stream: pathsStream, + builder: + (BuildContext context, AsyncSnapshot> listSnap) { + // Wait until the images paths have been fetched. + if (!listSnap.hasData) { + return buildImagePlaceholder(); + } + + return PageView.builder( + // Do not allow page changes on swipe. User manual controls, + // the [PageView] gestures interfere with zooming otherwhise. + physics: new NeverScrollableScrollPhysics(), + controller: _controller, + itemCount: listSnap.data.length, + itemBuilder: (BuildContext context, int index) { + final imagePath = listSnap.data[index]; + fetchImage(imagePath); + + return AsyncImage( + cacheId: imagePath, + cacheMap: cacheStream, + ); + }, + ); + }, + ), + buildCloseButton(context), + buildPageNavigation(), + ], + ), + ); + } + + /// Jumps to the specified page. + void animateToPage(Page page) { + final curve = Curves.easeInOut; + final duration = Duration(milliseconds: 300); + switch (page) { + case Page.previous: + _controller.previousPage( + curve: curve, + duration: duration, + ); + break; + case Page.next: + _controller.nextPage( + curve: curve, + duration: duration, + ); + } + } + + /// Builds the button that closes this screen. + Widget buildCloseButton(BuildContext context) { + return Positioned( + left: 20, + top: 60, + child: IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: Icon( + Icons.close, + size: 40, + ), + ), + ); + } + + /// Builds the navigation controls. + /// + /// Two icon buttons allow the user to navigate to the next or previous page. + Widget buildPageNavigation() { + return Positioned( + bottom: 50, + child: FractionallyScreenSizedBox( + widthFactor: 1, + child: Row( + children: [ + Spacer(), + IconButton( + onPressed: () => animateToPage(Page.previous), + icon: Icon( + FontAwesomeIcons.caretLeft, + size: 40, + ), + ), + Spacer( + flex: 8, + ), + IconButton( + onPressed: () => animateToPage(Page.next), + icon: Icon( + FontAwesomeIcons.caretRight, + size: 40, + ), + ), + Spacer(), + ], + ), + ), + ); + } + + /// Builds a placeholder for an image. + Widget buildImagePlaceholder() { + return Container( + color: Colors.black, + child: Center( + child: LoadingIndicator(), + ), + ); + } +} + +enum Page { previous, next } diff --git a/lib/src/screens/new_image_screen.dart b/lib/src/screens/new_image_screen.dart index 7f9c678..c8c3633 100644 --- a/lib/src/screens/new_image_screen.dart +++ b/lib/src/screens/new_image_screen.dart @@ -12,13 +12,31 @@ import '../widgets/custom_dropdown.dart'; import '../widgets/fractionally_screen_sized_box.dart'; import '../widgets/gradient_touchable_container.dart'; +/// A screen that prompts the user for an image and uploads it to +/// firebase storage. class NewImageScreen extends StatefulWidget { + /// The name of the event to be preselected in the dropdown menu. + final String defaultEventName; + + /// Creates a new screen that prompts the user for an image and uploads it to + /// firevase storage. + NewImageScreen({ + this.defaultEventName, + }); + _NewImageScreenState createState() => _NewImageScreenState(); } class _NewImageScreenState extends State { /// An instance of the bloc for this scree. - final NewImageBloc bloc = NewImageBloc(); + NewImageBloc bloc; + + initState() { + bloc = NewImageBloc( + defaultEventName: widget.defaultEventName, + ); + super.initState(); + } Widget build(BuildContext context) { return Scaffold( diff --git a/lib/src/widgets/async_image.dart b/lib/src/widgets/async_image.dart new file mode 100644 index 0000000..663466f --- /dev/null +++ b/lib/src/widgets/async_image.dart @@ -0,0 +1,66 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:photo_view/photo_view.dart'; +import 'package:rxdart/rxdart.dart'; + +import './loading_indicator.dart'; + +/// A widget that shows an image given a cache map and its cache id. +/// +/// A placeholder is shown while it loads. +class AsyncImage extends StatelessWidget { + /// The id of the image inside the [cacheMap]. + final String cacheId; + + /// A cache that maps an image path to its file. + final Observable>> cacheMap; + + /// Creates a widget that shows an image given a cache map and its cache id. + /// + /// A placeholder is shown while the image loads. + AsyncImage({ + @required this.cacheId, + @required this.cacheMap, + }) : assert(cacheId != null), + assert(cacheMap != null); + + Widget build(BuildContext context) { + return StreamBuilder( + stream: cacheMap, + builder: (BuildContext context, + AsyncSnapshot>> imagesCacheSnap) { + // Wait until the images cache has data. + if (!imagesCacheSnap.hasData) { + return buildImagePlaceholder(); + } + + return FutureBuilder( + future: imagesCacheSnap.data[cacheId], + builder: (BuildContext context, AsyncSnapshot imageFileSnap) { + // Wait until the future of the file for this image resolves. + if (!imageFileSnap.hasData) { + return buildImagePlaceholder(); + } + + return PhotoView( + imageProvider: FileImage(imageFileSnap.data), + minScale: .2, + maxScale: 2.0, + ); + }, + ); + }, + ); + } + + Widget buildImagePlaceholder() { + return Container( + color: Colors.black, + child: Center( + child: LoadingIndicator(), + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 84753af..027268e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -32,6 +32,7 @@ dependencies: image_picker: ^0.5.0+3 mockito: ^4.0.0 path_provider: ^0.5.0+1 + photo_view: 0.2.3 rxdart: ^0.20.0 sqflite: ^1.1.0 uuid: ^2.0.0