added support for web ; bumped dart_core_sdk version to 1.13.2

This commit is contained in:
2026-06-24 17:05:42 +02:00
parent fff5617757
commit 3efe4dcca6
20 changed files with 734 additions and 30 deletions

1
.gitignore vendored
View File

@@ -32,6 +32,7 @@ migrate_working_dir/
.pub-cache/
.pub/
/build/
pubspec.lock
# Symbolication related
app.*.symbols

View File

@@ -0,0 +1,374 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:intl/intl.dart' as intl;
import 'app_localizations_en.dart';
import 'app_localizations_fr.dart';
// ignore_for_file: type=lint
/// Callers can lookup localized strings with an instance of AppLocalizations
/// returned by `AppLocalizations.of(context)`.
///
/// Applications need to include `AppLocalizations.delegate()` in their app's
/// `localizationDelegates` list, and the locales they support in the app's
/// `supportedLocales` list. For example:
///
/// ```dart
/// import 'l10n/app_localizations.dart';
///
/// return MaterialApp(
/// localizationsDelegates: AppLocalizations.localizationsDelegates,
/// supportedLocales: AppLocalizations.supportedLocales,
/// home: MyApplicationHome(),
/// );
/// ```
///
/// ## Update pubspec.yaml
///
/// Please make sure to update your pubspec.yaml to include the following
/// packages:
///
/// ```yaml
/// dependencies:
/// # Internationalization support.
/// flutter_localizations:
/// sdk: flutter
/// intl: any # Use the pinned version from flutter_localizations
///
/// # Rest of dependencies
/// ```
///
/// ## iOS Applications
///
/// iOS applications define key application metadata, including supported
/// locales, in an Info.plist file that is built into the application bundle.
/// To configure the locales supported by your app, youll need to edit this
/// file.
///
/// First, open your projects ios/Runner.xcworkspace Xcode workspace file.
/// Then, in the Project Navigator, open the Info.plist file under the Runner
/// projects Runner folder.
///
/// Next, select the Information Property List item, select Add Item from the
/// Editor menu, then select Localizations from the pop-up menu.
///
/// Select and expand the newly-created Localizations item then, for each
/// locale your application supports, add a new item and select the locale
/// you wish to add from the pop-up menu in the Value field. This list should
/// be consistent with the languages listed in the AppLocalizations.supportedLocales
/// property.
abstract class AppLocalizations {
AppLocalizations(String locale)
: localeName = intl.Intl.canonicalizedLocale(locale.toString());
final String localeName;
static AppLocalizations? of(BuildContext context) {
return Localizations.of<AppLocalizations>(context, AppLocalizations);
}
static const LocalizationsDelegate<AppLocalizations> delegate =
_AppLocalizationsDelegate();
/// A list of this localizations delegate along with the default localizations
/// delegates.
///
/// Returns a list of localizations delegates containing this delegate along with
/// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
/// and GlobalWidgetsLocalizations.delegate.
///
/// Additional delegates can be added by appending to this list in
/// MaterialApp. This list does not have to be used at all if a custom list
/// of delegates is preferred or required.
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates =
<LocalizationsDelegate<dynamic>>[
delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
];
/// A list of this localizations delegate's supported locales.
static const List<Locale> supportedLocales = <Locale>[
Locale('en'),
Locale('fr'),
];
/// No description provided for @addPhoto.
///
/// In en, this message translates to:
/// **'Add photo'**
String get addPhoto;
/// No description provided for @anomaly.
///
/// In en, this message translates to:
/// **'Anomaly'**
String get anomaly;
/// No description provided for @anomalyBadHandlingUnit.
///
/// In en, this message translates to:
/// **'Declined - Package in bad condition'**
String get anomalyBadHandlingUnit;
/// No description provided for @anomalyCustomerRefused.
///
/// In en, this message translates to:
/// **'Declined - Customer declined'**
String get anomalyCustomerRefused;
/// No description provided for @anomalyCustomerUnavailable.
///
/// In en, this message translates to:
/// **'Unavailable - Customer unavailable'**
String get anomalyCustomerUnavailable;
/// No description provided for @anomalyOther.
///
/// In en, this message translates to:
/// **'Other reason'**
String get anomalyOther;
/// No description provided for @anomalyReason.
///
/// In en, this message translates to:
/// **'Anomaly reason'**
String get anomalyReason;
/// No description provided for @cancel.
///
/// In en, this message translates to:
/// **'Cancel'**
String get cancel;
/// No description provided for @cannotNotifyDelivery.
///
/// In en, this message translates to:
/// **'Cannot notify delivery'**
String get cannotNotifyDelivery;
/// No description provided for @copied.
///
/// In en, this message translates to:
/// **'copied'**
String get copied;
/// No description provided for @dateOn.
///
/// In en, this message translates to:
/// **'the {date} at {time}'**
String dateOn(DateTime date, DateTime time);
/// No description provided for @deliveryOk.
///
/// In en, this message translates to:
/// **'Delivery notified as OK'**
String get deliveryOk;
/// No description provided for @deliveryWithAnomaly.
///
/// In en, this message translates to:
/// **'Delivery notified with anomaly'**
String get deliveryWithAnomaly;
/// No description provided for @dimensions.
///
/// In en, this message translates to:
/// **'Dimensions (H x W x L)'**
String get dimensions;
/// No description provided for @error.
///
/// In en, this message translates to:
/// **'Error'**
String get error;
/// No description provided for @handlingUnitIdentifier.
///
/// In en, this message translates to:
/// **'Handling unit Identifier'**
String get handlingUnitIdentifier;
/// No description provided for @handlingUnitType.
///
/// In en, this message translates to:
/// **'Handling unit type'**
String get handlingUnitType;
/// No description provided for @informations.
///
/// In en, this message translates to:
/// **'Informations'**
String get informations;
/// No description provided for @items.
///
/// In en, this message translates to:
/// **'Items'**
String get items;
/// No description provided for @itemId.
///
/// In en, this message translates to:
/// **'Item ID'**
String get itemId;
/// No description provided for @missingSendingFilesRights.
///
/// In en, this message translates to:
/// **'You are not allowed to send files in this project'**
String get missingSendingFilesRights;
/// No description provided for @nextDelivery.
///
/// In en, this message translates to:
/// **'Next delivery'**
String get nextDelivery;
/// No description provided for @noHandlingUnitFound.
///
/// In en, this message translates to:
/// **'No handling unit found'**
String get noHandlingUnitFound;
/// No description provided for @ok.
///
/// In en, this message translates to:
/// **'OK'**
String get ok;
/// No description provided for @or.
///
/// In en, this message translates to:
/// **'OR'**
String get or;
/// No description provided for @organization.
///
/// In en, this message translates to:
/// **'Organization'**
String get organization;
/// No description provided for @pleaseTryAgain.
///
/// In en, this message translates to:
/// **'Please try again'**
String get pleaseTryAgain;
/// No description provided for @pleaseIndicateAnomaly.
///
/// In en, this message translates to:
/// **'Please indicate if the handling unit has an anomaly or not'**
String get pleaseIndicateAnomaly;
/// No description provided for @project.
///
/// In en, this message translates to:
/// **'Project'**
String get project;
/// No description provided for @quantity.
///
/// In en, this message translates to:
/// **'Quantity'**
String get quantity;
/// No description provided for @scanBarcode.
///
/// In en, this message translates to:
/// **'SCAN BARCODE'**
String get scanBarcode;
/// No description provided for @search.
///
/// In en, this message translates to:
/// **'Search'**
String get search;
/// No description provided for @selectContext.
///
/// In en, this message translates to:
/// **'Select the desired organization and project of your delivery'**
String get selectContext;
/// No description provided for @selectHandlingUnit.
///
/// In en, this message translates to:
/// **'Select a handling unit by typing or scanning its identifier'**
String get selectHandlingUnit;
/// No description provided for @signature.
///
/// In en, this message translates to:
/// **'Signature'**
String get signature;
/// No description provided for @start.
///
/// In en, this message translates to:
/// **'Start tour'**
String get start;
/// No description provided for @tryAgain.
///
/// In en, this message translates to:
/// **'Try again'**
String get tryAgain;
/// No description provided for @trackingNumber.
///
/// In en, this message translates to:
/// **'Tracking N°'**
String get trackingNumber;
/// No description provided for @validDelivery.
///
/// In en, this message translates to:
/// **'OK'**
String get validDelivery;
/// No description provided for @weight.
///
/// In en, this message translates to:
/// **'Weight'**
String get weight;
}
class _AppLocalizationsDelegate
extends LocalizationsDelegate<AppLocalizations> {
const _AppLocalizationsDelegate();
@override
Future<AppLocalizations> load(Locale locale) {
return SynchronousFuture<AppLocalizations>(lookupAppLocalizations(locale));
}
@override
bool isSupported(Locale locale) =>
<String>['en', 'fr'].contains(locale.languageCode);
@override
bool shouldReload(_AppLocalizationsDelegate old) => false;
}
AppLocalizations lookupAppLocalizations(Locale locale) {
// Lookup logic when only language code is specified.
switch (locale.languageCode) {
case 'en':
return AppLocalizationsEn();
case 'fr':
return AppLocalizationsFr();
}
throw FlutterError(
'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely '
'an issue with the localizations generation tool. Please file an issue '
'on GitHub with a reproducible sample app and the gen-l10n configuration '
'that was used.',
);
}

View File

@@ -0,0 +1,141 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for English (`en`).
class AppLocalizationsEn extends AppLocalizations {
AppLocalizationsEn([String locale = 'en']) : super(locale);
@override
String get addPhoto => 'Add photo';
@override
String get anomaly => 'Anomaly';
@override
String get anomalyBadHandlingUnit => 'Declined - Package in bad condition';
@override
String get anomalyCustomerRefused => 'Declined - Customer declined';
@override
String get anomalyCustomerUnavailable => 'Unavailable - Customer unavailable';
@override
String get anomalyOther => 'Other reason';
@override
String get anomalyReason => 'Anomaly reason';
@override
String get cancel => 'Cancel';
@override
String get cannotNotifyDelivery => 'Cannot notify delivery';
@override
String get copied => 'copied';
@override
String dateOn(DateTime date, DateTime time) {
final intl.DateFormat dateDateFormat = intl.DateFormat.yMd(localeName);
final String dateString = dateDateFormat.format(date);
final intl.DateFormat timeDateFormat = intl.DateFormat.jm(localeName);
final String timeString = timeDateFormat.format(time);
return 'the $dateString at $timeString';
}
@override
String get deliveryOk => 'Delivery notified as OK';
@override
String get deliveryWithAnomaly => 'Delivery notified with anomaly';
@override
String get dimensions => 'Dimensions (H x W x L)';
@override
String get error => 'Error';
@override
String get handlingUnitIdentifier => 'Handling unit Identifier';
@override
String get handlingUnitType => 'Handling unit type';
@override
String get informations => 'Informations';
@override
String get items => 'Items';
@override
String get itemId => 'Item ID';
@override
String get missingSendingFilesRights =>
'You are not allowed to send files in this project';
@override
String get nextDelivery => 'Next delivery';
@override
String get noHandlingUnitFound => 'No handling unit found';
@override
String get ok => 'OK';
@override
String get or => 'OR';
@override
String get organization => 'Organization';
@override
String get pleaseTryAgain => 'Please try again';
@override
String get pleaseIndicateAnomaly =>
'Please indicate if the handling unit has an anomaly or not';
@override
String get project => 'Project';
@override
String get quantity => 'Quantity';
@override
String get scanBarcode => 'SCAN BARCODE';
@override
String get search => 'Search';
@override
String get selectContext =>
'Select the desired organization and project of your delivery';
@override
String get selectHandlingUnit =>
'Select a handling unit by typing or scanning its identifier';
@override
String get signature => 'Signature';
@override
String get start => 'Start tour';
@override
String get tryAgain => 'Try again';
@override
String get trackingNumber => 'Tracking N°';
@override
String get validDelivery => 'OK';
@override
String get weight => 'Weight';
}

View File

@@ -0,0 +1,142 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for French (`fr`).
class AppLocalizationsFr extends AppLocalizations {
AppLocalizationsFr([String locale = 'fr']) : super(locale);
@override
String get addPhoto => 'Ajouter une photo';
@override
String get anomaly => 'Anomalie';
@override
String get anomalyBadHandlingUnit => 'Refusé - Colis abimé';
@override
String get anomalyCustomerRefused => 'Refusé - Refus client';
@override
String get anomalyCustomerUnavailable => 'Absence - Client non présent';
@override
String get anomalyOther => 'Autres raisons';
@override
String get anomalyReason => 'Raison de l\'anomalie';
@override
String get cancel => 'Annuler';
@override
String get cannotNotifyDelivery => 'Impossible de notifier la livraison';
@override
String get copied => 'copié';
@override
String dateOn(DateTime date, DateTime time) {
final intl.DateFormat dateDateFormat = intl.DateFormat.yMd(localeName);
final String dateString = dateDateFormat.format(date);
final intl.DateFormat timeDateFormat = intl.DateFormat.jm(localeName);
final String timeString = timeDateFormat.format(time);
return 'le $dateString à $timeString';
}
@override
String get deliveryOk => 'Livraison déclarée conforme';
@override
String get deliveryWithAnomaly => 'Livraison déclarée en anomalie';
@override
String get dimensions => 'Dimensions (H x L x P)';
@override
String get error => 'Erreur';
@override
String get handlingUnitIdentifier => 'Identifiant du colis';
@override
String get handlingUnitType => 'Type de colis';
@override
String get informations => 'Informations';
@override
String get items => 'Articles';
@override
String get itemId => 'Article ID';
@override
String get missingSendingFilesRights =>
'Vous n\'avez pas le droit d\'envoyer de fichiers dans ce projet';
@override
String get nextDelivery => 'Livraison suivante';
@override
String get noHandlingUnitFound =>
'Aucun colis trouvé pour cet identifiant de colis.';
@override
String get ok => 'OK';
@override
String get or => 'OU';
@override
String get organization => 'Organisation';
@override
String get pleaseTryAgain => 'Veuillez réessayer';
@override
String get pleaseIndicateAnomaly =>
'Veuillez indiquer si le colis présente une anomalie ou non';
@override
String get project => 'Projet';
@override
String get quantity => 'Quantité';
@override
String get scanBarcode => 'SCANNER UN CODE BARRE';
@override
String get search => 'Rechercher';
@override
String get selectContext =>
'Sélectionner l\'organisation et le projet cible de votre livraison';
@override
String get selectHandlingUnit =>
'Sélectionner un colis en scannant ou tapant son numéro ou son identifiant tracking';
@override
String get signature => 'Signature';
@override
String get start => 'Démarrer la tournée';
@override
String get tryAgain => 'Réessayer';
@override
String get trackingNumber => 'N° Tracking';
@override
String get validDelivery => 'Valide';
@override
String get weight => 'Poids';
}

View File

@@ -10,7 +10,7 @@ import 'globals.dart';
import 'locator.dart';
import 'services/auth/auth_view_model.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:sampleapp/l10n/app_localizations.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();

View File

@@ -28,15 +28,26 @@ class ReflexRouterDelegate extends RouterDelegate
_init() async {
try {
await authService.init();
loggedIn = await authService.isLoggedIn();
bool isAuthenticated = await authService.isLoggedIn();
// If the access token check failed but we have a refresh token,
// try to silently renew before forcing the user back to the login screen.
if (!isAuthenticated &&
authService.refreshToken != null &&
authService.refreshToken!.isNotEmpty) {
try {
await authService.doRefreshToken();
authService.tokenRefresher = TokenRefresher(authService,
refreshInterval: const Duration(seconds: 60));
authService.tokenRefresher!.start();
isAuthenticated = await authService.isLoggedIn();
} catch (_) {
// Refresh token is expired or invalid — full re-login required.
}
}
loggedIn = isAuthenticated;
} catch (e) {
showDialog(
context: navigatorKey.currentContext!,
builder: (context) => AlertDialog(
title: Text('Error'),
content: Text('Error: $e'),
),
);
loggedIn = false;
}
}

View File

@@ -177,6 +177,7 @@ class AuthService {
Future<void> setAccessToken(String token) async {
final prefs = await SharedPreferences.getInstance();
prefs.setString('accessToken', token);
accessToken = token;
}

View File

@@ -1,4 +1,5 @@
import 'package:grpc/grpc.dart';
import 'package:grpc/grpc_or_grpcweb.dart';
import 'package:sampleapp/services/auth/auth.dart';
class AuthInterceptor extends ClientInterceptor {
@@ -17,15 +18,19 @@ class AuthInterceptor extends ClientInterceptor {
}
class GrpcClient {
static Client initializeClient(AuthService authService, String host, int port, Client Function(ClientChannel, List<ClientInterceptor>) clientFactory) {
static Client initializeClient(
AuthService authService,
String host,
int port,
Client Function(GrpcOrGrpcWebClientChannel, List<ClientInterceptor>)
clientFactory,
) {
final interceptor = AuthInterceptor(authService);
final channel = ClientChannel(
host,
final channel = GrpcOrGrpcWebClientChannel.toSingleEndpoint(
host: host,
port: port,
options: const ChannelOptions(
credentials: ChannelCredentials.secure(),
),
transportSecure: true,
);
return clientFactory(channel, [interceptor]);
}
}
}

View File

@@ -1,22 +1,30 @@
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:package_info_plus/package_info_plus.dart';
String getRedirectURI() {
if (kIsWeb) {
// Use the current page's origin so the redirect URI always matches
// the host and port the app is actually served on.
final base = Uri.base;
final port = base.hasPort ? ':${base.port}' : '';
return '/${base.host}$port/auth.html';
}
try {
if (Platform.isAndroid) {
return "";
} else {
return "/localhost:3000/auth.html";
return "/localhost:8080/auth.html";
}
} catch (e) {
// Must be web
return "/localhost:3000/auth.html";
return "/localhost:8080/auth.html";
}
}
Future<String> getCallbackUrlScheme() async {
if (kIsWeb) return "http";
try {
if (Platform.isAndroid) {
PackageInfo packageInfo = await PackageInfo.fromPlatform();
@@ -25,7 +33,6 @@ Future<String> getCallbackUrlScheme() async {
return "http";
}
} catch (e) {
// Must be web
return "http";
}
}

View File

@@ -4,7 +4,7 @@ import 'package:dart_core_sdk/handlingunit.pb.dart';
import 'package:sampleapp/globals.dart';
import 'package:sampleapp/widgets/components/reflex_alert.dart';
import 'package:sampleapp/widgets/components/reflex_button.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:sampleapp/l10n/app_localizations.dart';
import 'reflex_box.dart';

View File

@@ -1,7 +1,7 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_signaturepad/signaturepad.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:sampleapp/l10n/app_localizations.dart';
import '../../globals.dart';
class ReflexSignature extends StatefulWidget {

View File

@@ -5,7 +5,7 @@ import 'package:dart_core_sdk/handlingunit.pb.dart';
import 'package:dart_core_sdk/trackingInput.pbgrpc.dart';
import 'package:dart_core_sdk/transportShared.pb.dart';
import 'package:dart_core_sdk/transportShared.pbenum.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:sampleapp/l10n/app_localizations.dart';
import 'package:flutter/material.dart';
import 'package:sampleapp/globals.dart';
import 'package:sampleapp/services/gcs.dart';

View File

@@ -8,7 +8,7 @@ import 'package:sampleapp/widgets/components/reflex_circular_progress.dart';
import 'package:sampleapp/widgets/components/reflex_text_form_field.dart';
import '../../locator.dart';
import '../components/reflex_alert.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:sampleapp/l10n/app_localizations.dart';
class ScanBarcode extends StatefulWidget {
final String projectID;
@@ -40,7 +40,7 @@ class _ScanBarcodeState extends State<ScanBarcode> {
header: QueryProjectHeader(
projectID: widget.projectID,
),
iDs: [EntityID(
iDs: [QueryEntityID(
refID: _handlingUnitIDController.text,
)],
))

View File

@@ -5,7 +5,7 @@ import 'package:sampleapp/widgets/components/reflex_hu_info.dart';
import '../../globals.dart';
import '../components/reflex_alert.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:sampleapp/l10n/app_localizations.dart';
class ScanResult extends StatefulWidget {
final String refID;

View File

@@ -6,7 +6,7 @@ import 'package:sampleapp/widgets/components/reflex_button.dart';
import 'package:sampleapp/widgets/components/reflex_circular_progress.dart';
import 'package:sampleapp/widgets/components/reflex_dropdown_button_form_field.dart';
import '../../locator.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:sampleapp/l10n/app_localizations.dart';
class SelectContext extends StatefulWidget {
final void Function(String projectID) onContextSelected;

View File

@@ -10,6 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
)
set(PLUGIN_BUNDLED_LIBRARIES)

View File

@@ -9,7 +9,6 @@ import desktop_webview_window
import file_selector_macos
import flutter_web_auth_2
import package_info_plus
import path_provider_foundation
import shared_preferences_foundation
import url_launcher_macos
import window_to_front
@@ -19,7 +18,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterWebAuth2Plugin.register(with: registry.registrar(forPlugin: "FlutterWebAuth2Plugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
WindowToFrontPlugin.register(with: registry.registrar(forPlugin: "WindowToFrontPlugin"))

View File

@@ -33,7 +33,7 @@ dependencies:
sdk: flutter
dart_core_sdk:
hosted: https://git.reflex-platform.com/api/packages/reflex-platform/pub/
version: 1.10.0
version: 1.13.2
dev_dependencies:
flutter_test:

22
web/auth.html Normal file
View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<title>Authentication complete</title>
<p>Authentication is complete. If this does not happen automatically, please close the window.</p>
<script>
function postAuthenticationMessage() {
const message = {
'flutter-web-auth-2': window.location.href
};
if (window.opener) {
window.opener.postMessage(message, window.location.origin);
window.close();
} else if (window.parent && window.parent !== window) {
window.parent.postMessage(message, window.location.origin);
} else {
localStorage.setItem('flutter-web-auth-2', window.location.href);
window.close();
}
}
postAuthenticationMessage();
</script>

View File

@@ -10,6 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
)
set(PLUGIN_BUNDLED_LIBRARIES)