feat: 4423 - specific "Not connected to internet" displayed error (#4455)

* feat: 4423 - specific "Not connected to internet" displayed error

Impacted files:
* `barcode_product_query.dart`: removed useless `try` as already `catch`'ed
* `continuous_scan_model.dart`: removed the `codeInvalid` case that could never happen
* `fetched_product.dart`: refactored with explicit constructors and additional exception and connectivity fields; removed the `codeInvalid` case that could never happen
* `new_product_page.dart`: minor refactoring
* `product_dialog_helper.dart`: removed the `codeInvalid` case that could never happen; minor refactoting
* `product_list_item_simple.dart`: removed the `codeInvalid` case that could never happen
* `product_loader_page.dart`: removed useless `try` as already `catch`'ed
* `product_refresher.dart`: added a specific "You're not connected to the internet" error message; refactored using more `FetchedProduct`; removed useless method
* `pubspec.lock`: wtf
* `pubspec.yaml`: added package `connectivity_plus`
* `question_card.dart`: refactored using `FetchedProduct`

* feat: 4423 - fixed pubspec.yaml

* feat: 4423 - new "server down" message after a ping attempt

Impacted files:
* `fetched_product.dart`: added field `failedPingedHost` where we store the host that we couldn't ping
* `generated_plugin_registrant.cc`: wtf
* `generated_plugins.cmake`: wtf
* `GeneratedPluginRegistrant.swift`: wtf
* `main.dart`: registered `DartPingIOS`
* `product_refresher.dart`: now trying to ping the server if exception and connection
* `pubspec.lock`: wtf
* `pubspec.yaml: added packages `dart_ping` and `dart_ping_ios`

* feat: 4423 - stupid lint check part 1

* feat: 4423 - stupid lint check part 2

* feat: 4423 - stupid lint check part 3

* feat: 4423 - stupid lint check part 5

* feat: 4423 - localizations

Impacted files:
* `app_en.arb`: 4 new labels when we couldn't retrieve a product (not found, no internet, server down, server error)
* `product_refresher.dart`: used the new labels
This commit is contained in:
monsieurtanuki
2023-08-16 12:01:25 +02:00
committed by GitHub
parent ff10b44c70
commit f79bae611f
16 changed files with 243 additions and 106 deletions

View File

@ -224,9 +224,6 @@ class ContinuousScanModel with ChangeNotifier {
case FetchedProductStatus.internetError:
_setBarcodeState(barcode, ScannedProductState.ERROR_INTERNET);
return;
case FetchedProductStatus.codeInvalid:
_setBarcodeState(barcode, ScannedProductState.ERROR_INVALID_CODE);
return;
case FetchedProductStatus.userCancelled:
// we do nothing
return;
@ -247,9 +244,6 @@ class ContinuousScanModel with ChangeNotifier {
case FetchedProductStatus.internetError:
_setBarcodeState(barcode, ScannedProductState.ERROR_INTERNET);
return;
case FetchedProductStatus.codeInvalid:
_setBarcodeState(barcode, ScannedProductState.ERROR_INVALID_CODE);
return;
case FetchedProductStatus.userCancelled:
// we do nothing
return;

View File

@ -1,28 +1,63 @@
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:openfoodfacts/openfoodfacts.dart';
/// Status of a "fetch [Product]" operation
enum FetchedProductStatus {
// found locally or from internet
ok,
internetNotFound,
internetError,
userCancelled,
codeInvalid,
// TODO(monsieurtanuki): time-out
}
/// A [Product] that we tried to fetch, but was it successful?..
class FetchedProduct {
// The reason behind the "ignore": I want to force "product" to be not null
FetchedProduct(final Product product)
// ignore: prefer_initializing_formals
: product = product,
status = FetchedProductStatus.ok;
const FetchedProduct._({
required this.status,
this.product,
this.connectivityResult,
this.exceptionString,
this.failedPingedHost,
});
/// When the "fetch product" operation didn't go well (no status "ok" here)
FetchedProduct.error(this.status)
: product = null,
assert(status != FetchedProductStatus.ok);
// The reason behind the "ignore": I want to force "product" to be not null
const FetchedProduct.found(final Product product)
// ignore: prefer_initializing_formals
: this._(
status: FetchedProductStatus.ok,
product: product,
);
/// The internet Product search said it couldn't find the product.
const FetchedProduct.internetNotFound()
: this._(status: FetchedProductStatus.internetNotFound);
/// The user cancelled the operation.
const FetchedProduct.userCancelled()
: this._(status: FetchedProductStatus.userCancelled);
/// When the "fetch product" operation had an internet error.
const FetchedProduct.error({
required final String exceptionString,
required final ConnectivityResult connectivityResult,
final String? failedPingedHost,
}) : this._(
status: FetchedProductStatus.internetError,
connectivityResult: connectivityResult,
exceptionString: exceptionString,
failedPingedHost: failedPingedHost,
);
final Product? product;
final FetchedProductStatus status;
/// When relevant, result of the connectivity check.
final ConnectivityResult? connectivityResult;
/// When relevant, string of the exception.
final String? exceptionString;
/// When relevant, host of the query that we couldn't even ping.
final String? failedPingedHost;
}

View File

@ -2272,6 +2272,32 @@
"@contrast_low": {
"description": "Low Contrast Text Color"
},
"product_refresher_internet_not_found": "Product not found!",
"@product_refresher_internet_not_found": {
"description": "When refreshing a product that does not exist on the server. Label is the body of a dialog."
},
"product_refresher_internet_not_connected": "You are not connected to internet!",
"@product_refresher_internet_not_connected": {
"description": "When refreshing a product and you're not even connected to internet. Label is the body of a dialog."
},
"product_refresher_internet_no_ping": "Server down ({host})",
"@product_refresher_internet_no_ping": {
"description": "When refreshing a product and you cannot even ping the server. Label is the body of a dialog.",
"placeholders": {
"host": {
"type": "String?"
}
}
},
"product_refresher_internet_error": "Server error ({exception})",
"@product_refresher_internet_error": {
"description": "When refreshing a product and the server returned an exception. Label is the body of a dialog.",
"placeholders": {
"exception": {
"type": "String?"
}
}
},
"product_loader_not_found_title": "Product not found!",
"@product_loader_not_found_title": {
"description": "When fetching a product opened via a link and it doesn't exist"

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io';
import 'package:app_store_shared/app_store_shared.dart';
import 'package:dart_ping_ios/dart_ping_ios.dart';
import 'package:device_preview/device_preview.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -120,6 +121,7 @@ Future<bool> _init1() async {
return false;
}
DartPingIOS.register();
await SmoothServices().init(GlobalVars.appStore);
await setupAppNetworkConfig();
await UserManagementProvider.mountCredentials();

View File

@ -3,6 +3,7 @@ import 'package:openfoodfacts/openfoodfacts.dart';
import 'package:provider/provider.dart';
import 'package:shimmer/shimmer.dart';
import 'package:smooth_app/cards/product_cards/product_title_card.dart';
import 'package:smooth_app/data_models/fetched_product.dart';
import 'package:smooth_app/database/dao_product.dart';
import 'package:smooth_app/database/local_database.dart';
import 'package:smooth_app/generic_lib/design_constants.dart';
@ -24,22 +25,22 @@ class QuestionCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final Future<Product?> productFuture = _getProduct(
final Future<FetchedProduct> productFuture = _getProduct(
question.barcode!,
context.read<LocalDatabase>(),
);
final Size screenSize = MediaQuery.of(context).size;
return FutureBuilder<Product?>(
return FutureBuilder<FetchedProduct>(
future: productFuture,
builder: (
BuildContext context,
AsyncSnapshot<Product?> snapshot,
AsyncSnapshot<FetchedProduct> snapshot,
) {
Product? product;
if (snapshot.connectionState == ConnectionState.done) {
product = snapshot.data;
product = snapshot.data?.product;
// TODO(monsieurtanuki): do something aggressive if product is null here and we don't have a fallback value - like an error widget
}
// fallback version
@ -131,13 +132,13 @@ class QuestionCard extends StatelessWidget {
);
}
Future<Product?> _getProduct(
Future<FetchedProduct> _getProduct(
final String barcode,
final LocalDatabase localDatabase,
) async {
final Product? result = await DaoProduct(localDatabase).get(barcode);
if (result != null) {
return result;
return FetchedProduct.found(result);
}
return ProductRefresher().silentFetchAndRefresh(
barcode: question.barcode!,

View File

@ -38,7 +38,7 @@ class ProductDialogHelper {
Future<FetchedProduct> openBestChoice() async {
final Product? product = await DaoProduct(localDatabase).get(barcode);
if (product != null) {
return FetchedProduct(product);
return FetchedProduct.found(product);
}
return openUniqueProductSearch();
}
@ -52,7 +52,7 @@ class ProductDialogHelper {
isScanned: false,
).getFetchedProduct(),
title: '${AppLocalizations.of(context).looking_for}: $barcode') ??
FetchedProduct.error(FetchedProductStatus.userCancelled);
const FetchedProduct.userCancelled();
void _openProductNotFoundDialog() => showDialog<Widget>(
context: context,
@ -175,9 +175,6 @@ class ProductDialogHelper {
case FetchedProductStatus.internetNotFound:
_openProductNotFoundDialog();
return;
case FetchedProductStatus.codeInvalid:
_openErrorMessage(appLocalizations.barcode_invalid_error);
return;
}
}
}

View File

@ -100,8 +100,6 @@ class _ProductListItemSimpleState extends State<ProductListItemSimple> {
switch (_model.downloadingStatus) {
case null:
break;
case FetchedProductStatus.codeInvalid:
return appLocalizations.barcode_invalid_error;
case FetchedProductStatus.internetNotFound:
return appLocalizations.product_internet_error;
default:

View File

@ -1,8 +1,11 @@
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:dart_ping/dart_ping.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_svg/svg.dart';
import 'package:openfoodfacts/openfoodfacts.dart';
import 'package:provider/provider.dart';
import 'package:smooth_app/data_models/fetched_product.dart';
import 'package:smooth_app/database/dao_product.dart';
import 'package:smooth_app/database/local_database.dart';
import 'package:smooth_app/generic_lib/dialogs/smooth_alert_dialog.dart';
@ -93,14 +96,11 @@ class ProductRefresher {
/// Fetches the product from the server and refreshes the local database.
///
/// Silent version.
Future<Product?> silentFetchAndRefresh({
Future<FetchedProduct> silentFetchAndRefresh({
required final String barcode,
required final LocalDatabase localDatabase,
}) async {
final _MetaProductRefresher meta =
await _fetchAndRefresh(localDatabase, barcode);
return meta.product;
}
}) async =>
_fetchAndRefresh(localDatabase, barcode);
/// Fetches the products from the server and refreshes the local database.
///
@ -111,23 +111,6 @@ class ProductRefresher {
}) async =>
_fetchAndRefreshList(localDatabase, barcodes);
/// Fetches the product from the server and refreshes the local database.
/// In the case of an error, it will be send throw an [Exception]
/// Silent version.
Future<Product?> silentFetchAndRefreshWithException({
required final String barcode,
required final LocalDatabase localDatabase,
}) async {
final _MetaProductRefresher meta =
await _fetchAndRefresh(localDatabase, barcode);
if (meta.error != null) {
throw Exception(meta.error);
}
return meta.product;
}
/// Fetches the product from the server and refreshes the local database.
///
/// With a waiting dialog.
@ -139,18 +122,45 @@ class ProductRefresher {
final LocalDatabase localDatabase = widget.context.read<LocalDatabase>();
final AppLocalizations appLocalizations =
AppLocalizations.of(widget.context);
final _MetaProductRefresher? fetchAndRefreshed =
await LoadingDialog.run<_MetaProductRefresher>(
final FetchedProduct? fetchAndRefreshed =
await LoadingDialog.run<FetchedProduct>(
future: _fetchAndRefresh(localDatabase, barcode),
context: widget.context,
title: appLocalizations.refreshing_product,
);
if (fetchAndRefreshed == null) {
// the user probably cancelled
return false;
}
if (fetchAndRefreshed.product == null) {
if (widget.mounted) {
await LoadingDialog.error(context: widget.context);
String getTitle(final FetchedProduct fetchedProduct) {
switch (fetchAndRefreshed.status) {
case FetchedProductStatus.ok:
return 'Not supposed to happen...';
case FetchedProductStatus.userCancelled:
return 'Not supposed to happen either...';
case FetchedProductStatus.internetNotFound:
return appLocalizations.product_refresher_internet_not_found;
case FetchedProductStatus.internetError:
if (fetchAndRefreshed.connectivityResult ==
ConnectivityResult.none) {
return appLocalizations
.product_refresher_internet_not_connected;
}
if (fetchAndRefreshed.failedPingedHost != null) {
return appLocalizations.product_refresher_internet_no_ping(
fetchAndRefreshed.failedPingedHost);
}
return appLocalizations.product_refresher_internet_no_ping(
fetchAndRefreshed.exceptionString);
}
}
await LoadingDialog.error(
context: widget.context,
title: getTitle(fetchAndRefreshed),
);
}
return false;
}
@ -165,7 +175,7 @@ class ProductRefresher {
return true;
}
Future<_MetaProductRefresher> _fetchAndRefresh(
Future<FetchedProduct> _fetchAndRefresh(
final LocalDatabase localDatabase,
final String barcode,
) async {
@ -177,12 +187,30 @@ class ProductRefresher {
await DaoProduct(localDatabase).put(result.product!);
localDatabase.upToDate.setLatestDownloadedProduct(result.product!);
localDatabase.notifyListeners();
return _MetaProductRefresher.product(result.product);
return FetchedProduct.found(result.product!);
}
return const _MetaProductRefresher.error(null);
return const FetchedProduct.internetNotFound();
} catch (e) {
Logs.e('Refresh from server error', ex: e);
return _MetaProductRefresher.error(e.toString());
final ConnectivityResult connectivityResult =
await Connectivity().checkConnectivity();
if (connectivityResult == ConnectivityResult.none) {
return FetchedProduct.error(
exceptionString: e.toString(),
connectivityResult: connectivityResult,
);
}
// TODO(monsieurtanuki): make things cleaner with off-dart
final String host =
OpenFoodAPIConfiguration.globalQueryType == QueryType.PROD
? OpenFoodAPIConfiguration.uriProdHost
: OpenFoodAPIConfiguration.uriTestHost;
final PingData result = await Ping(host, count: 1).stream.first;
return FetchedProduct.error(
exceptionString: e.toString(),
connectivityResult: connectivityResult,
failedPingedHost: result.error == null ? null : host,
);
}
}
@ -212,12 +240,3 @@ class ProductRefresher {
}
}
}
class _MetaProductRefresher {
const _MetaProductRefresher.error(this.error) : product = null;
const _MetaProductRefresher.product(this.product) : error = null;
final String? error;
final Product? product;
}

View File

@ -173,7 +173,7 @@ class _ProductPageState extends State<ProductPage>
final LocalDatabase localDatabase = context.read<LocalDatabase>();
final DaoProductList daoProductList = DaoProductList(localDatabase);
return RefreshIndicator(
onRefresh: () => ProductRefresher().fetchAndRefresh(
onRefresh: () async => ProductRefresher().fetchAndRefresh(
barcode: barcode,
widget: this,
),

View File

@ -3,6 +3,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_svg/svg.dart';
import 'package:openfoodfacts/openfoodfacts.dart';
import 'package:provider/provider.dart';
import 'package:smooth_app/data_models/fetched_product.dart';
import 'package:smooth_app/database/local_database.dart';
import 'package:smooth_app/generic_lib/buttons/smooth_large_button_with_icon.dart';
import 'package:smooth_app/generic_lib/design_constants.dart';
@ -42,28 +43,31 @@ class _ProductLoaderPageState extends State<ProductLoaderPage> {
_state = _ProductLoaderState.loading;
});
try {
final Product? product =
await ProductRefresher().silentFetchAndRefreshWithException(
final FetchedProduct fetchedProduct =
await ProductRefresher().silentFetchAndRefresh(
barcode: widget.barcode,
localDatabase: context.read<LocalDatabase>(),
);
if (product != null && mounted) {
if (mounted) {
if (fetchedProduct.product != null) {
navigator.pushReplacement(
AppRoutes.PRODUCT(
widget.barcode,
heroTag: 'product_${widget.barcode}',
),
extra: product,
extra: fetchedProduct.product,
);
} else {
return;
}
if (fetchedProduct.status == FetchedProductStatus.internetNotFound) {
setState(() {
_state = _ProductLoaderState.productNotFound;
});
return;
}
} catch (err) {
setState(() {
// TODO(monsieurtanuki): put more details from FetchedProduct?
_state = _ProductLoaderState.serverError;
});
}

View File

@ -1,6 +1,5 @@
import 'dart:async';
import 'package:openfoodfacts/openfoodfacts.dart';
import 'package:smooth_app/data_models/fetched_product.dart';
import 'package:smooth_app/database/dao_product.dart';
import 'package:smooth_app/helpers/analytics_helper.dart';
@ -19,21 +18,18 @@ class BarcodeProductQuery {
final bool isScanned;
Future<FetchedProduct> getFetchedProduct() async {
try {
ProductQuery.setUserAgentComment(isScanned ? 'scan' : 'search');
final Product? product = await ProductRefresher().silentFetchAndRefresh(
final FetchedProduct fetchedProduct =
await ProductRefresher().silentFetchAndRefresh(
barcode: barcode,
localDatabase: daoProduct.localDatabase,
);
if (product != null) {
return FetchedProduct(product);
}
} catch (e) {
return FetchedProduct.error(FetchedProductStatus.internetError);
} finally {
ProductQuery.setUserAgentComment('');
if (fetchedProduct.product != null) {
return fetchedProduct;
}
if (fetchedProduct.status == FetchedProductStatus.internetNotFound) {
if (isScanned) {
AnalyticsHelper.trackEvent(
AnalyticsEvent.couldNotScanProduct,
@ -45,7 +41,8 @@ class BarcodeProductQuery {
barcode: barcode,
);
}
}
return FetchedProduct.error(FetchedProductStatus.internetNotFound);
return fetchedProduct;
}
}

View File

@ -6,6 +6,7 @@ import FlutterMacOS
import Foundation
import audioplayers_darwin
import connectivity_plus
import device_info_plus
import file_selector_macos
import flutter_secure_storage_macos
@ -22,6 +23,7 @@ import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin"))
ConnectivityPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlugin"))
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))

View File

@ -293,6 +293,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.17.1"
connectivity_plus:
dependency: "direct main"
description:
name: connectivity_plus
sha256: "77a180d6938f78ca7d2382d2240eb626c0f6a735d0bfdce227d8ffb80f95c48b"
url: "https://pub.dev"
source: hosted
version: "4.0.2"
connectivity_plus_platform_interface:
dependency: transitive
description:
name: connectivity_plus_platform_interface
sha256: cf1d1c28f4416f8c654d7dc3cd638ec586076255d407cef3ddbdaf178272a71a
url: "https://pub.dev"
source: hosted
version: "1.2.4"
convert:
dependency: transitive
description:
@ -341,6 +357,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.5"
dart_ping:
dependency: "direct main"
description:
name: dart_ping
sha256: dd3a93d9b986565cb2fadd0c9277cf9880298634ccc9588e353e63c6f736a386
url: "https://pub.dev"
source: hosted
version: "9.0.0"
dart_ping_ios:
dependency: "direct main"
description:
name: dart_ping_ios
sha256: ba60bcd1ef8f13d564e9490197fb32c34d38fd1c10a890143a52f5b71d82ea95
url: "https://pub.dev"
source: hosted
version: "4.0.0"
dart_style:
dependency: transitive
description:
@ -363,6 +395,14 @@ packages:
relative: true
source: path
version: "0.0.0"
dbus:
dependency: transitive
description:
name: dbus
sha256: "6f07cba3f7b3448d42d015bfd3d53fe12e5b36da2423f23838efc1d5fb31a263"
url: "https://pub.dev"
source: hosted
version: "0.7.8"
device_frame:
dependency: transitive
description:
@ -517,6 +557,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.1"
flutter_icmp_ping:
dependency: transitive
description:
name: flutter_icmp_ping
sha256: a06c2255a857c8f9d1b0a68f546b113557e48e7a543f91e38bd66aeab296f3a6
url: "https://pub.dev"
source: hosted
version: "3.1.2"
flutter_image_compress:
dependency: "direct main"
description:
@ -1006,6 +1054,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
nm:
dependency: transitive
description:
name: nm
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
url: "https://pub.dev"
source: hosted
version: "0.5.0"
openfoodfacts:
dependency: "direct main"
description:

View File

@ -70,6 +70,9 @@ dependencies:
webview_flutter: 3.0.4
flutter_custom_tabs: ^1.0.4
flutter_image_compress: 2.0.4
connectivity_plus: ^4.0.2
dart_ping: 9.0.0
dart_ping_ios: 4.0.0
# According to the build variant, only one "app store" implementation must be added when building a release
# Call "flutter pub remove xxxx" to remove unused dependencies
@ -97,7 +100,6 @@ dependencies:
path: ../scanner/zxing
openfoodfacts: 2.10.0
# openfoodfacts:
# path: ../../../openfoodfacts-dart

View File

@ -7,6 +7,7 @@
#include "generated_plugin_registrant.h"
#include <audioplayers_windows/audioplayers_windows_plugin.h>
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
@ -18,6 +19,8 @@
void RegisterPlugins(flutter::PluginRegistry* registry) {
AudioplayersWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin"));
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(

View File

@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_windows
connectivity_plus
file_selector_windows
flutter_secure_storage_windows
permission_handler_windows