Finished the media view and the gallery for the event screen

This commit is contained in:
Mariano Uvalle 2019-04-09 07:04:49 -05:00
parent acc0b54cfb
commit dd57c85972
9 changed files with 337 additions and 14 deletions

View file

@ -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') {

View file

@ -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;
}

View file

@ -29,7 +29,13 @@ class NewImageBloc {
/// A subject of the current media event name.
final _eventName = BehaviorSubject<String>();
NewImageBloc() {
/// Default event name.
final String defaultEventName;
NewImageBloc({this.defaultEventName}) {
if (defaultEventName != null && defaultEventName != '') {
_eventName.sink.add(defaultEventName);
}
setCurrentUser();
}

View file

@ -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<File> 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<File> 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);
}
}

View file

@ -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<EventScreen>
final imagePath = listSnap.data[index - 1];
bloc.fetchThumbnail(imagePath);
return AsyncThumbnail(
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<EventScreen>
);
}
// 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<EventScreen>
);
}
/// 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();

View file

@ -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<List<String>> pathsStream;
/// An observable that emits a map that caches the files corresponding to the
/// images to be shown.
final Observable<Map<String, Future<File>>> 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: <Widget>[
StreamBuilder(
stream: pathsStream,
builder:
(BuildContext context, AsyncSnapshot<List<String>> 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: <Widget>[
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 }

View file

@ -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<NewImageScreen> {
/// 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(

View file

@ -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<Map<String, Future<File>>> 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<Map<String, Future<File>>> imagesCacheSnap) {
// Wait until the images cache has data.
if (!imagesCacheSnap.hasData) {
return buildImagePlaceholder();
}
return FutureBuilder(
future: imagesCacheSnap.data[cacheId],
builder: (BuildContext context, AsyncSnapshot<File> 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(),
),
);
}
}

View file

@ -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