From edce25ec55c732d4a697774ad9cec7c49bb231ed Mon Sep 17 00:00:00 2001 From: NaiJi Date: Tue, 24 May 2022 20:45:13 +0300 Subject: [PATCH] Hot bug fixing of recovery flow Co-authored-by: Inex Code --- analysis_options.yaml | 2 +- assets/translations/en.json | 21 ++++- assets/translations/ru.json | 14 +++- lib/config/bloc_config.dart | 3 + lib/logic/api_maps/server.dart | 4 +- .../initializing/backblaze_form_cubit.dart | 1 + .../recovery_key/recovery_key_cubit.dart | 2 +- .../server_installation_cubit.dart | 11 +++ lib/main.dart | 3 +- lib/ui/pages/recovery_key/recovery_key.dart | 14 ++++ .../recovery_key/recovery_key_receiving.dart | 1 + .../recovering/recover_by_new_device_key.dart | 64 +++++++++------- .../recovering/recover_by_old_token.dart | 47 +++++++----- .../recovery_confirm_backblaze.dart | 8 +- .../recovery_confirm_cloudflare.dart | 5 +- .../recovering/recovery_confirm_server.dart | 76 +++++++++++++++---- .../setup/recovering/recovery_routing.dart | 4 + pubspec.lock | 14 ++++ pubspec.yaml | 1 + 19 files changed, 220 insertions(+), 75 deletions(-) create mode 100644 lib/ui/pages/recovery_key/recovery_key.dart create mode 100644 lib/ui/pages/recovery_key/recovery_key_receiving.dart diff --git a/analysis_options.yaml b/analysis_options.yaml index 61b6c4de..1caa1fad 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -23,7 +23,7 @@ linter: # producing the lint. rules: # avoid_print: false # Uncomment to disable the `avoid_print` rule - # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options diff --git a/assets/translations/en.json b/assets/translations/en.json index ca3c1984..7cbfdd4f 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -313,11 +313,25 @@ "choose_server_description": "We couldn't figure out which server your are trying to connect to.", "no_servers": "There is no available servers on your account.", "domain_not_available_on_token": "Selected domain is not available on this token.", + "modal_confirmation_title": "Is it really your server?", + "modal_confirmation_description": "If you connect to a wrong server you may lose all your data.", + "modal_confirmation_dns_valid": "Reverse DNS is valid", + "modal_confirmation_dns_invalid": "Reverse DNS points to another domain", + "modal_confirmation_ip_valid": "IP is the same as in DNS record", + "modal_confirmation_ip_invalid": "IP is not the same as in DNS record", "confirm_cloudflare": "Connect to CloudFlare", "confirm_cloudflare_description": "Enter a Cloudflare token with access to {}:", - "confirm_backblze": "Connect to Backblaze", + "confirm_backblaze": "Connect to Backblaze", "confirm_backblaze_description": "Enter a Backblaze token with access to backup storage:" - + }, + "recovery_key": { + "key_main_header": "Recovery key", + "key_main_description": "Is needed for SelfPrivacy authorization when all your other authorized devices aren't available.", + "key_amount_toggle": "Limit by number of uses", + "key_amount_field_title": "Max number of uses", + "key_duedate_toggle": "Limit by time", + "key_duedate_field_title": "Due date of expiration", + "key_receive_button": "Receive key" }, "modals": { "_comment": "messages in modals", @@ -331,7 +345,8 @@ "8": "Remove task", "9": "Reboot", "10": "You cannot use this API for domains with such TLD.", - "yes": "Yes" + "yes": "Yes", + "no": "No" }, "timer": { "sec": "{} sec" diff --git a/assets/translations/ru.json b/assets/translations/ru.json index d240c6e3..7b060a49 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -315,11 +315,22 @@ "choose_server_description": "Не удалось определить, с каким сервером вы устанавливаете связь.", "no_servers": "На вашем аккаунте нет доступных серверов.", "domain_not_available_on_token": "Введённый токен не имеет доступа к нужному домену.", + "modal_confirmation_title": "Это действительно ваш сервер?", + "modal_confirmation_description": "Подключение к неправильному серверу может привести к деструктивным последствиям.", "confirm_cloudflare": "Подключение к Cloudflare", "confirm_cloudflare_description": "Введите токен Cloudflare, который имеет права на {}:", "confirm_backblze": "Подключение к Backblaze", "confirm_backblaze_description": "Введите токен Backblaze, который имеет права на хранилище резервных копий:" }, + "recovery_key": { + "key_main_header": "Ключ восстановления", + "key_main_description": "Требуется для авторизации SelfPrivacy, когда авторизованные устройства недоступны.", + "key_amount_toggle": "Ограничить использования", + "key_amount_field_title": "Макс. кол-во использований", + "key_duedate_toggle": "Ограничить срок использования", + "key_duedate_field_title": "Дата окончания срока", + "key_receive_button": "Получить ключ" + }, "modals": { "_comment": "messages in modals", "1": "Сервер с таким именем уже существует", @@ -332,7 +343,8 @@ "8": "Удалить задачу", "9": "Перезагрузить", "10": "API не поддерживает домены с таким TLD.", - "yes": "Да" + "yes": "Да", + "no": "Нет" }, "timer": { "sec": "{} сек" diff --git a/lib/config/bloc_config.dart b/lib/config/bloc_config.dart index 3456b2de..222b70bf 100644 --- a/lib/config/bloc_config.dart +++ b/lib/config/bloc_config.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:selfprivacy/logic/cubit/recovery_key/recovery_key_cubit.dart'; import 'package:selfprivacy/logic/cubit/server_installation/server_installation_cubit.dart'; import 'package:selfprivacy/logic/cubit/app_settings/app_settings_cubit.dart'; import 'package:selfprivacy/logic/cubit/backups/backups_cubit.dart'; @@ -22,6 +23,7 @@ class BlocAndProviderConfig extends StatelessWidget { var servicesCubit = ServicesCubit(serverInstallationCubit); var backupsCubit = BackupsCubit(serverInstallationCubit); var dnsRecordsCubit = DnsRecordsCubit(serverInstallationCubit); + var recoveryKeyCubit = RecoveryKeyCubit(serverInstallationCubit); return MultiProvider( providers: [ BlocProvider( @@ -36,6 +38,7 @@ class BlocAndProviderConfig extends StatelessWidget { BlocProvider(create: (_) => servicesCubit..load(), lazy: false), BlocProvider(create: (_) => backupsCubit..load(), lazy: false), BlocProvider(create: (_) => dnsRecordsCubit..load()), + BlocProvider(create: (_) => recoveryKeyCubit..load()), BlocProvider( create: (_) => JobsCubit(usersCubit: usersCubit, servicesCubit: servicesCubit), diff --git a/lib/logic/api_maps/server.dart b/lib/logic/api_maps/server.dart index db8230bd..d29dffa9 100644 --- a/lib/logic/api_maps/server.dart +++ b/lib/logic/api_maps/server.dart @@ -628,7 +628,7 @@ class ServerApi extends ApiMap { .replaceAll('"', ''); } - Future> getRecoveryTokenStatus() async { + Future> getRecoveryTokenStatus() async { Response response; var client = await getClient(); @@ -649,7 +649,7 @@ class ServerApi extends ApiMap { return ApiResponse( statusCode: code, data: response.data != null - ? response.data.fromJson(response.data) + ? RecoveryKeyStatus.fromJson(response.data) : null); } diff --git a/lib/logic/cubit/forms/setup/initializing/backblaze_form_cubit.dart b/lib/logic/cubit/forms/setup/initializing/backblaze_form_cubit.dart index fc9062e7..9958effa 100644 --- a/lib/logic/cubit/forms/setup/initializing/backblaze_form_cubit.dart +++ b/lib/logic/cubit/forms/setup/initializing/backblaze_form_cubit.dart @@ -51,6 +51,7 @@ class BackblazeFormCubit extends FormCubit { isKeyValid = await apiClient.isValid(encodedApiKey); } catch (e) { addError(e); + isKeyValid = false; } if (!isKeyValid) { diff --git a/lib/logic/cubit/recovery_key/recovery_key_cubit.dart b/lib/logic/cubit/recovery_key/recovery_key_cubit.dart index 377bd010..eec3bfb1 100644 --- a/lib/logic/cubit/recovery_key/recovery_key_cubit.dart +++ b/lib/logic/cubit/recovery_key/recovery_key_cubit.dart @@ -26,7 +26,7 @@ class RecoveryKeyCubit } Future _getRecoveryKeyStatus() async { - final ApiResponse response = + final ApiResponse response = await api.getRecoveryTokenStatus(); if (response.isSuccess) { return response.data; diff --git a/lib/logic/cubit/server_installation/server_installation_cubit.dart b/lib/logic/cubit/server_installation/server_installation_cubit.dart index 13b84610..579269e7 100644 --- a/lib/logic/cubit/server_installation/server_installation_cubit.dart +++ b/lib/logic/cubit/server_installation/server_installation_cubit.dart @@ -64,6 +64,10 @@ class ServerInstallationCubit extends Cubit { } void setCloudflareKey(String cloudFlareKey) async { + if (state is ServerInstallationRecovery) { + setAndValidateCloudflareToken(cloudFlareKey); + return; + } await repository.saveCloudFlareKey(cloudFlareKey); emit((state as ServerInstallationNotFinished) .copyWith(cloudFlareKey: cloudFlareKey)); @@ -431,12 +435,19 @@ class ServerInstallationCubit extends Cubit { .showSnackBar('recovering.domain_not_available_on_token'.tr()); return; } + await repository.saveDomain(ServerDomain( + domainName: serverDomain.domainName, + zoneId: zoneId, + provider: DnsProvider.Cloudflare, + )); + await repository.saveCloudFlareKey(token); emit(dataState.copyWith( serverDomain: ServerDomain( domainName: serverDomain.domainName, zoneId: zoneId, provider: DnsProvider.Cloudflare, ), + cloudFlareKey: token, currentStep: RecoveryStep.BackblazeToken, )); } diff --git a/lib/main.dart b/lib/main.dart index cdb817fc..1889f65a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -88,8 +88,9 @@ class MyApp extends StatelessWidget { : RootPage(), builder: (BuildContext context, Widget? widget) { Widget error = Text('...rendering error...'); - if (widget is Scaffold || widget is Navigator) + if (widget is Scaffold || widget is Navigator) { error = Scaffold(body: Center(child: error)); + } ErrorWidget.builder = (FlutterErrorDetails errorDetails) => error; return widget!; diff --git a/lib/ui/pages/recovery_key/recovery_key.dart b/lib/ui/pages/recovery_key/recovery_key.dart new file mode 100644 index 00000000..bf0fdd6d --- /dev/null +++ b/lib/ui/pages/recovery_key/recovery_key.dart @@ -0,0 +1,14 @@ +/*import 'package:flutter/src/foundation/key.dart'; +import 'package:flutter/src/widgets/framework.dart'; + +class RecoveryKey extends StatefulWidget { + const RecoveryKey({Key? key}) : super(key: key); + + @override + State createState() => _RecoveryKeyState(); +} + +class _RecoveryKeyState extends State { + @override + Widget build(BuildContext context) {} +}*/ diff --git a/lib/ui/pages/recovery_key/recovery_key_receiving.dart b/lib/ui/pages/recovery_key/recovery_key_receiving.dart new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/lib/ui/pages/recovery_key/recovery_key_receiving.dart @@ -0,0 +1 @@ + diff --git a/lib/ui/pages/setup/recovering/recover_by_new_device_key.dart b/lib/ui/pages/setup/recovering/recover_by_new_device_key.dart index e93b4c4f..de5112e2 100644 --- a/lib/ui/pages/setup/recovering/recover_by_new_device_key.dart +++ b/lib/ui/pages/setup/recovering/recover_by_new_device_key.dart @@ -40,34 +40,44 @@ class RecoverByNewDeviceKeyInput extends StatelessWidget { FieldCubitFactory(context), ServerRecoveryMethods.newDeviceKey, ), - child: Builder( - builder: (context) { - var formCubitState = context.watch().state; - - return BrandHeroScreen( - heroTitle: "recovering.recovery_main_header".tr(), - heroSubtitle: "recovering.method_device_input_description".tr(), - hasBackButton: true, - hasFlashButton: false, - children: [ - CubitFormTextField( - formFieldCubit: - context.read().tokenField, - decoration: InputDecoration( - border: OutlineInputBorder(), - labelText: "recovering.method_device_input_placeholder".tr(), - ), - ), - SizedBox(height: 16), - FilledButton( - title: "more.continue".tr(), - onPressed: formCubitState.isSubmitting - ? null - : () => context.read().trySubmit(), - ) - ], - ); + child: BlocListener( + listener: (context, state) { + if (state is ServerInstallationRecovery && + state.currentStep != RecoveryStep.NewDeviceKey) { + Navigator.of(context).pop(); + } }, + child: Builder( + builder: (context) { + var formCubitState = context.watch().state; + + return BrandHeroScreen( + heroTitle: "recovering.recovery_main_header".tr(), + heroSubtitle: "recovering.method_device_input_description".tr(), + hasBackButton: true, + hasFlashButton: false, + children: [ + CubitFormTextField( + formFieldCubit: + context.read().tokenField, + decoration: InputDecoration( + border: OutlineInputBorder(), + labelText: + "recovering.method_device_input_placeholder".tr(), + ), + ), + SizedBox(height: 16), + FilledButton( + title: "more.continue".tr(), + onPressed: formCubitState.isSubmitting + ? null + : () => + context.read().trySubmit(), + ) + ], + ); + }, + ), ), ); } diff --git a/lib/ui/pages/setup/recovering/recover_by_old_token.dart b/lib/ui/pages/setup/recovering/recover_by_old_token.dart index 4d36262b..363519c9 100644 --- a/lib/ui/pages/setup/recovering/recover_by_old_token.dart +++ b/lib/ui/pages/setup/recovering/recover_by_old_token.dart @@ -13,24 +13,33 @@ class RecoverByOldTokenInstruction extends StatelessWidget { RecoverByOldTokenInstruction({required this.instructionFilename}); Widget build(BuildContext context) { - return BrandHeroScreen( - heroTitle: "recovering.recovery_main_header".tr(), - hasBackButton: true, - hasFlashButton: false, - onBackButtonPressed: () => - context.read().revertRecoveryStep(), - children: [ - BrandMarkdown( - fileName: instructionFilename, - ), - SizedBox(height: 16), - FilledButton( - title: "recovering.method_device_button".tr(), - onPressed: () => context - .read() - .selectRecoveryMethod(ServerRecoveryMethods.oldToken), - ) - ], + return BlocListener( + listener: (context, state) { + if (state is ServerInstallationRecovery && + state.currentStep != RecoveryStep.Selecting) { + Navigator.of(context).pop(); + Navigator.of(context).pop(); + } + }, + child: BrandHeroScreen( + heroTitle: "recovering.recovery_main_header".tr(), + hasBackButton: true, + hasFlashButton: false, + onBackButtonPressed: () => + context.read().revertRecoveryStep(), + children: [ + BrandMarkdown( + fileName: instructionFilename, + ), + SizedBox(height: 18), + FilledButton( + title: "recovering.method_device_button".tr(), + onPressed: () => context + .read() + .selectRecoveryMethod(ServerRecoveryMethods.oldToken), + ) + ], + ), ); } @@ -66,7 +75,7 @@ class RecoverByOldToken extends StatelessWidget { labelText: "recovering.method_device_input_placeholder".tr(), ), ), - SizedBox(height: 16), + SizedBox(height: 18), FilledButton( title: "more.continue".tr(), onPressed: formCubitState.isSubmitting diff --git a/lib/ui/pages/setup/recovering/recovery_confirm_backblaze.dart b/lib/ui/pages/setup/recovering/recovery_confirm_backblaze.dart index 521a1860..6c1779e1 100644 --- a/lib/ui/pages/setup/recovering/recovery_confirm_backblaze.dart +++ b/lib/ui/pages/setup/recovering/recovery_confirm_backblaze.dart @@ -30,26 +30,28 @@ class RecoveryConfirmBackblaze extends StatelessWidget { textAlign: TextAlign.center, scrollPadding: EdgeInsets.only(bottom: 70), decoration: InputDecoration( + border: const OutlineInputBorder(), hintText: 'KeyID', ), ), - Spacer(), + const SizedBox(height: 18), CubitFormTextField( formFieldCubit: context.read().applicationKey, textAlign: TextAlign.center, scrollPadding: EdgeInsets.only(bottom: 70), decoration: InputDecoration( + border: const OutlineInputBorder(), hintText: 'Master Application Key', ), ), - Spacer(), + const SizedBox(height: 18), BrandButton.rised( onPressed: formCubitState.isSubmitting ? null : () => context.read().trySubmit(), text: 'basis.connect'.tr(), ), - SizedBox(height: 10), + const SizedBox(height: 18), BrandButton.text( onPressed: () => showModalBottomSheet( context: context, diff --git a/lib/ui/pages/setup/recovering/recovery_confirm_cloudflare.dart b/lib/ui/pages/setup/recovering/recovery_confirm_cloudflare.dart index c77948c7..a0966f4f 100644 --- a/lib/ui/pages/setup/recovering/recovery_confirm_cloudflare.dart +++ b/lib/ui/pages/setup/recovering/recovery_confirm_cloudflare.dart @@ -32,17 +32,18 @@ class RecoveryConfirmCloudflare extends StatelessWidget { textAlign: TextAlign.center, scrollPadding: EdgeInsets.only(bottom: 70), decoration: InputDecoration( + border: const OutlineInputBorder(), hintText: 'initializing.5'.tr(), ), ), - Spacer(), + const SizedBox(height: 18), BrandButton.rised( onPressed: formCubitState.isSubmitting ? null : () => context.read().trySubmit(), text: 'basis.connect'.tr(), ), - SizedBox(height: 10), + const SizedBox(height: 18), BrandButton.text( onPressed: () => showModalBottomSheet( context: context, diff --git a/lib/ui/pages/setup/recovering/recovery_confirm_server.dart b/lib/ui/pages/setup/recovering/recovery_confirm_server.dart index b83f00f7..8a5c45c3 100644 --- a/lib/ui/pages/setup/recovering/recovery_confirm_server.dart +++ b/lib/ui/pages/setup/recovering/recovery_confirm_server.dart @@ -36,11 +36,11 @@ class _RecoveryConfirmServerState extends State { Widget build(BuildContext context) { return BrandHeroScreen( heroTitle: _isExtended - ? "recovering.choose_server".tr() - : "recovering.confirm_server".tr(), + ? 'recovering.choose_server'.tr() + : 'recovering.confirm_server'.tr(), heroSubtitle: _isExtended - ? "recovering.choose_server_description".tr() - : "recovering.confirm_server_description".tr(), + ? 'recovering.choose_server_description'.tr() + : 'recovering.confirm_server_description'.tr(), hasBackButton: true, hasFlashButton: false, children: [ @@ -68,7 +68,7 @@ class _RecoveryConfirmServerState extends State { if (servers?.isEmpty ?? true) Center( child: Text( - "recovering.no_servers".tr(), + 'recovering.no_servers'.tr(), style: Theme.of(context).textTheme.headline6, ), ), @@ -99,7 +99,7 @@ class _RecoveryConfirmServerState extends State { ), SizedBox(height: 16), FilledButton( - title: "recovering.confirm_server_accept".tr(), + title: 'recovering.confirm_server_accept'.tr(), onPressed: () => _showConfirmationDialog(context, server), ), SizedBox(height: 16), @@ -166,19 +166,65 @@ class _RecoveryConfirmServerState extends State { context: context, builder: (context) { return AlertDialog( - title: Text('ssh.delete'.tr()), - content: SingleChildScrollView( - child: ListBody( - children: [ - Text("WOW DIALOGUE TEXT WOW :)"), - ], - ), + title: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon(Icons.warning_amber_outlined), + const SizedBox(height: 8), + Text( + 'recovering.modal_confirmation_title'.tr(), + style: Theme.of(context).textTheme.headlineSmall, + ), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('recovering.modal_confirmation_description'.tr(), + style: Theme.of(context).textTheme.bodyMedium), + const Divider(), + Text( + server.name, + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.start, + ), + const SizedBox(height: 4), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(server.isReverseDnsValid ? Icons.check : Icons.close), + const SizedBox(width: 8), + Text(server.isReverseDnsValid + ? 'recovering.modal_confirmation_dns_valid'.tr() + : 'recovering.modal_confirmation_dns_invalid'.tr()), + ], + ), + const SizedBox(height: 4), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(server.isIpValid ? Icons.check : Icons.close), + const SizedBox(width: 8), + Text(server.isIpValid + ? 'recovering.modal_confirmation_ip_valid'.tr() + : 'recovering.modal_confirmation_ip_invalid'.tr()), + ], + ), + ], ), actions: [ TextButton( - child: Text('basis.cancel'.tr()), + child: Text('modals.no'.tr()), onPressed: () { - Navigator.of(context)..pop(); + Navigator.of(context).pop(); + }, + ), + TextButton( + child: Text('modals.yes'.tr()), + onPressed: () { + context.read().setServerId(server); + Navigator.of(context).pop(); }, ), ], diff --git a/lib/ui/pages/setup/recovering/recovery_routing.dart b/lib/ui/pages/setup/recovering/recovery_routing.dart index d3a8a0b9..743e4aab 100644 --- a/lib/ui/pages/setup/recovering/recovery_routing.dart +++ b/lib/ui/pages/setup/recovering/recovery_routing.dart @@ -9,6 +9,8 @@ import 'package:selfprivacy/ui/components/brand_hero_screen/brand_hero_screen.da import 'package:selfprivacy/ui/pages/setup/recovering/recover_by_old_token.dart'; import 'package:selfprivacy/ui/pages/setup/recovering/recover_by_recovery_key.dart'; import 'package:selfprivacy/ui/pages/setup/recovering/recover_by_new_device_key.dart'; +import 'package:selfprivacy/ui/pages/setup/recovering/recovery_confirm_backblaze.dart'; +import 'package:selfprivacy/ui/pages/setup/recovering/recovery_confirm_cloudflare.dart'; import 'package:selfprivacy/ui/pages/setup/recovering/recovery_confirm_server.dart'; import 'package:selfprivacy/ui/pages/setup/recovering/recovery_hentzner_connected.dart'; import 'package:selfprivacy/ui/pages/setup/recovering/recovery_method_select.dart'; @@ -43,8 +45,10 @@ class RecoveryRouting extends StatelessWidget { currentPage = RecoveryConfirmServer(); break; case RecoveryStep.CloudflareToken: + currentPage = RecoveryConfirmCloudflare(); break; case RecoveryStep.BackblazeToken: + currentPage = RecoveryConfirmBackblaze(); break; } } diff --git a/pubspec.lock b/pubspec.lock index 965eb666..de1631fe 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -363,6 +363,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.9.2" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" flutter_localizations: dependency: transitive description: flutter @@ -560,6 +567,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "6.2.0" + lints: + dependency: transitive + description: + name: lints + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" local_auth: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index f8b02e64..a99c36a0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -52,6 +52,7 @@ dev_dependencies: flutter_launcher_icons: ^0.9.2 hive_generator: ^1.0.0 json_serializable: ^6.1.4 + flutter_lints: ^2.0.1 flutter_icons: android: "launcher_icon"