Finished the media view and the gallery for the event screen
This commit is contained in:
parent
acc0b54cfb
commit
dd57c85972
9 changed files with 337 additions and 14 deletions
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
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<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();
|
||||
|
|
|
|||
169
lib/src/screens/gallery_screen.dart
Normal file
169
lib/src/screens/gallery_screen.dart
Normal 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 }
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
66
lib/src/widgets/async_image.dart
Normal file
66
lib/src/widgets/async_image.dart
Normal 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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue