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(
|
theme: ThemeData(
|
||||||
// Accent color is set to be used by the floating action button.
|
// Accent color is set to be used by the floating action button.
|
||||||
accentColor: Color(0xFF707070),
|
accentColor: Color(0xFF707070),
|
||||||
|
iconTheme: IconThemeData(
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
canvasColor: Color.fromRGBO(23, 25, 29, 1.0),
|
canvasColor: Color.fromRGBO(23, 25, 29, 1.0),
|
||||||
cardColor: Color.fromRGBO(36, 39, 44, 1.0),
|
cardColor: Color.fromRGBO(36, 39, 44, 1.0),
|
||||||
cursorColor: Color.fromRGBO(112, 112, 112, 1),
|
cursorColor: Color.fromRGBO(112, 112, 112, 1),
|
||||||
|
|
@ -55,9 +58,15 @@ class App extends StatelessWidget {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else if (routeTokens.first == 'newImage') {
|
} else if (routeTokens.first == 'newImage') {
|
||||||
|
String eventName;
|
||||||
|
if (routeTokens.length > 1) {
|
||||||
|
eventName = routeTokens[1];
|
||||||
|
}
|
||||||
return MaterialPageRoute(
|
return MaterialPageRoute(
|
||||||
builder: (BuildContext context) {
|
builder: (BuildContext context) {
|
||||||
return NewImageScreen();
|
return NewImageScreen(
|
||||||
|
defaultEventName: eventName,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else if (routeTokens.first == 'event') {
|
} else if (routeTokens.first == 'event') {
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,7 @@ class EventBloc {
|
||||||
if (isThumbnail) {
|
if (isThumbnail) {
|
||||||
path = getImageThumbnailPath(path);
|
path = getImageThumbnailPath(path);
|
||||||
}
|
}
|
||||||
|
// Do not re-fetch the image if it resolved already.
|
||||||
if (cache.containsKey(path)) {
|
if (cache.containsKey(path)) {
|
||||||
return cache;
|
return cache;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,13 @@ class NewImageBloc {
|
||||||
/// A subject of the current media event name.
|
/// A subject of the current media event name.
|
||||||
final _eventName = BehaviorSubject<String>();
|
final _eventName = BehaviorSubject<String>();
|
||||||
|
|
||||||
NewImageBloc() {
|
/// Default event name.
|
||||||
|
final String defaultEventName;
|
||||||
|
|
||||||
|
NewImageBloc({this.defaultEventName}) {
|
||||||
|
if (defaultEventName != null && defaultEventName != '') {
|
||||||
|
_eventName.sink.add(defaultEventName);
|
||||||
|
}
|
||||||
setCurrentUser();
|
setCurrentUser();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:firebase_storage/firebase_storage.dart';
|
import 'package:firebase_storage/firebase_storage.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
@ -47,17 +48,47 @@ class FirebaseStorageProvider {
|
||||||
|
|
||||||
// TODO: Add tests for this method.
|
// TODO: Add tests for this method.
|
||||||
// TODO: Delete the tmp file when the app closes.
|
// 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.
|
/// Returns a future of the file from the path.
|
||||||
///
|
///
|
||||||
/// Downloads the raw bytes from the sotrage buckets and converts it to a file.
|
/// Downloads the raw bytes from the sotrage buckets and converts it to a file.
|
||||||
Future<File> getFile(String path) async {
|
/// The fetching process is repeated up to [retries] times with a [retryDelay]
|
||||||
final fileName = path.split('/').last;
|
/// delay in between tries.
|
||||||
final fileReference = _storage.child(path);
|
Future<File> getFile(
|
||||||
final metadata = await fileReference.getMetadata();
|
String path, {
|
||||||
final bytes = await fileReference.getData(metadata.sizeBytes);
|
int retries = 3,
|
||||||
|
Duration retryDelay = const Duration(seconds: 2),
|
||||||
|
}) async {
|
||||||
final Directory tmp = await getTemporaryDirectory();
|
final Directory tmp = await getTemporaryDirectory();
|
||||||
|
final fileName = path.split('/').last;
|
||||||
final file = File('${tmp.path}/$fileName');
|
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);
|
return file.writeAsBytes(bytes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../utils.dart' show getImageThumbnailPath;
|
import '../utils.dart' show getImageThumbnailPath;
|
||||||
import '../blocs/event_bloc.dart';
|
import '../blocs/event_bloc.dart';
|
||||||
|
import '../screens/gallery_screen.dart';
|
||||||
import '../models/task_model.dart';
|
import '../models/task_model.dart';
|
||||||
|
import '../widgets/async_thumbnail.dart';
|
||||||
import '../widgets/custom_app_bar.dart';
|
import '../widgets/custom_app_bar.dart';
|
||||||
import '../widgets/gradient_touchable_container.dart';
|
import '../widgets/gradient_touchable_container.dart';
|
||||||
import '../widgets/loading_indicator.dart';
|
import '../widgets/loading_indicator.dart';
|
||||||
import '../widgets/task_list_tile.dart';
|
import '../widgets/task_list_tile.dart';
|
||||||
import '../widgets/async_thumbnail.dart';
|
|
||||||
|
|
||||||
/// A screen that shows all the items linked to an event.
|
/// A screen that shows all the items linked to an event.
|
||||||
class EventScreen extends StatefulWidget {
|
class EventScreen extends StatefulWidget {
|
||||||
|
|
@ -128,9 +128,12 @@ class _EventScreenState extends State<EventScreen>
|
||||||
final imagePath = listSnap.data[index - 1];
|
final imagePath = listSnap.data[index - 1];
|
||||||
bloc.fetchThumbnail(imagePath);
|
bloc.fetchThumbnail(imagePath);
|
||||||
|
|
||||||
return AsyncThumbnail(
|
return GestureDetector(
|
||||||
cacheStream: bloc.thumbnails,
|
onTap: () => openGallery(imageIndex: index - 1),
|
||||||
cacheId: getImageThumbnailPath(imagePath),
|
child: AsyncThumbnail(
|
||||||
|
cacheStream: bloc.thumbnails,
|
||||||
|
cacheId: getImageThumbnailPath(imagePath),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
|
@ -143,9 +146,12 @@ class _EventScreenState extends State<EventScreen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Change the navigation call whent the new package is ready.
|
||||||
Widget buildAddPictureButton() {
|
Widget buildAddPictureButton() {
|
||||||
return GradientTouchableContainer(
|
return GradientTouchableContainer(
|
||||||
radius: 8,
|
radius: 8,
|
||||||
|
onTap: () =>
|
||||||
|
Navigator.of(context).pushNamed('newImage/${bloc.eventName}'),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.camera_alt,
|
Icons.camera_alt,
|
||||||
color: Colors.white,
|
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() {
|
void dispose() {
|
||||||
bloc.dispose();
|
bloc.dispose();
|
||||||
super.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/fractionally_screen_sized_box.dart';
|
||||||
import '../widgets/gradient_touchable_container.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 {
|
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();
|
_NewImageScreenState createState() => _NewImageScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _NewImageScreenState extends State<NewImageScreen> {
|
class _NewImageScreenState extends State<NewImageScreen> {
|
||||||
/// An instance of the bloc for this scree.
|
/// 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) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
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
|
image_picker: ^0.5.0+3
|
||||||
mockito: ^4.0.0
|
mockito: ^4.0.0
|
||||||
path_provider: ^0.5.0+1
|
path_provider: ^0.5.0+1
|
||||||
|
photo_view: 0.2.3
|
||||||
rxdart: ^0.20.0
|
rxdart: ^0.20.0
|
||||||
sqflite: ^1.1.0
|
sqflite: ^1.1.0
|
||||||
uuid: ^2.0.0
|
uuid: ^2.0.0
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue