diff --git a/lib/album_sharing_service.dart b/lib/album_sharing_service.dart new file mode 100644 index 000000000..6eec05eba --- /dev/null +++ b/lib/album_sharing_service.dart @@ -0,0 +1,46 @@ +import 'package:dio/dio.dart'; +import 'package:photos/core/configuration.dart'; + +class AlbumSharingService { + final _dio = Dio(); + + AlbumSharingService._privateConstructor(); + static final AlbumSharingService instance = + AlbumSharingService._privateConstructor(); + + Future> getSharingStatus(String path) async { + // TODO fetch folderID from path + var folderID = 0; + return _dio + .get( + Configuration.instance.getHttpEndpoint() + "/users", + options: + Options(headers: {"X-Auth-Token": Configuration.instance.getToken()}), + ) + .then((usersResponse) { + return _dio + .get( + Configuration.instance.getHttpEndpoint() + + "/folders/" + + folderID.toString(), + options: Options( + headers: {"X-Auth-Token": Configuration.instance.getToken()}), + ) + .then((sharedFoldersResponse) { + final sharedUsers = + (sharedFoldersResponse.data["sharedWith"] as List).toSet(); + final result = Map(); + (usersResponse.data as List).forEach((user) { + if (user != Configuration.instance.getUsername()) { + result[user] = sharedUsers.contains(user); + } + }); + return result; + }); + }); + } + + void shareAlbum( + String path, + ) {} +} diff --git a/lib/db/db_helper.dart b/lib/db/db_helper.dart index 4c7be2226..7efb2b0b5 100644 --- a/lib/db/db_helper.dart +++ b/lib/db/db_helper.dart @@ -15,7 +15,7 @@ class DatabaseHelper { static final columnUploadedFileId = 'uploaded_file_id'; static final columnLocalId = 'local_id'; static final columnTitle = 'title'; - static final columnPathName = 'path_name'; + static final columnDeviceFolder = 'device_folder'; static final columnRemotePath = 'remote_path'; static final columnIsDeleted = 'is_deleted'; static final columnCreateTimestamp = 'create_timestamp'; @@ -50,7 +50,7 @@ class DatabaseHelper { $columnLocalId TEXT, $columnUploadedFileId INTEGER NOT NULL, $columnTitle TEXT NOT NULL, - $columnPathName TEXT NOT NULL, + $columnDeviceFolder TEXT NOT NULL, $columnRemotePath TEXT, $columnIsDeleted INTEGER DEFAULT 0, $columnCreateTimestamp TEXT NOT NULL, @@ -161,12 +161,12 @@ class DatabaseHelper { final db = await instance.database; final rows = await db.query( table, - columns: [columnPathName], + columns: [columnDeviceFolder], distinct: true, ); List result = List(); for (final row in rows) { - result.add(row[columnPathName]); + result.add(row[columnDeviceFolder]); } return result; } @@ -175,7 +175,7 @@ class DatabaseHelper { final db = await instance.database; var rows = await db.query( table, - where: '$columnPathName =?', + where: '$columnDeviceFolder =?', whereArgs: [path], orderBy: '$columnCreateTimestamp DESC', limit: 1, @@ -217,7 +217,7 @@ class DatabaseHelper { row[columnUploadedFileId] = photo.uploadedFileId == null ? -1 : photo.uploadedFileId; row[columnTitle] = photo.title; - row[columnPathName] = photo.pathName; + row[columnDeviceFolder] = photo.deviceFolder; row[columnRemotePath] = photo.remotePath; row[columnCreateTimestamp] = photo.createTimestamp; row[columnSyncTimestamp] = photo.syncTimestamp; @@ -230,7 +230,7 @@ class DatabaseHelper { photo.localId = row[columnLocalId]; photo.uploadedFileId = row[columnUploadedFileId]; photo.title = row[columnTitle]; - photo.pathName = row[columnPathName]; + photo.deviceFolder = row[columnDeviceFolder]; photo.remotePath = row[columnRemotePath]; photo.createTimestamp = int.parse(row[columnCreateTimestamp]); photo.syncTimestamp = row[columnSyncTimestamp] == null diff --git a/lib/main.dart b/lib/main.dart index 7e8eb7e14..9f4f2a6d7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,7 +15,7 @@ final logger = Logger("main"); void main() async { WidgetsFlutterBinding.ensureInitialized(); - SuperLogging.main(LogConfig( + await SuperLogging.main(LogConfig( body: _main, sentryDsn: SENTRY_DSN, logDirPath: (await getTemporaryDirectory()).path + "/logs", diff --git a/lib/models/filters/folder_name_filter.dart b/lib/models/filters/folder_name_filter.dart index e8f2cfda8..e2475dedc 100644 --- a/lib/models/filters/folder_name_filter.dart +++ b/lib/models/filters/folder_name_filter.dart @@ -9,6 +9,6 @@ class FolderNameFilter implements GalleryItemsFilter { @override bool shouldInclude(Photo photo) { - return path.basename(photo.pathName) == folderName; + return path.basename(photo.deviceFolder) == folderName; } } diff --git a/lib/models/filters/important_items_filter.dart b/lib/models/filters/important_items_filter.dart index a19aa4e26..ca1eeb9d0 100644 --- a/lib/models/filters/important_items_filter.dart +++ b/lib/models/filters/important_items_filter.dart @@ -8,7 +8,7 @@ class ImportantItemsFilter implements GalleryItemsFilter { @override bool shouldInclude(Photo photo) { if (Platform.isAndroid) { - final String folder = basename(photo.pathName); + final String folder = basename(photo.deviceFolder); return folder == "Camera" || folder == "DCIM" || folder == "Download" || diff --git a/lib/models/folder.dart b/lib/models/folder.dart new file mode 100644 index 000000000..527034cf7 --- /dev/null +++ b/lib/models/folder.dart @@ -0,0 +1,11 @@ +class Folder { + final int folderID; + final String name; + final String owner; + final String deviceFolder; + final List sharedWith; + final int updateTimestamp; + + Folder(this.folderID, this.name, this.owner, this.deviceFolder, + this.sharedWith, this.updateTimestamp); +} diff --git a/lib/models/photo.dart b/lib/models/photo.dart index e123c0cae..e2e80a081 100644 --- a/lib/models/photo.dart +++ b/lib/models/photo.dart @@ -10,7 +10,7 @@ class Photo { int uploadedFileId; String localId; String title; - String pathName; + String deviceFolder; String remotePath; int createTimestamp; int syncTimestamp; @@ -18,8 +18,9 @@ class Photo { Photo(); Photo.fromJson(Map json) : uploadedFileId = json["fileId"], - remotePath = json["path"], title = json["title"], + deviceFolder = json["deviceFolder"], + remotePath = json["path"], createTimestamp = json["createTimestamp"], syncTimestamp = json["syncTimestamp"]; @@ -29,7 +30,7 @@ class Photo { photo.uploadedFileId = -1; photo.localId = asset.id; photo.title = asset.title; - photo.pathName = pathEntity.name; + photo.deviceFolder = pathEntity.name; photo.createTimestamp = asset.createDateTime.microsecondsSinceEpoch; if (photo.createTimestamp == 0) { try { @@ -67,7 +68,7 @@ class Photo { @override String toString() { - return 'Photo(generatedId: $generatedId, uploadedFileId: $uploadedFileId, localId: $localId, title: $title, pathName: $pathName, remotePath: $remotePath, createTimestamp: $createTimestamp, syncTimestamp: $syncTimestamp)'; + return 'Photo(generatedId: $generatedId, uploadedFileId: $uploadedFileId, localId: $localId, title: $title, deviceFolder: $deviceFolder, remotePath: $remotePath, createTimestamp: $createTimestamp, syncTimestamp: $syncTimestamp)'; } @override @@ -79,7 +80,7 @@ class Photo { o.uploadedFileId == uploadedFileId && o.localId == localId && o.title == title && - o.pathName == pathName && + o.deviceFolder == deviceFolder && o.remotePath == remotePath && o.createTimestamp == createTimestamp && o.syncTimestamp == syncTimestamp; @@ -91,7 +92,7 @@ class Photo { uploadedFileId.hashCode ^ localId.hashCode ^ title.hashCode ^ - pathName.hashCode ^ + deviceFolder.hashCode ^ remotePath.hashCode ^ createTimestamp.hashCode ^ syncTimestamp.hashCode; diff --git a/lib/photo_sync_manager.dart b/lib/photo_sync_manager.dart index 07949e0d7..39182690a 100644 --- a/lib/photo_sync_manager.dart +++ b/lib/photo_sync_manager.dart @@ -8,7 +8,6 @@ import 'package:photos/db/db_helper.dart'; import 'package:photos/events/user_authenticated_event.dart'; import 'package:photos/photo_repository.dart'; import 'package:photos/photo_provider.dart'; -import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; import 'package:photo_manager/photo_manager.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -110,15 +109,13 @@ class PhotoSyncManager { await _getDiff(lastSyncTimestamp, _diffLimit).then((diff) async { if (diff != null) { - await _downloadDiff(diff, prefs).then((_) { - if (diff.length > 0) { - _syncPhotos(); - } + await _storeDiff(diff, prefs).then((_) { + // TODO: Recursively store diff + _uploadDiff(prefs); }); } }); - _uploadDiff(prefs); // TODO: Fix race conditions triggered due to concurrent syncs. // Add device_id/last_sync_timestamp to the upload request? } @@ -127,32 +124,26 @@ class PhotoSyncManager { List photosToBeUploaded = await DatabaseHelper.instance.getPhotosToBeUploaded(); for (Photo photo in photosToBeUploaded) { - var uploadedPhoto = await _uploadFile(photo); - if (uploadedPhoto == null) { - return; + try { + var uploadedPhoto = await _uploadFile(photo); + if (uploadedPhoto == null) { + return; + } + await DatabaseHelper.instance.updatePhoto(photo.generatedId, + uploadedPhoto.remotePath, uploadedPhoto.syncTimestamp); + prefs.setInt(_lastSyncTimestampKey, uploadedPhoto.syncTimestamp); + } catch (e) { + _logger.severe(e); } - await DatabaseHelper.instance.updatePhoto(photo.generatedId, - uploadedPhoto.remotePath, uploadedPhoto.syncTimestamp); - prefs.setInt(_lastSyncTimestampKey, uploadedPhoto.syncTimestamp); } } - Future _downloadDiff(List diff, SharedPreferences prefs) async { - var externalPath = (await getApplicationDocumentsDirectory()).path; - var path = externalPath + "/photos/"; + Future _storeDiff(List diff, SharedPreferences prefs) async { for (Photo photo in diff) { - var localPath = path + basename(photo.remotePath); - await _dio - .download( - Configuration.instance.getHttpEndpoint() + "/" + photo.remotePath, - localPath) - .catchError((e) => _logger.severe(e)); - // TODO: Save path - photo.pathName = localPath; await DatabaseHelper.instance.insertPhoto(photo); - PhotoRepository.instance.reloadPhotos(); await prefs.setInt(_lastSyncTimestampKey, photo.syncTimestamp); } + PhotoRepository.instance.reloadPhotos(); } Future> _getDiff(int lastSyncTimestamp, int limit) async { @@ -180,19 +171,22 @@ class PhotoSyncManager { var formData = FormData.fromMap({ "file": MultipartFile.fromBytes((await localPhoto.getOriginalBytes()), filename: localPhoto.title), + "deviceFolder": localPhoto.deviceFolder, "title": localPhoto.title, "createTimestamp": localPhoto.createTimestamp, - "token": Configuration.instance.getToken(), }); return _dio .post( - Configuration.instance.getHttpEndpoint() + "/files", - options: Options( - headers: {"X-Auth-Token": Configuration.instance.getToken()}), - data: formData, - ) - .then((response) => Photo.fromJson(response.data)) - .catchError((e) => _logger.severe(e)); + Configuration.instance.getHttpEndpoint() + "/files", + options: + Options(headers: {"X-Auth-Token": Configuration.instance.getToken()}), + data: formData, + ) + .then((response) { + return Photo.fromJson(response.data); + }).catchError((e) { + _logger.severe("Error in uploading ", e); + }); } Future _deletePhotos() async { diff --git a/lib/ui/album_page.dart b/lib/ui/album_page.dart index ddd95ffe5..7732783ff 100644 --- a/lib/ui/album_page.dart +++ b/lib/ui/album_page.dart @@ -37,6 +37,7 @@ class _AlbumPageState extends State { return Scaffold( appBar: GalleryAppBarWidget( widget.album.name, + widget.album.thumbnailPhoto.deviceFolder, _selectedPhotos, onSelectionClear: () { setState(() { diff --git a/lib/ui/gallery_app_bar_widget.dart b/lib/ui/gallery_app_bar_widget.dart index 0e557bc1d..da8410a06 100644 --- a/lib/ui/gallery_app_bar_widget.dart +++ b/lib/ui/gallery_app_bar_widget.dart @@ -1,5 +1,4 @@ import 'dart:async'; - import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:photos/core/event_bus.dart'; @@ -9,15 +8,18 @@ import 'package:photos/models/photo.dart'; import 'package:photos/photo_repository.dart'; import 'package:photos/ui/setup_page.dart'; import 'package:photo_manager/photo_manager.dart'; +import 'package:photos/ui/share_album_widget.dart'; import 'package:photos/utils/share_util.dart'; class GalleryAppBarWidget extends StatefulWidget implements PreferredSizeWidget { final String title; + final String path; final Set selectedPhotos; final Function() onSelectionClear; - GalleryAppBarWidget(this.title, this.selectedPhotos, {this.onSelectionClear}); + GalleryAppBarWidget(this.title, this.path, this.selectedPhotos, + {this.onSelectionClear}); @override _GalleryAppBarWidgetState createState() => _GalleryAppBarWidgetState(); @@ -70,10 +72,26 @@ class _GalleryAppBarWidgetState extends State { _openSyncConfiguration(context); }, )); + } else { + actions.add(IconButton( + icon: Icon(Icons.person_add), + onPressed: () { + _showShareAlbumDialog(); + }, + )); } return actions; } + Future _showShareAlbumDialog() async { + return showDialog( + context: context, + builder: (BuildContext context) { + return ShareAlbumWidget(widget.title, widget.path); + }, + ); + } + List _getPhotoActions(BuildContext context) { List actions = List(); if (widget.selectedPhotos.isNotEmpty) { diff --git a/lib/ui/home_widget.dart b/lib/ui/home_widget.dart index 5b4e38bea..55de7394e 100644 --- a/lib/ui/home_widget.dart +++ b/lib/ui/home_widget.dart @@ -53,6 +53,7 @@ class _HomeWidgetState extends State { return Scaffold( appBar: GalleryAppBarWidget( widget.title, + "/", _selectedPhotos, onSelectionClear: _clearSelectedPhotos, ), diff --git a/lib/ui/share_album_widget.dart b/lib/ui/share_album_widget.dart new file mode 100644 index 000000000..1d2e48faa --- /dev/null +++ b/lib/ui/share_album_widget.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:photos/album_sharing_service.dart'; +import 'package:photos/ui/loading_widget.dart'; + +class ShareAlbumWidget extends StatefulWidget { + final String title; + final String path; + + const ShareAlbumWidget( + this.title, + this.path, { + Key key, + }) : super(key: key); + + @override + _ShareAlbumWidgetState createState() => _ShareAlbumWidgetState(); +} + +class _ShareAlbumWidgetState extends State { + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: AlbumSharingService.instance.getSharingStatus(widget.path), + builder: (context, snapshot) { + if (snapshot.hasData) { + return _getSharingDialog(snapshot.data); + } else if (snapshot.hasError) { + return Text(snapshot.error.toString()); + } else { + return loadWidget; + } + }, + ); + } + + Widget _getSharingDialog(Map sharingStatus) { + return AlertDialog( + title: Text('Share "' + widget.title + '" with'), + content: SingleChildScrollView( + child: ListBody( + children: [ + SharingCheckboxWidget(sharingStatus), + ], + ), + ), + actions: [ + FlatButton( + child: Text("Share"), + onPressed: () { + // TODO: AlbumSharingService.instance.shareAlbum(); + Navigator.of(context).pop(); + }, + ), + ], + ); + } +} + +class SharingCheckboxWidget extends StatefulWidget { + final Map sharingStatus; + + const SharingCheckboxWidget( + this.sharingStatus, { + Key key, + }) : super(key: key); + + @override + _SharingCheckboxWidgetState createState() => _SharingCheckboxWidgetState(); +} + +class _SharingCheckboxWidgetState extends State { + Map _sharingStatus; + + @override + void initState() { + _sharingStatus = widget.sharingStatus; + super.initState(); + } + + @override + Widget build(BuildContext context) { + final checkboxes = List(); + for (final user in _sharingStatus.keys) { + checkboxes.add(Row( + children: [ + Checkbox( + materialTapTargetSize: MaterialTapTargetSize.padded, + value: _sharingStatus[user], + onChanged: (value) { + setState(() { + _sharingStatus[user] = value; + }); + }), + Text(user), + ], + )); + } + return Column(children: checkboxes); + } +} diff --git a/lib/ui/sign_in_widget.dart b/lib/ui/sign_in_widget.dart index f5bf68d96..f68e7bb8e 100644 --- a/lib/ui/sign_in_widget.dart +++ b/lib/ui/sign_in_widget.dart @@ -16,17 +16,17 @@ class SignInWidget extends StatefulWidget { _SignInWidgetState createState() => _SignInWidgetState(); } +enum Mode { sign_up, sign_in, unknown } + class _SignInWidgetState extends State { - String _username, _password, _repeatedPassword; - @override - void initState() { - super.initState(); - } + Mode mode = Mode.unknown; + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + final _repeatPasswordController = TextEditingController(); @override Widget build(BuildContext context) { - if (Configuration.instance.getToken() == null) { - // Has probably not signed up + if (mode == Mode.sign_up) { return _getSignUpWidget(context); } else { return _getSignInWidget(context); @@ -35,120 +35,121 @@ class _SignInWidgetState extends State { Widget _getSignUpWidget(BuildContext context) { return Container( - child: Column( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(0, 16, 0, 0), - child: Text("Create an account to get started"), - ), - TextFormField( - decoration: InputDecoration( - hintText: 'username', - contentPadding: EdgeInsets.all(20), + child: SingleChildScrollView( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(0, 16, 0, 0), + child: Text("Create an account to get started"), ), - autofocus: true, - autocorrect: false, - onChanged: (value) { - setState(() { - _username = value; - }); - }, - ), - TextFormField( - decoration: InputDecoration( - hintText: 'password', - contentPadding: EdgeInsets.all(20), + TextFormField( + decoration: InputDecoration( + hintText: 'username', + contentPadding: EdgeInsets.all(20), + ), + controller: _usernameController, + autofocus: true, + autocorrect: false, ), - autocorrect: false, - obscureText: true, - onChanged: (value) { - setState(() { - _password = value; - }); - }, - ), - TextFormField( - decoration: InputDecoration( - hintText: 'repeat password', - contentPadding: EdgeInsets.all(20), + TextFormField( + decoration: InputDecoration( + hintText: 'password', + contentPadding: EdgeInsets.all(20), + ), + autocorrect: false, + obscureText: true, + controller: _passwordController, ), - autocorrect: false, - obscureText: true, - onChanged: (value) { - setState(() { - _repeatedPassword = value; - }); - }, - ), - CupertinoButton( - child: Text("Sign Up"), - onPressed: () async { - if (_password != _repeatedPassword) { - _showPasswordMismatchDialog(); - } else { - try { - final userCreated = await UserAuthenticator.instance - .create(_username, _password); - if (userCreated) { - Navigator.of(context).pop(); - } else { - _showGenericErrorDialog(); + TextFormField( + decoration: InputDecoration( + hintText: 'repeat password', + contentPadding: EdgeInsets.all(20), + ), + autocorrect: false, + obscureText: true, + controller: _repeatPasswordController, + ), + CupertinoButton( + child: Text("Sign Up"), + onPressed: () async { + if (_passwordController.text != _repeatPasswordController.text) { + _showPasswordMismatchDialog(); + } else { + try { + final userCreated = await UserAuthenticator.instance.create( + _usernameController.text, _passwordController.text); + if (userCreated) { + Navigator.of(context).pop(); + } else { + _showGenericErrorDialog(); + } + } catch (e) { + _showGenericErrorDialog(error: e); } - } catch (e) { - _showGenericErrorDialog(error: e); } - } - }, - ), - ], + }, + ), + CupertinoButton( + child: Text("Have an account?"), + onPressed: () { + setState(() { + mode = Mode.sign_in; + }); + }, + ), + ], + ), )); } Widget _getSignInWidget(BuildContext context) { return Container( - child: Column( - children: [ - TextFormField( - initialValue: Configuration.instance.getUsername(), - decoration: InputDecoration( - hintText: 'username', - contentPadding: EdgeInsets.all(20), + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextFormField( + // initialValue: Configuration.instance.getUsername(), + decoration: InputDecoration( + hintText: 'username', + contentPadding: EdgeInsets.all(20), + ), + autofocus: true, + autocorrect: false, + controller: _usernameController, ), - autofocus: true, - autocorrect: false, - onChanged: (value) { - setState(() { - _username = value; - }); - }, - ), - TextFormField( - initialValue: Configuration.instance.getPassword(), - decoration: InputDecoration( - hintText: 'password', - contentPadding: EdgeInsets.all(20), + TextFormField( + // initialValue: Configuration.instance.getPassword(), + decoration: InputDecoration( + hintText: 'password', + contentPadding: EdgeInsets.all(20), + ), + autocorrect: false, + obscureText: true, + controller: _passwordController, ), - autocorrect: false, - obscureText: true, - onChanged: (value) { - setState(() { - _password = value; - }); - }, - ), - CupertinoButton( - child: Text("Sign In"), - onPressed: () async { - final loggedIn = - await UserAuthenticator.instance.login(_username, _password); - if (loggedIn) { - Navigator.of(context).pop(); - } else { - _showAuthenticationFailedErrorDialog(); - } - }, - ), - ], + CupertinoButton( + child: Text("Sign In"), + onPressed: () async { + final loggedIn = await UserAuthenticator.instance + .login(_usernameController.text, _passwordController.text); + if (loggedIn) { + Navigator.of(context).pop(); + } else { + _showAuthenticationFailedErrorDialog(); + } + }, + ), + CupertinoButton( + child: Text("Don't have an account?"), + onPressed: () { + setState(() { + mode = Mode.sign_up; + }); + }, + ), + ], + ), )); } diff --git a/lib/user_authenticator.dart b/lib/user_authenticator.dart index 0549c67e4..e2fabc9a8 100644 --- a/lib/user_authenticator.dart +++ b/lib/user_authenticator.dart @@ -16,10 +16,11 @@ class UserAuthenticator { Future login(String username, String password) { return _dio.post( - "http://" + - Configuration.instance.getEndpoint() + - ":8080/users/authenticate", - data: {"username": username, "password": password}).then((response) { + Configuration.instance.getHttpEndpoint() + "/users/authenticate", + data: { + "username": username, + "password": password, + }).then((response) { if (response.statusCode == 200 && response.data != null) { Configuration.instance.setUsername(username); Configuration.instance.setPassword(password); @@ -36,9 +37,11 @@ class UserAuthenticator { } Future create(String username, String password) { - return _dio.post( - "http://" + Configuration.instance.getEndpoint() + ":8080/users", - data: {"username": username, "password": password}).then((response) { + return _dio + .post(Configuration.instance.getHttpEndpoint() + "/users", data: { + "username": username, + "password": password, + }).then((response) { if (response.statusCode == 200 && response.data != null) { Configuration.instance.setUsername(username); Configuration.instance.setPassword(password); diff --git a/pubspec.lock b/pubspec.lock index f9858c28b..bc406eefa 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -213,7 +213,7 @@ packages: source: hosted version: "1.0.0" logging: - dependency: transitive + dependency: "direct main" description: name: logging url: "https://pub.dartlang.org" diff --git a/pubspec.yaml b/pubspec.yaml index 0ed63c314..21b89da8a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -46,6 +46,7 @@ dependencies: archive: ^2.0.11 flutter_email_sender: ^3.0.1 like_button: ^0.2.0 + logging: ^0.11.4 dev_dependencies: flutter_test: