diff --git a/README.md b/README.md index 368dc9a9..7c1278fe 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,15 @@ ![GitHub](https://img.shields.io/github/license/Teifun2/nextcloud-cookbook-flutter) ![GitHub release (latest by date)](https://img.shields.io/github/v/release/Teifun2/nextcloud-cookbook-flutter) ![GitHub Workflow Status (branch)](https://img.shields.io/github/workflow/status/Teifun2/nextcloud-cookbook-flutter/Build/master) +[IzyyDroid](https://apt.izzysoft.de/fdroid/index/apk/com.nextcloud_cookbook_flutter) # Nextcloud Cookbook Mobile Client written in Flutter -This project aims to provide a mobile client for both Android and IOs for the nextcloud plugin cookbook (https://github.com/nextcloud/cookbook) +This project aims to provide a mobile client for both Android and IOs for the nextcloud app cookbook (https://github.com/nextcloud/cookbook) + +It works best with an Nextcloud installation >= 17 ## Screenshots -![Screenshot_categories](https://user-images.githubusercontent.com/7461832/91664922-899d6400-eaf2-11ea-8120-3222bd5b5363.png) -![Screenshot_category_list](https://user-images.githubusercontent.com/7461832/91664920-8904cd80-eaf2-11ea-9bb3-62e0b41f85c0.png) -![Screenshot_recipe_1](https://user-images.githubusercontent.com/7461832/91664918-873b0a00-eaf2-11ea-86a6-e30fde4c98a9.png) -![Screenshot_recipe_2](https://user-images.githubusercontent.com/7461832/91664923-8a35fa80-eaf2-11ea-9bfe-6ed8edc41b49.png) +Screenshot_categories Screenshot_category_list + +Screenshot_recipe_2 Screenshot_recipe_1 diff --git a/assets/IzzyOnDroid.png b/assets/IzzyOnDroid.png new file mode 100644 index 00000000..af5bf5bd Binary files /dev/null and b/assets/IzzyOnDroid.png differ diff --git a/lib/src/blocs/login/login_bloc.dart b/lib/src/blocs/login/login_bloc.dart index ddf699af..4d2d0782 100644 --- a/lib/src/blocs/login/login_bloc.dart +++ b/lib/src/blocs/login/login_bloc.dart @@ -1,10 +1,10 @@ import 'dart:async'; -import 'package:meta/meta.dart'; import 'package:bloc/bloc.dart'; +import 'package:meta/meta.dart'; import 'package:nextcloud_cookbook_flutter/src/blocs/authentication/authentication.dart'; -import '../../services/user_repository.dart'; +import '../../services/user_repository.dart'; import 'login.dart'; class LoginBloc extends Bloc { @@ -23,9 +23,8 @@ class LoginBloc extends Bloc { yield LoginLoading(); try { - final appAuthentication = await userRepository.authenticate( - serverUrl: event.serverURL - ); + final appAuthentication = + await userRepository.authenticate(event.serverURL); authenticationBloc.add(LoggedIn(appAuthentication: appAuthentication)); yield LoginInitial(); diff --git a/lib/src/blocs/login/login_event.dart b/lib/src/blocs/login/login_event.dart index d3b3a6df..8b48215f 100644 --- a/lib/src/blocs/login/login_event.dart +++ b/lib/src/blocs/login/login_event.dart @@ -1,8 +1,11 @@ -import 'package:meta/meta.dart'; import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; abstract class LoginEvent extends Equatable { const LoginEvent(); + + @override + List get props => []; } class LoginButtonPressed extends LoginEvent { @@ -16,6 +19,5 @@ class LoginButtonPressed extends LoginEvent { List get props => [serverURL]; @override - String toString() => - 'LoginButtonPressed {serverURL: $serverURL}'; -} \ No newline at end of file + String toString() => 'LoginButtonPressed {serverURL: $serverURL}'; +} diff --git a/lib/src/models/app_authentication.dart b/lib/src/models/app_authentication.dart index ea854df8..f01fbf4a 100644 --- a/lib/src/models/app_authentication.dart +++ b/lib/src/models/app_authentication.dart @@ -15,8 +15,14 @@ class AppAuthentication { factory AppAuthentication.fromJson(String jsonString) { Map jsonData = json.decode(jsonString); - String basicAuth = jsonData.containsKey("basicAuth") ? jsonData['basicAuth'] : - 'Basic '+base64Encode(utf8.encode('${jsonData["loginName"]}:${jsonData["appPassword"]}')); + String basicAuth = jsonData.containsKey("basicAuth") + ? jsonData['basicAuth'] + : 'Basic ' + + base64Encode( + utf8.encode( + '${jsonData["loginName"]}:${jsonData["appPassword"]}', + ), + ); return AppAuthentication( server: jsonData["server"], @@ -35,4 +41,4 @@ class AppAuthentication { @override String toString() => 'LoggedIn { token: $server, $loginName, $basicAuth}'; -} \ No newline at end of file +} diff --git a/lib/src/models/category.dart b/lib/src/models/category.dart index 787678bf..977b4328 100644 --- a/lib/src/models/category.dart +++ b/lib/src/models/category.dart @@ -7,6 +7,8 @@ class Category extends Equatable { final int recipeCount; String imageUrl; + Category(this.name, this.recipeCount); + Category.fromJson(Map json) : name = json["name"], recipeCount = json["recipe_count"] is int diff --git a/lib/src/screens/form/login_form.dart b/lib/src/screens/form/login_form.dart index f22a227a..48792b18 100644 --- a/lib/src/screens/form/login_form.dart +++ b/lib/src/screens/form/login_form.dart @@ -1,6 +1,8 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_spinkit/flutter_spinkit.dart'; +import 'package:nextcloud_cookbook_flutter/src/services/user_repository.dart'; import '../../blocs/login/login.dart'; @@ -9,7 +11,7 @@ class LoginForm extends StatefulWidget { State createState() => _LoginFormState(); } -class _LoginFormState extends State { +class _LoginFormState extends State with WidgetsBindingObserver { final _serverUrl = TextEditingController(); // Create a global key that uniquely identifies the Form widget // and allows validation of the form. @@ -18,8 +20,34 @@ class _LoginFormState extends State { // not a GlobalKey. final _formKey = GlobalKey(); + Function authenticateInterruptCallback; + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + authenticateInterruptCallback(); + debugPrint("WAT"); + } + } + + @override + initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + @override Widget build(BuildContext context) { + authenticateInterruptCallback = () { + UserRepository().stopAuthenticate(); + }; + _onLoginButtonPressed() { if (_formKey.currentState.validate()) { BlocProvider.of(context).add( @@ -43,45 +71,49 @@ class _LoginFormState extends State { }, child: BlocBuilder( builder: (context, state) { - return Form( - // Build a Form widget using the _formKey created above. - key: _formKey, - child: Column( - children: [ - TextFormField( - decoration: InputDecoration(labelText: 'Server URL'), - controller: _serverUrl, - validator: (value) { - if (value.isEmpty) { - return 'Please enter a Nextcloud URL'; - } - var urlPattern = - r"([-A-Z0-9.]+)(/[-A-Z0-9+&@#/%=~_|!:,.;]*)?(\?[A-Z0-9+&@#/%=~_|!:‌​,.;]*)?"; - bool _match = new RegExp(urlPattern, caseSensitive: false) - .hasMatch(value); - if (!_match) { - return 'Please enter a valid URL'; - } - return null; - }, - onFieldSubmitted: (val) { - if (state is! LoginLoading) { - _onLoginButtonPressed(); - } - }, - textInputAction: TextInputAction.done, - ), - RaisedButton( - onPressed: - state is! LoginLoading ? _onLoginButtonPressed : null, - child: Text('Login'), - ), - Container( - child: state is LoginLoading - ? SpinKitWave(color: Colors.blue, size: 50.0) - : null, - ), - ], + return Padding( + padding: const EdgeInsets.all(8.0), + child: Form( + // Build a Form widget using the _formKey created above. + key: _formKey, + child: Column( + children: [ + TextFormField( + decoration: InputDecoration(labelText: 'Server URL'), + controller: _serverUrl, + keyboardType: TextInputType.url, + validator: (value) { + if (value.isEmpty) { + return 'Please enter a Nextcloud URL'; + } + var urlPattern = + r"([-A-Z0-9.]+)(/[-A-Z0-9+&@#/%=~_|!:,.;]*)?(\?[A-Z0-9+&@#/%=~_|!:‌​,.;]*)?"; + bool _match = new RegExp(urlPattern, caseSensitive: false) + .hasMatch(value); + if (!_match) { + return 'Please enter a valid URL'; + } + return null; + }, + onFieldSubmitted: (val) { + if (state is! LoginLoading) { + _onLoginButtonPressed(); + } + }, + textInputAction: TextInputAction.done, + ), + RaisedButton( + onPressed: + state is! LoginLoading ? _onLoginButtonPressed : null, + child: Text('Login'), + ), + Container( + child: state is LoginLoading + ? SpinKitWave(color: Colors.blue, size: 50.0) + : null, + ), + ], + ), ), ); }, diff --git a/lib/src/services/authentication_provider.dart b/lib/src/services/authentication_provider.dart new file mode 100644 index 00000000..a7cb39de --- /dev/null +++ b/lib/src/services/authentication_provider.dart @@ -0,0 +1,124 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:dio/dio.dart' as dio; +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:http/http.dart' as http; +import 'package:nextcloud_cookbook_flutter/src/models/app_authentication.dart'; +import 'package:nextcloud_cookbook_flutter/src/models/intial_login.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class AuthenticationProvider { + final FlutterSecureStorage _secureStorage = new FlutterSecureStorage(); + final String _appAuthenticationKey = 'appAuthentication'; + AppAuthentication currentAppAuthentication; + bool resumeAuthenticate = true; + + Future authenticate({ + @required String serverUrl, + }) async { + resumeAuthenticate = true; + if (serverUrl.substring(0, 4) != 'http') { + serverUrl = 'https://' + serverUrl; + } + String urlInitialCall = serverUrl + '/index.php/login/v2'; + var response; + try { + response = await http.post(urlInitialCall, + headers: {"User-Agent": "Cookbook App", "Accept-Language": "en-US"}); + } catch (e) { + throw ('Cannot reach: $serverUrl'); + } + + if (response.statusCode == 200) { + final initialLogin = InitialLogin.fromJson(json.decode(response.body)); + + if (await canLaunch(initialLogin.login)) { + _launchURL(initialLogin.login); + + String urlLoginSuccess = + initialLogin.poll.endpoint + "?token=" + initialLogin.poll.token; + + var responseLog = await http.post(urlLoginSuccess); + while (responseLog.statusCode != 200 && resumeAuthenticate) { + await Future.delayed(Duration(milliseconds: 100)); + responseLog = await http.post(urlLoginSuccess); + } + + await closeWebView(); + + if (responseLog.statusCode != 200) { + throw "Login Process was interrupted!"; + } else { + return AppAuthentication.fromJson(responseLog.body); + } + } else { + throw 'Could not launch the authentication window.'; + } + } else { + throw Exception('Your server Name is not correct'); + } + } + + void stopAuthenticate() { + resumeAuthenticate = false; + } + + Future hasAppAuthentication() async { + if (currentAppAuthentication != null) { + return true; + } else { + String appAuthentication = + await _secureStorage.read(key: _appAuthenticationKey); + return appAuthentication != null; + } + } + + Future loadAppAuthentication() async { + String appAuthenticationString = + await _secureStorage.read(key: _appAuthenticationKey); + if (appAuthenticationString == null) { + throw ("No authentication found in Storage"); + } else { + currentAppAuthentication = + AppAuthentication.fromJson(appAuthenticationString); + } + } + + Future persistAppAuthentication( + AppAuthentication appAuthentication) async { + currentAppAuthentication = appAuthentication; + await _secureStorage.write( + key: _appAuthenticationKey, value: appAuthentication.toJson()); + } + + Future deleteAppAuthentication() async { + var response = await dio.Dio().delete( + "${currentAppAuthentication.server}/ocs/v2.php/core/apppassword", + options: new dio.Options( + headers: { + "OCS-APIREQUEST": "true", + "authorization": currentAppAuthentication.basicAuth + }, + ), + ); + + if (response.statusCode != 200) { + debugPrint("Failed to remove remote apppassword!"); + } + + //TODO Delete Appkey Serverside + currentAppAuthentication = null; + await _secureStorage.delete(key: _appAuthenticationKey); + } + + Future _launchURL(String url) async { + await launch( + url, + forceSafariVC: true, + forceWebView: true, + enableJavaScript: true, + ); + } +} diff --git a/lib/src/services/categories_provider.dart b/lib/src/services/categories_provider.dart index fedac029..9a51d620 100644 --- a/lib/src/services/categories_provider.dart +++ b/lib/src/services/categories_provider.dart @@ -8,7 +8,7 @@ class CategoriesProvider { Future> fetchCategories() async { AppAuthentication appAuthentication = - UserRepository().currentAppAuthentication; + UserRepository().getCurrentAppAuthentication(); final response = await client.get( "${appAuthentication.server}/index.php/apps/cookbook/categories", @@ -19,8 +19,19 @@ class CategoriesProvider { if (response.statusCode == 200) { try { - return Category.parseCategories(response.body) - ..sort((a, b) => a.name.compareTo(b.name)); + List categories = Category.parseCategories(response.body); + categories.sort((a, b) => a.name.compareTo(b.name)); + categories.insert( + 0, + Category( + "All", + categories.fold( + 0, + (previousValue, element) => + previousValue + element.recipeCount), + ), + ); + return categories; } catch (e) { throw Exception(e); } diff --git a/lib/src/services/category_recipes_short_provider.dart b/lib/src/services/category_recipes_short_provider.dart index 497a647b..23836897 100644 --- a/lib/src/services/category_recipes_short_provider.dart +++ b/lib/src/services/category_recipes_short_provider.dart @@ -8,7 +8,7 @@ class CategoryRecipesShortProvider { Future> fetchCategoryRecipesShort(String category) async { AppAuthentication appAuthentication = - UserRepository().currentAppAuthentication; + UserRepository().getCurrentAppAuthentication(); final response = await client.get( "${appAuthentication.server}/index.php/apps/cookbook/category/$category", diff --git a/lib/src/services/data_repository.dart b/lib/src/services/data_repository.dart index 86d38223..946ff7a0 100644 --- a/lib/src/services/data_repository.dart +++ b/lib/src/services/data_repository.dart @@ -25,8 +25,8 @@ class DataRepository { static Future> _allRecipesShort; // Actions - Future> fetchRecipesShort({String category = 'all'}) { - if (category == 'all') { + Future> fetchRecipesShort({String category = 'All'}) { + if (category == 'All') { return recipesShortProvider.fetchRecipesShort(); } else { return categoryRecipesShortProvider.fetchCategoryRecipesShort(category); @@ -52,8 +52,14 @@ class DataRepository { } Future _fetchCategoryImage(Category category) async { - List categoryRecipes = await categoryRecipesShortProvider - .fetchCategoryRecipesShort(category.name); + List categoryRecipes = await () { + if (category.name == "All") { + return recipesShortProvider.fetchRecipesShort(); + } else { + return categoryRecipesShortProvider + .fetchCategoryRecipesShort(category.name); + } + }(); category.imageUrl = categoryRecipes.first.imageUrl; diff --git a/lib/src/services/recipe_provider.dart b/lib/src/services/recipe_provider.dart index d33010e9..8a200a2a 100644 --- a/lib/src/services/recipe_provider.dart +++ b/lib/src/services/recipe_provider.dart @@ -9,7 +9,7 @@ class RecipeProvider { Future fetchRecipe(int id) async { AppAuthentication appAuthentication = - UserRepository().currentAppAuthentication; + UserRepository().getCurrentAppAuthentication(); final response = await client.get( "${appAuthentication.server}/index.php/apps/cookbook/api/recipes/$id", @@ -31,7 +31,7 @@ class RecipeProvider { Future updateRecipe(Recipe recipe) async { AppAuthentication appAuthentication = - UserRepository().currentAppAuthentication; + UserRepository().getCurrentAppAuthentication(); try { var response = await Dio().put( diff --git a/lib/src/services/recipes_short_provider.dart b/lib/src/services/recipes_short_provider.dart index 47d3eb3a..f0e435fe 100644 --- a/lib/src/services/recipes_short_provider.dart +++ b/lib/src/services/recipes_short_provider.dart @@ -3,11 +3,12 @@ import 'package:nextcloud_cookbook_flutter/src/models/app_authentication.dart'; import 'package:nextcloud_cookbook_flutter/src/models/recipe_short.dart'; import 'package:nextcloud_cookbook_flutter/src/services/user_repository.dart'; -class RecipesShortProvider { +class RecipesShortProvider { Client client = Client(); Future> fetchRecipesShort() async { - AppAuthentication appAuthentication = UserRepository().currentAppAuthentication; + AppAuthentication appAuthentication = + UserRepository().getCurrentAppAuthentication(); final response = await client.get( "${appAuthentication.server}/index.php/apps/cookbook/api/recipes", diff --git a/lib/src/services/user_repository.dart b/lib/src/services/user_repository.dart index 7698c455..058366f3 100644 --- a/lib/src/services/user_repository.dart +++ b/lib/src/services/user_repository.dart @@ -1,17 +1,10 @@ import 'dart:async'; -import 'package:flutter/material.dart'; +import 'package:nextcloud_cookbook_flutter/src/services/authentication_provider.dart'; import '../models/app_authentication.dart'; -import '../models/intial_login.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:http/http.dart' as http; - -import 'dart:convert' show json; -import 'package:url_launcher/url_launcher.dart'; class UserRepository { - // Singleton static final UserRepository _userRepository = UserRepository._internal(); factory UserRepository() { @@ -19,93 +12,34 @@ class UserRepository { } UserRepository._internal(); - final FlutterSecureStorage _secureStorage = new FlutterSecureStorage(); - final String _appAuthenticationKey = 'appAuthentication'; - AppAuthentication currentAppAuthentication; - - Future authenticate({ - @required String serverUrl, - }) async { - - if (serverUrl.substring(0,4) != 'http') { - serverUrl = 'https://'+serverUrl; - } - String urlInitialCall = serverUrl+'/index.php/login/v2'; - var response; - try { - response = await http.post(urlInitialCall, headers: {"User-Agent":"Cookbook App", "Accept-Language":"en-US"}); - } catch (e) { - throw ('Cannot reach: $serverUrl') ; - } - - if (response.statusCode == 200) { - final initialLogin = InitialLogin.fromJson(json.decode(response.body)); - - if (await canLaunch(initialLogin.login)) { + AuthenticationProvider authenticationProvider = AuthenticationProvider(); - Future _launched = _launchURL(initialLogin.login); - - String urlLoginSuccess = initialLogin.poll.endpoint + "?token=" + initialLogin.poll.token; - //TODO add when users goes back - - var responseLog = await http.post(urlLoginSuccess); - while (responseLog.statusCode != 200) { - //TODO check if time is good - // I think this is no a correct usage of the Timer. We cold use Timer.periodic. But it should call the function. - Timer(const Duration(milliseconds: 500), () { - }); - responseLog = await http.post(urlLoginSuccess); - } + Future authenticate(String serverUrl) async { + return authenticationProvider.authenticate(serverUrl: serverUrl); + } - await closeWebView(); + void stopAuthenticate() { + authenticationProvider.stopAuthenticate(); + } - return AppAuthentication.fromJson(responseLog.body); - } else { - //TODO throw good errror - throw 'Could not launchsade'; - } - } else{ - //TODO Catch Errors - throw Exception('Your server Name is not correct'); - } + AppAuthentication getCurrentAppAuthentication() { + return authenticationProvider.currentAppAuthentication; } Future hasAppAuthentication() async { - if (currentAppAuthentication != null) { - return true; - } else { - String appAuthentication = await _secureStorage.read(key: _appAuthenticationKey); - return appAuthentication != null; - } + return authenticationProvider.hasAppAuthentication(); } Future loadAppAuthentication() async { - String appAuthenticationString = await _secureStorage.read(key: _appAuthenticationKey); - if (appAuthenticationString == null) { - throw("No authentication found in Storage"); - } else{ - currentAppAuthentication = AppAuthentication.fromJson(appAuthenticationString); - } + return authenticationProvider.loadAppAuthentication(); } - Future persistAppAuthentication(AppAuthentication appAuthentication) async { - currentAppAuthentication = appAuthentication; - await _secureStorage.write(key: _appAuthenticationKey, value: appAuthentication.toJson()); + Future persistAppAuthentication( + AppAuthentication appAuthentication) async { + return authenticationProvider.persistAppAuthentication(appAuthentication); } Future deleteAppAuthentication() async { - //TODO Delete Appkey Serverside - currentAppAuthentication = null; - await _secureStorage.delete(key: _appAuthenticationKey); + return authenticationProvider.deleteAppAuthentication(); } } - -// TODO: Move this to more appropriate position -Future _launchURL( String url) async { - await launch( - url, - forceSafariVC: true, - forceWebView: true, - enableJavaScript: true, - ); -} diff --git a/lib/src/widget/authentication_cached_network_image.dart b/lib/src/widget/authentication_cached_network_image.dart index de75ccc0..d366b2a5 100644 --- a/lib/src/widget/authentication_cached_network_image.dart +++ b/lib/src/widget/authentication_cached_network_image.dart @@ -15,7 +15,7 @@ class AuthenticationCachedNetworkImage extends StatelessWidget { @override Widget build(BuildContext context) { AppAuthentication appAuthentication = - UserRepository().currentAppAuthentication; + UserRepository().getCurrentAppAuthentication(); return CachedNetworkImage( width: width, diff --git a/lib/src/widget/duration_indicator.dart b/lib/src/widget/duration_indicator.dart index b417cbfc..6cd3a95b 100644 --- a/lib/src/widget/duration_indicator.dart +++ b/lib/src/widget/duration_indicator.dart @@ -32,7 +32,7 @@ class DurationIndicator extends StatelessWidget { Container( child: Center( child: Text( - "${duration.inHours % 24}:${duration.inMinutes % 60}", + "${duration.inHours % 24}:${duration.inMinutes % 60 < 10 ? "0" : ""}${duration.inMinutes % 60}", style: TextStyle(fontSize: 16), ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 1f29790c..ec6ec9b5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,7 +11,7 @@ description: A new Flutter application. # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 0.3.2+8 +version: 0.3.3+9 environment: sdk: ">=2.6.0 <3.0.0"