diff --git a/README.md b/README.md index f13f396f..36ab64e1 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ # Nextcloud Cookbook Mobile Client written in Flutter -This project aims to provide a mobile client for both Android and IOs for the nextcloud app cookbook (https://github.com/nextcloud/cookbook) +This project aims to provide a mobile client for both Android and IOs for the Nextcloud Cookbook App (https://github.com/nextcloud/cookbook) -It works best with an Nextcloud installation >= 17 +It works best with an Nextcloud installation >= 19 and a Cookbook plugin version 0.7.9 ## Screenshots diff --git a/assets/i18n/en.json b/assets/i18n/en.json index fafbe50a..7e8ceb2a 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -44,7 +44,9 @@ "errors": { "unknown": "Categories in unknown state", "load_failed": "Category Load Failed: {error_msg}", - "load_no_response": "Could not retrieve the Categories from the server." + "load_no_response": "Could not retrieve the Categories from the server.", + "api_version_check_failed": "Failed to check the API version of the Server:\n {error_msg}", + "api_version_above_confirmed": "The Api Version of the Server was Updated. Some features might not work as expected. Please wait for an update!\n {version}" } }, "recipe_list": { diff --git a/lib/src/blocs/authentication/authentication_bloc.dart b/lib/src/blocs/authentication/authentication_bloc.dart index 1aaa9e3d..f09e972c 100644 --- a/lib/src/blocs/authentication/authentication_bloc.dart +++ b/lib/src/blocs/authentication/authentication_bloc.dart @@ -23,6 +23,7 @@ class AuthenticationBloc await userRepository.loadAppAuthentication(); bool validCredentials = await userRepository.checkAppAuthentication(); if (validCredentials) { + await userRepository.fetchApiVersion(); yield AuthenticationAuthenticated(); } else { await userRepository.deleteAppAuthentication(); @@ -36,6 +37,7 @@ class AuthenticationBloc if (event is LoggedIn) { yield AuthenticationLoading(); await userRepository.persistAppAuthentication(event.appAuthentication); + await userRepository.fetchApiVersion(); yield AuthenticationAuthenticated(); } @@ -45,4 +47,4 @@ class AuthenticationBloc yield AuthenticationUnauthenticated(); } } -} \ No newline at end of file +} diff --git a/lib/src/screens/category_screen.dart b/lib/src/screens/category_screen.dart index 7689c0c6..d1bb22ba 100644 --- a/lib/src/screens/category_screen.dart +++ b/lib/src/screens/category_screen.dart @@ -6,6 +6,7 @@ import 'package:nextcloud_cookbook_flutter/src/blocs/categories/categories.dart' import 'package:nextcloud_cookbook_flutter/src/models/category.dart'; import 'package:nextcloud_cookbook_flutter/src/screens/recipes_list_screen.dart'; import 'package:nextcloud_cookbook_flutter/src/screens/search_screen.dart'; +import 'package:nextcloud_cookbook_flutter/src/widget/api_version_warning.dart'; import 'package:nextcloud_cookbook_flutter/src/widget/category_card.dart'; class CategoryScreen extends StatefulWidget { @@ -66,7 +67,12 @@ class _CategoryScreenState extends State { return _buildCategoriesScreen(categoriesState.categories); } else if (categoriesState is CategoriesLoadInProgress || categoriesState is CategoriesInitial) { - return Center(child: CircularProgressIndicator()); + return Column( + children: [ + ApiVersionWarning(), + Center(child: CircularProgressIndicator()), + ], + ); } else if (categoriesState is CategoriesLoadFailure) { return Text(translate('categories.errors.load_failed', args: {'error_msg': categoriesState.errorMsg})); diff --git a/lib/src/screens/form/login_form.dart b/lib/src/screens/form/login_form.dart index 4f7ae8b4..c0a1d4c6 100644 --- a/lib/src/screens/form/login_form.dart +++ b/lib/src/screens/form/login_form.dart @@ -114,7 +114,7 @@ class _LoginFormState extends State with WidgetsBindingObserver { return translate('login.server_url.validator.empty'); } var urlPattern = - r"^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]+$"; + r"^(?:http(s)?:\/\/)?[\w.-]+(?:(?:\.[\w\.-]+)|(?:\:\d+))+[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]*$"; bool _match = new RegExp(urlPattern, caseSensitive: false) .hasMatch(_punyEncodeUrl(value)); diff --git a/lib/src/services/authentication_provider.dart b/lib/src/services/authentication_provider.dart index 65601ca8..63cefef3 100644 --- a/lib/src/services/authentication_provider.dart +++ b/lib/src/services/authentication_provider.dart @@ -24,6 +24,9 @@ class AuthenticationProvider { }) async { if (serverUrl.substring(0, 4) != 'http') { serverUrl = 'https://' + serverUrl; + if (serverUrl.endsWith("/")) { + serverUrl = serverUrl.substring(0, serverUrl.length - 1); + } } String urlInitialCall = serverUrl + '/ocs/v2.php/core/getapppassword'; diff --git a/lib/src/services/category_recipes_short_provider.dart b/lib/src/services/category_recipes_short_provider.dart index 54383507..5ad61ecf 100644 --- a/lib/src/services/category_recipes_short_provider.dart +++ b/lib/src/services/category_recipes_short_provider.dart @@ -3,16 +3,23 @@ import 'package:flutter_translate/flutter_translate.dart'; 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'; +import 'package:nextcloud_cookbook_flutter/src/services/version_provider.dart'; class CategoryRecipesShortProvider { Future> fetchCategoryRecipesShort(String category) async { + AndroidApiVersion androidApiVersion = UserRepository().getAndroidVersion(); Dio client = UserRepository().getAuthenticatedClient(); AppAuthentication appAuthentication = UserRepository().getCurrentAppAuthentication(); - final response = await client.get( - "${appAuthentication.server}/index.php/apps/cookbook/category/$category", - ); + Response response; + if (androidApiVersion == AndroidApiVersion.BEFORE_API_ENDPOINT) { + response = await client.get( + "${appAuthentication.server}/index.php/apps/cookbook/category/$category"); + } else { + response = await client.get( + "${appAuthentication.server}/index.php/apps/cookbook/api/category/$category"); + } if (response.statusCode == 200) { return RecipeShort.parseRecipesShort(response.data); diff --git a/lib/src/services/user_repository.dart b/lib/src/services/user_repository.dart index 76a9cc99..28397368 100644 --- a/lib/src/services/user_repository.dart +++ b/lib/src/services/user_repository.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:dio/dio.dart'; import 'package:nextcloud_cookbook_flutter/src/services/authentication_provider.dart'; +import 'package:nextcloud_cookbook_flutter/src/services/version_provider.dart'; import '../models/app_authentication.dart'; @@ -14,6 +15,7 @@ class UserRepository { UserRepository._internal(); AuthenticationProvider authenticationProvider = AuthenticationProvider(); + VersionProvider versionProvider = VersionProvider(); Future authenticate( String serverUrl, @@ -79,4 +81,12 @@ class UserRepository { Future deleteAppAuthentication() async { return authenticationProvider.deleteAppAuthentication(); } + + Future fetchApiVersion() async { + return versionProvider.fetchApiVersion(); + } + + AndroidApiVersion getAndroidVersion() { + return versionProvider.getApiVersion().getAndroidVersion(); + } } diff --git a/lib/src/services/version_provider.dart b/lib/src/services/version_provider.dart new file mode 100644 index 00000000..fbb55143 --- /dev/null +++ b/lib/src/services/version_provider.dart @@ -0,0 +1,111 @@ +import 'dart:convert'; + +import 'package:nextcloud_cookbook_flutter/src/models/app_authentication.dart'; +import 'package:nextcloud_cookbook_flutter/src/services/user_repository.dart'; + +class VersionProvider { + ApiVersion _currentApiVersion; + bool warningWasShown = false; + + Future fetchApiVersion() async { + warningWasShown = false; + + AppAuthentication appAuthentication = + UserRepository().getCurrentAppAuthentication(); + + var response = await appAuthentication.authenticatedClient + .get("${appAuthentication.server}/index.php/apps/cookbook/api/version"); + + if (response.statusCode == 200 && + !response.data.toString().startsWith("")) { + try { + _currentApiVersion = ApiVersion.decodeJsonApiVersion(response.data); + } catch (e) { + _currentApiVersion = ApiVersion(0, 0, 0, 0, 0); + _currentApiVersion.loadFailureMessage = e.toString(); + } + } else { + _currentApiVersion = ApiVersion(0, 0, 0, 0, 0); + } + + return _currentApiVersion; + } + + ApiVersion getApiVersion() { + return _currentApiVersion; + } +} + +class ApiVersion { + static const int CONFIRMED_MAJOR_API_VERSION = 0; + static const int CONFIRMED_MINOR_API_VERSION = 1; + + final int majorApiVersion; + final int minorApiVersion; + final int majorAppVersion; + final int minorAppVersion; + final int patchAppVersion; + + String loadFailureMessage = ""; + + ApiVersion( + this.majorApiVersion, + this.minorApiVersion, + this.majorAppVersion, + this.minorAppVersion, + this.patchAppVersion, + ); + + static ApiVersion decodeJsonApiVersion(jsonString) { + Map data = json.decode(jsonString); + + if (!(data.containsKey("cookbook_version") && + data.containsKey("api_version"))) { + throw Exception("Required Fields not present!\n$jsonString"); + } + + List appVersion = data["cookbook_version"].cast(); + var apiVersion = data["api_version"]; + + if (!(appVersion.length == 3 && + apiVersion.containsKey("major") && + apiVersion.containsKey("minor"))) { + throw Exception("Required Fields not present!\n$jsonString"); + } + + return ApiVersion( + apiVersion["major"], + apiVersion["minor"], + appVersion[0], + appVersion[1], + appVersion[2], + ); + } + + /// Returns a VersionCode that indicates the app which endpoints to call. + /// Versions only need to be adapted if backwards comparability is required. + AndroidApiVersion getAndroidVersion() { + if (majorApiVersion == 0 && minorApiVersion == 0) { + return AndroidApiVersion.BEFORE_API_ENDPOINT; + } else { + return AndroidApiVersion.CATEGORY_API_TRANSITION; + } + } + + bool isVersionAboveConfirmed() { + if (majorApiVersion > CONFIRMED_MAJOR_API_VERSION || + (majorApiVersion == CONFIRMED_MAJOR_API_VERSION && + minorApiVersion > CONFIRMED_MINOR_API_VERSION)) { + return true; + } else { + return false; + } + } + + @override + String toString() { + return "ApiVersion: $majorApiVersion.$minorApiVersion AppVersion: $majorAppVersion.$minorAppVersion.$patchAppVersion"; + } +} + +enum AndroidApiVersion { BEFORE_API_ENDPOINT, CATEGORY_API_TRANSITION } diff --git a/lib/src/widget/api_version_warning.dart b/lib/src/widget/api_version_warning.dart new file mode 100644 index 00000000..063849df --- /dev/null +++ b/lib/src/widget/api_version_warning.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:nextcloud_cookbook_flutter/src/services/user_repository.dart'; +import 'package:nextcloud_cookbook_flutter/src/services/version_provider.dart'; + +class ApiVersionWarning extends StatelessWidget { + @override + Widget build(BuildContext context) { + VersionProvider versionProvider = UserRepository().versionProvider; + ApiVersion apiVersion = versionProvider.getApiVersion(); + + if (!versionProvider.warningWasShown) { + versionProvider.warningWasShown = true; + Future.delayed(const Duration(milliseconds: 100), () { + if (apiVersion.loadFailureMessage.isNotEmpty) { + Scaffold.of(context).showSnackBar( + SnackBar( + content: Text( + translate( + "categories.errors.api_version_check_failed", + args: {"error_msg": apiVersion.loadFailureMessage}, + ), + ), + backgroundColor: Colors.red, + ), + ); + } else if (apiVersion.isVersionAboveConfirmed()) { + Scaffold.of(context).showSnackBar( + SnackBar( + content: Text( + translate( + "categories.errors.api_version_above_confirmed", + args: { + "version": apiVersion.majorApiVersion.toString() + + "." + + apiVersion.minorApiVersion.toString() + }, + ), + ), + backgroundColor: Colors.orange, + ), + ); + } + }); + } + return Container(); + } +} diff --git a/lib/src/widget/authentication_cached_network_image.dart b/lib/src/widget/authentication_cached_network_image.dart index 19c4352b..95b69bcf 100644 --- a/lib/src/widget/authentication_cached_network_image.dart +++ b/lib/src/widget/authentication_cached_network_image.dart @@ -33,7 +33,7 @@ class AuthenticationCachedNetworkImage extends StatelessWidget { height: height, fit: boxFit, imageUrl: - '${appAuthentication.server}/apps/cookbook/recipes/$imageId/image?$imageSettings', + '${appAuthentication.server}/index.php/apps/cookbook/recipes/$imageId/image?$imageSettings', httpHeaders: { "authorization": appAuthentication.basicAuth, }, diff --git a/pubspec.yaml b/pubspec.yaml index 15f83d4d..6ee57080 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.6+12 +version: 0.3.7+13 environment: sdk: ">=2.6.0 <3.0.0"