diff --git a/lib/core/configuration.dart b/lib/core/configuration.dart new file mode 100644 index 000000000..b260e8658 --- /dev/null +++ b/lib/core/configuration.dart @@ -0,0 +1,49 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class Configuration { + Configuration._privateConstructor(); + static final Configuration instance = Configuration._privateConstructor(); + + static const endpointKey = "endpoint_7"; + static const tokenKey = "token"; + static const usernameKey = "username"; + static const passwordKey = "password"; + + SharedPreferences preferences; + + Future init() async { + preferences = await SharedPreferences.getInstance(); + } + + String getEndpoint() { + return preferences.getString(endpointKey); + } + + void setEndpoint(String endpoint) async { + await preferences.setString(endpointKey, endpoint); + } + + String getToken() { + return preferences.getString(tokenKey); + } + + void setToken(String token) async { + await preferences.setString(tokenKey, token); + } + + String getUsername() { + return preferences.getString(usernameKey); + } + + void setUsername(String username) async { + await preferences.setString(usernameKey, username); + } + + String getPassword() { + return preferences.getString(passwordKey); + } + + void setPassword(String password) async { + await preferences.setString(passwordKey, password); + } +} diff --git a/lib/core/event_bus.dart b/lib/core/event_bus.dart new file mode 100644 index 000000000..162a10743 --- /dev/null +++ b/lib/core/event_bus.dart @@ -0,0 +1,5 @@ +import 'package:event_bus/event_bus.dart'; + +class Bus { + static final EventBus instance = EventBus(); +} diff --git a/lib/events/remote_sync_event.dart b/lib/events/remote_sync_event.dart new file mode 100644 index 000000000..c89002661 --- /dev/null +++ b/lib/events/remote_sync_event.dart @@ -0,0 +1,5 @@ +class RemoteSyncEvent { + final bool success; + + RemoteSyncEvent(this.success); +} diff --git a/lib/events/user_authenticated_event.dart b/lib/events/user_authenticated_event.dart new file mode 100644 index 000000000..3493c9207 --- /dev/null +++ b/lib/events/user_authenticated_event.dart @@ -0,0 +1 @@ +class UserAuthenticatedEvent {} diff --git a/lib/main.dart b/lib/main.dart index 1319c6e01..f43d5c930 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:logger/logger.dart'; +import 'package:myapp/core/configuration.dart'; import 'package:myapp/photo_loader.dart'; -import 'package:myapp/photo_provider.dart'; import 'package:myapp/photo_sync_manager.dart'; import 'package:myapp/ui/home_widget.dart'; import 'package:provider/provider.dart'; @@ -9,9 +9,8 @@ import 'package:provider/provider.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); runApp(MyApp()); - PhotoProvider.instance - .refreshGalleryList() - .then((_) => PhotoSyncManager.instance.load(PhotoProvider.instance.list)); + Configuration.instance.init(); + PhotoSyncManager.instance.sync(); } class MyApp extends StatelessWidget with WidgetsBindingObserver { @@ -33,9 +32,7 @@ class MyApp extends StatelessWidget with WidgetsBindingObserver { @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { - PhotoProvider.instance.refreshGalleryList().then((_) { - return PhotoSyncManager.instance.load(PhotoProvider.instance.list); - }); + PhotoSyncManager.instance.sync(); } } } diff --git a/lib/photo_sync_manager.dart b/lib/photo_sync_manager.dart index 514ffe406..e746d001e 100644 --- a/lib/photo_sync_manager.dart +++ b/lib/photo_sync_manager.dart @@ -2,8 +2,11 @@ import 'dart:async'; import 'dart:io'; import 'package:logger/logger.dart'; +import 'package:myapp/core/event_bus.dart'; import 'package:myapp/db/db_helper.dart'; +import 'package:myapp/events/user_authenticated_event.dart'; import 'package:myapp/photo_loader.dart'; +import 'package:myapp/photo_provider.dart'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; import 'package:photo_manager/photo_manager.dart'; @@ -12,25 +15,33 @@ import 'package:dio/dio.dart'; import 'package:myapp/models/photo.dart'; import 'package:myapp/core/constants.dart' as Constants; +import 'events/remote_sync_event.dart'; + class PhotoSyncManager { final _logger = Logger(); final _dio = Dio(); - bool _isLoadInProgress = false; + bool _isSyncInProgress = false; static final _lastSyncTimestampKey = "last_sync_timestamp_0"; static final _lastDBUpdateTimestampKey = "last_db_update_timestamp"; - PhotoSyncManager._privateConstructor(); + PhotoSyncManager._privateConstructor() { + Bus.instance.on().listen((event) { + sync(); + }); + } + static final PhotoSyncManager instance = PhotoSyncManager._privateConstructor(); - Future load(List pathEntities) async { - if (_isLoadInProgress) { - _logger.w("Load already in progress, skipping."); + Future sync() async { + if (_isSyncInProgress) { + _logger.w("Sync already in progress, skipping."); return; } - _isLoadInProgress = true; - _logger.i("Loading..."); + _isSyncInProgress = true; + _logger.i("Syncing..."); + final prefs = await SharedPreferences.getInstance(); var lastDBUpdateTimestamp = prefs.getInt(_lastDBUpdateTimestampKey); if (lastDBUpdateTimestamp == null) { @@ -38,6 +49,8 @@ class PhotoSyncManager { await _initializeDirectories(); } + await PhotoProvider.instance.refreshGalleryList(); + final pathEntities = PhotoProvider.instance.list; final photos = List(); for (AssetPathEntity pathEntity in pathEntities) { if (Platform.isIOS || pathEntity.name != "Recent") { @@ -56,16 +69,15 @@ class PhotoSyncManager { } } if (photos.isEmpty) { - _isLoadInProgress = false; - return; + _isSyncInProgress = false; + _syncPhotos().then((_) { + _deletePhotos(); + }); } else { photos.sort((first, second) => first.createTimestamp.compareTo(second.createTimestamp)); _updateDatabase(photos, prefs, lastDBUpdateTimestamp).then((_) { - _isLoadInProgress = false; - _syncPhotos().then((_) { - _deletePhotos(); - }); + _isSyncInProgress = false; }); } } @@ -91,9 +103,11 @@ class PhotoSyncManager { _logger.i("Last sync timestamp: " + lastSyncTimestamp.toString()); _getDiff(lastSyncTimestamp).then((diff) { - _downloadDiff(diff, prefs).then((_) { - _uploadDiff(prefs); - }); + if (diff != null) { + _downloadDiff(diff, prefs).then((_) { + _uploadDiff(prefs); + }); + } }); // TODO: Fix race conditions triggered due to concurrent syncs. @@ -121,7 +135,7 @@ class PhotoSyncManager { var localPath = path + basename(photo.remotePath); await _dio .download(Constants.ENDPOINT + "/" + photo.remotePath, localPath) - .catchError(_onError); + .catchError((e) => _logger.e(e)); // TODO: Save path photo.pathName = localPath; await DatabaseHelper.instance.insertPhoto(photo); @@ -135,14 +149,15 @@ class PhotoSyncManager { queryParameters: { "user": Constants.USER, "lastSyncTimestamp": lastSyncTimestamp - }).catchError(_onError); - _logger.i(response.toString()); + }).catchError((e) => _logger.e(e)); if (response != null) { + Bus.instance.fire(RemoteSyncEvent(true)); return (response.data["diff"] as List) .map((photo) => new Photo.fromJson(photo)) .toList(); } else { - return List(); + Bus.instance.fire(RemoteSyncEvent(false)); + return null; } } @@ -158,7 +173,7 @@ class PhotoSyncManager { _logger.i(response.toString()); var photo = Photo.fromJson(response.data); return photo; - }).catchError(_onError); + }).catchError((e) => _logger.e(e)); } Future _deletePhotos() async { @@ -174,11 +189,7 @@ class PhotoSyncManager { return _dio.post(Constants.ENDPOINT + "/delete", queryParameters: { "user": Constants.USER, "fileID": photo.uploadedFileId - }).catchError((e) => _onError(e)); - } - - void _onError(error) { - _logger.e(error); + }).catchError((e) => _logger.e(e)); } Future _initializeDirectories() async { diff --git a/lib/ui/gallery_app_bar_widget.dart b/lib/ui/gallery_app_bar_widget.dart index 701aa79ea..8bcbb6db0 100644 --- a/lib/ui/gallery_app_bar_widget.dart +++ b/lib/ui/gallery_app_bar_widget.dart @@ -1,8 +1,12 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:logger/logger.dart'; +import 'package:myapp/core/event_bus.dart'; import 'package:myapp/db/db_helper.dart'; +import 'package:myapp/events/remote_sync_event.dart'; import 'package:myapp/models/photo.dart'; import 'package:myapp/photo_loader.dart'; +import 'package:myapp/ui/setup_page.dart'; import 'package:photo_manager/photo_manager.dart'; import 'package:myapp/utils/share_util.dart'; @@ -24,10 +28,25 @@ class GalleryAppBarWidget extends StatefulWidget } class _GalleryAppBarWidgetState extends State { + bool _hasSyncErrors = false; + + @override + void initState() { + Bus.instance.on().listen((event) { + setState(() { + _hasSyncErrors = !event.success; + }); + }); + super.initState(); + } + @override Widget build(BuildContext context) { if (widget.selectedPhotos.isEmpty) { - return AppBar(title: Text(widget.title)); + return AppBar( + title: Text(widget.title), + actions: _getDefaultActions(context), + ); } return AppBar( @@ -38,11 +57,24 @@ class _GalleryAppBarWidgetState extends State { }, ), title: Text(widget.selectedPhotos.length.toString()), - actions: _getActions(context), + actions: _getPhotoActions(context), ); } - List _getActions(BuildContext context) { + List _getDefaultActions(BuildContext context) { + List actions = List(); + if (_hasSyncErrors) { + actions.add(IconButton( + icon: Icon(Icons.sync_problem), + onPressed: () { + _openSyncConfiguration(context); + }, + )); + } + return actions; + } + + List _getPhotoActions(BuildContext context) { List actions = List(); if (widget.selectedPhotos.isNotEmpty) { actions.add(IconButton( @@ -119,4 +151,16 @@ class _GalleryAppBarWidgetState extends State { widget.onSelectionClear(); } } + + void _openSyncConfiguration(BuildContext context) { + final page = SetupPage(); + Navigator.of(context).push( + MaterialPageRoute( + settings: RouteSettings(name: "/setup"), + builder: (BuildContext context) { + return page; + }, + ), + ); + } } diff --git a/lib/ui/setup_page.dart b/lib/ui/setup_page.dart new file mode 100644 index 000000000..a9650739c --- /dev/null +++ b/lib/ui/setup_page.dart @@ -0,0 +1,190 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:logger/logger.dart'; +import 'package:myapp/core/configuration.dart'; +import 'package:myapp/ui/sign_in_widget.dart'; +import 'package:myapp/utils/endpoint_finder.dart'; + +class SetupPage extends StatefulWidget { + SetupPage({key}) : super(key: key); + + @override + _SetupPageState createState() => _SetupPageState(); +} + +class _SetupPageState extends State { + bool _hasFoundEndpoint = Configuration.instance.getEndpoint() != null; + bool _errorFindingEndpoint = false; + String _enteredEndpoint = ""; + + @override + Widget build(BuildContext context) { + _hasFoundEndpoint = Configuration.instance.getEndpoint() != null; + if (!_hasFoundEndpoint && !_errorFindingEndpoint) { + EndpointFinder.instance.findEndpoint().then((endpoint) { + setState(() { + Configuration.instance.setEndpoint(endpoint); + }); + }).catchError((e) { + setState(() { + _errorFindingEndpoint = true; + }); + }); + } + + return Scaffold( + appBar: AppBar( + title: Text("Setup"), + ), + body: _getBody(), + ); + } + + Widget _getBody() { + if (!_hasFoundEndpoint && !_errorFindingEndpoint) { + return _getSearchScreen(); + } else if (!_hasFoundEndpoint && _errorFindingEndpoint) { + return _getManualEndpointEntryScreen(); + } else { + return SignInWidget(() { + setState(() {}); + }); + } + } + + Widget _getManualEndpointEntryScreen() { + return Container( + margin: EdgeInsets.all(12), + child: Column( + children: [ + Text("Please enter the IP address of the ente server manually."), + TextField( + decoration: InputDecoration( + hintText: '192.168.1.1', + contentPadding: EdgeInsets.all(20), + ), + autofocus: true, + autocorrect: false, + onChanged: (value) { + setState(() { + _enteredEndpoint = value; + }); + }, + ), + CupertinoButton( + child: Text("Connect"), + onPressed: () async { + try { + bool success = + await EndpointFinder.instance.ping(_enteredEndpoint); + if (success) { + setState(() { + _errorFindingEndpoint = false; + Configuration.instance.setEndpoint(_enteredEndpoint); + }); + } else { + _showPingErrorDialog(); + } + } catch (e) { + _showPingErrorDialog(); + } + }, + ), + ], + ), + ); + } + + Center _getSearchScreen() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + AnimatedSearchIconWidget(), + Text("Searching for ente server..."), + ], + ), + ); + } + + void _showPingErrorDialog() { + showDialog( + context: context, + barrierDismissible: false, // user must tap button! + builder: (BuildContext context) { + return CupertinoAlertDialog( + title: Text('Connection failed'), + content: Padding( + padding: const EdgeInsets.fromLTRB(0, 8, 0, 0), + child: Text( + 'Please make sure that the server is running and reachable.'), + ), + actions: [ + CupertinoDialogAction( + child: Text('OK'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ); + }, + ); + } +} + +class AnimatedSearchIconWidget extends StatefulWidget { + AnimatedSearchIconWidget({ + Key key, + }) : super(key: key); + + @override + _AnimatedSearchIconWidgetState createState() => + _AnimatedSearchIconWidgetState(); +} + +class _AnimatedSearchIconWidgetState extends State + with SingleTickerProviderStateMixin { + Animation _animation; + AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = + AnimationController(duration: const Duration(seconds: 1), vsync: this); + _animation = Tween(begin: 100, end: 200).animate(_controller) + ..addListener(() { + setState(() { + // The state that has changed here is the animation object’s value. + }); + }) + ..addStatusListener((status) { + if (status == AnimationStatus.completed) { + _controller.reverse(); + } else if (status == AnimationStatus.dismissed) { + _controller.forward(); + } + }); + _controller.forward(); + } + + @override + Widget build(BuildContext context) { + return Container( + child: Icon( + Icons.search, + size: _animation.value, + ), + width: 200, + height: 200, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } +} diff --git a/lib/ui/sign_in_widget.dart b/lib/ui/sign_in_widget.dart new file mode 100644 index 000000000..a13563484 --- /dev/null +++ b/lib/ui/sign_in_widget.dart @@ -0,0 +1,109 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:myapp/core/configuration.dart'; +import 'package:myapp/user_authenticator.dart'; + +class SignInWidget extends StatefulWidget { + final Function() onReconfigurationRequested; + + const SignInWidget( + this.onReconfigurationRequested, { + Key key, + }) : super(key: key); + + @override + _SignInWidgetState createState() => _SignInWidgetState(); +} + +class _SignInWidgetState extends State { + String _username, _password; + @override + void initState() { + _username = Configuration.instance.getUsername(); + _password = Configuration.instance.getPassword(); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Container( + child: Column( + children: [ + TextFormField( + initialValue: _username, + decoration: InputDecoration( + hintText: 'username', + contentPadding: EdgeInsets.all(20), + ), + autofocus: true, + autocorrect: false, + onChanged: (value) { + setState(() { + _username = value; + }); + }, + ), + TextFormField( + initialValue: _password, + decoration: InputDecoration( + hintText: 'password', + contentPadding: EdgeInsets.all(20), + ), + 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 { + _showErrorDialog(); + } + }, + ), + ], + )); + } + + void _showErrorDialog() { + showDialog( + context: context, + barrierDismissible: false, // user must tap button! + builder: (BuildContext context) { + return CupertinoAlertDialog( + title: Text('Login failed'), + content: Padding( + padding: const EdgeInsets.fromLTRB(0, 8, 0, 0), + child: Text( + 'Please make sure that the credentials entered are correct.'), + ), + actions: [ + CupertinoDialogAction( + child: Text('Reconfigure'), + onPressed: () { + Navigator.of(context).pop(); + Configuration.instance.setEndpoint(null); + widget.onReconfigurationRequested(); + }, + ), + CupertinoDialogAction( + child: Text('OK'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ); + }, + ); + } +} diff --git a/lib/user_authenticator.dart b/lib/user_authenticator.dart new file mode 100644 index 000000000..f8310c972 --- /dev/null +++ b/lib/user_authenticator.dart @@ -0,0 +1,38 @@ +import 'package:dio/dio.dart'; +import 'package:logger/logger.dart'; +import 'package:myapp/core/configuration.dart'; +import 'package:myapp/core/event_bus.dart'; + +import 'events/user_authenticated_event.dart'; + +class UserAuthenticator { + final _dio = Dio(); + final _logger = Logger(); + + UserAuthenticator._privateConstructor(); + + static final UserAuthenticator instance = + UserAuthenticator._privateConstructor(); + + Future login(String username, String password) { + return _dio.post( + "http://" + Configuration.instance.getEndpoint() + ":8080/users/login", + queryParameters: { + "username": username, + "password": password + }).then((response) { + if (response.statusCode == 200 && response.data != null) { + Configuration.instance.setUsername(username); + Configuration.instance.setPassword(password); + Configuration.instance.setToken(response.data["token"]); + Bus.instance.fire(UserAuthenticatedEvent()); + return true; + } else { + return false; + } + }).catchError((e) { + _logger.e(e.toString()); + return false; + }); + } +} diff --git a/lib/utils/endpoint_finder.dart b/lib/utils/endpoint_finder.dart new file mode 100644 index 000000000..92a6d5e61 --- /dev/null +++ b/lib/utils/endpoint_finder.dart @@ -0,0 +1,57 @@ +import 'dart:async'; + +import 'package:connectivity/connectivity.dart'; +import 'package:dio/dio.dart'; +import 'package:logger/logger.dart'; + +class EndpointFinder { + final _dio = Dio(); + + EndpointFinder._privateConstructor() { + _dio.options = BaseOptions(connectTimeout: 200); + } + + static final EndpointFinder instance = EndpointFinder._privateConstructor(); + + Future findEndpoint() { + return (Connectivity().getWifiIP()).then((ip) async { + Logger().i(ip); + final ipSplit = ip.split("."); + var prefix = ""; + for (int index = 0; index < ipSplit.length; index++) { + if (index != ipSplit.length - 1) { + prefix += ipSplit[index] + "."; + } + } + Logger().i(prefix); + + for (int i = 1; i <= 10; i++) { + var endpoint = prefix + i.toString(); + if (i == 300) { + endpoint = "192.168.0.101"; + } + Logger().i("Trying " + endpoint); + try { + final success = await ping(endpoint); + if (success) { + return endpoint; + } + } catch (e) { + // Do nothing + } + } + throw TimeoutException("Could not find a valid endpoint"); + }); + } + + Future ping(String endpoint) async { + return _dio.get("http://" + endpoint + ":8080/ping").then((response) { + if (response.data["message"] == "pong") { + Logger().i("Found " + endpoint); + return true; + } else { + return false; + } + }); + } +} diff --git a/pubspec.lock b/pubspec.lock index 06dd81692..8d97f26f3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -43,6 +43,27 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.14.11" + connectivity: + dependency: "direct main" + description: + name: connectivity + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.8+2" + connectivity_macos: + dependency: transitive + description: + name: connectivity_macos + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.0+2" + connectivity_platform_interface: + dependency: transitive + description: + name: connectivity_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.5" convert: dependency: transitive description: @@ -85,6 +106,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.2" + event_bus: + dependency: "direct main" + description: + name: event_bus + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 4d8a18428..8b1684f2c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,8 @@ dependencies: photo_view: ^0.9.2 flutter_image_compress: ^0.6.5+1 visibility_detector: ^0.1.4 + connectivity: ^0.4.8+2 + event_bus: ^1.1.1 dev_dependencies: flutter_test: