diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index f1f63162..60aebe4f 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -140,7 +140,21 @@ jobs: run: flutter analyze - name: Test - run: flutter test + run: flutter test --coverage + + - name: Test coverage + run: | + awk -F: ' + /^LF:/ { total += $2 } + /^LH:/ { covered += $2 } + END { + if (total == 0) { + print "Coverage: 0%" + } else { + printf "Coverage: %.2f%%\n", (covered / total) * 100 + } + } + ' coverage/lcov.info - name: Build apk run: | diff --git a/android/app/build.gradle b/android/app/build.gradle index 8fc22d6d..79e6d71d 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -19,15 +19,15 @@ def yandexClientId = project.hasProperty('YANDEX_CLIENT_ID') ? project.property( android { namespace = "com.eatvioapp.app" compileSdk = flutter.compileSdkVersion - ndkVersion = "27.0.12077973" + ndkVersion = flutter.ndkVersion compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = JavaVersion.VERSION_11.toString() + jvmTarget = JavaVersion.VERSION_17.toString() } defaultConfig { diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index d57d24d8..04cd623d 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -406,7 +406,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - MARKETING_VERSION = 1.3.3; + MARKETING_VERSION = 1.3.4; PRODUCT_BUNDLE_IDENTIFIER = com.eatvioapp.app; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.eatvioapp.app 1747050044"; @@ -557,7 +557,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - MARKETING_VERSION = 1.3.3; + MARKETING_VERSION = 1.3.4; PRODUCT_BUNDLE_IDENTIFIER = com.eatvioapp.app; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.eatvioapp.app 1747050044"; @@ -596,7 +596,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - MARKETING_VERSION = 1.3.3; + MARKETING_VERSION = 1.3.4; PRODUCT_BUNDLE_IDENTIFIER = com.eatvioapp.app; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.eatvioapp.app 1747050044"; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 15c18c06..e90493e3 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -21,7 +21,7 @@ CFBundleSignature ???? CFBundleVersion - 82 + 83 ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS diff --git a/lib/features/auth/presentation/pages/auth_screen.dart b/lib/features/auth/presentation/pages/auth_screen.dart index 2a63de39..0220c9fb 100644 --- a/lib/features/auth/presentation/pages/auth_screen.dart +++ b/lib/features/auth/presentation/pages/auth_screen.dart @@ -411,7 +411,7 @@ class _AuthScreenState extends State { _isLoading = false; }); }, - label: state is AuthStateLoading + label: state is AuthStateLoading || _isLoading ? const CupertinoActivityIndicator() : Text('Войти', style: theme.textTheme.titleSmall!.copyWith(color: Colors.white)), ), @@ -426,8 +426,8 @@ class _AuthScreenState extends State { elevation: 0, focusElevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - backgroundColor: state is AuthStateLoading ? Colors.black.withAlpha(50) : appTheme.blueA400, - onPressed: state is AuthStateLoading + backgroundColor: state is AuthStateLoading || _isLoading ? Colors.black.withAlpha(50) : appTheme.blueA400, + onPressed: state is AuthStateLoading || _isLoading ? null : () async { await showDialog( @@ -487,7 +487,7 @@ class _AuthScreenState extends State { }, ); }, - label: state is AuthStateLoading + label: state is AuthStateLoading || _isLoading ? const CupertinoActivityIndicator() : const Icon(Icons.qr_code_scanner_rounded, color: Colors.white), ), diff --git a/lib/features/blog_post_create/presentation/pages/blog_post_create_screen.dart b/lib/features/blog_post_create/presentation/pages/blog_post_create_screen.dart index 48536468..98cb0053 100644 --- a/lib/features/blog_post_create/presentation/pages/blog_post_create_screen.dart +++ b/lib/features/blog_post_create/presentation/pages/blog_post_create_screen.dart @@ -5,6 +5,7 @@ import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/permission_handler/permission_handler.dart'; import 'package:eatvio_app/core/theme/app_decoration.dart'; +import 'package:eatvio_app/core/widgets/loader.dart'; import 'package:eatvio_app/core/widgets/show_error_dialog.dart'; import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart'; import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_state.dart'; @@ -620,16 +621,16 @@ class _BlogPostCreateScreenState extends State { } }, borderRadius: BorderRadius.circular(16), - child: Ink( - width: MediaQuery.of(context).size.width - 32, - height: 52, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(16), color: appTheme.blueA400), - child: isLoading - ? const Center(child: CircularProgressIndicator()) - : const Center( + child: isLoading + ? const Center(child: Loader()) + : Ink( + width: MediaQuery.of(context).size.width - 32, + height: 52, + decoration: BoxDecoration(borderRadius: BorderRadius.circular(16), color: appTheme.blueA400), + child: const Center( child: Text('Создать', style: TextStyle(color: Colors.white)), ), - ), + ), ); } diff --git a/lib/features/consultations/presentation/widgets/consultation_card_widget.dart b/lib/features/consultations/presentation/widgets/consultation_card_widget.dart index f9fa4143..b1529280 100644 --- a/lib/features/consultations/presentation/widgets/consultation_card_widget.dart +++ b/lib/features/consultations/presentation/widgets/consultation_card_widget.dart @@ -63,25 +63,28 @@ class _ConsultationCardWidgetState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - CircleAvatar(radius: 30, backgroundImage: NetworkImage(widget.consultationsModel.userProfilePhotoUrl)), - const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.consultationsModel.userName, - style: const TextStyle(height: 1, color: Color(0xFF323F4B), fontSize: 16, fontWeight: FontWeight.w600), - ), - const SizedBox(height: 4), - Text( - widget.consultationsModel.planTitle, - style: const TextStyle(height: 1, color: Color(0xFF88949F), fontSize: 14, fontWeight: FontWeight.w500), - ), - ], - ), - ], + GestureDetector( + onTap: () => GoRouter.of(context).pushNamed('profileScreen', extra: widget.consultationsModel.userSlug), + child: Row( + children: [ + CircleAvatar(radius: 30, backgroundImage: NetworkImage(widget.consultationsModel.userProfilePhotoUrl)), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.consultationsModel.userName, + style: const TextStyle(height: 1, color: Color(0xFF323F4B), fontSize: 16, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 4), + Text( + widget.consultationsModel.planTitle, + style: const TextStyle(height: 1, color: Color(0xFF88949F), fontSize: 14, fontWeight: FontWeight.w500), + ), + ], + ), + ], + ), ), const SizedBox(height: 12), GestureDetector( diff --git a/lib/features/create_diet/presentation/diet_preview_screen.dart b/lib/features/create_diet/presentation/diet_preview_screen.dart index 94b21709..23d946af 100644 --- a/lib/features/create_diet/presentation/diet_preview_screen.dart +++ b/lib/features/create_diet/presentation/diet_preview_screen.dart @@ -1,13 +1,8 @@ import 'dart:convert'; -import 'dart:io'; import 'dart:typed_data'; -import 'package:eatvio_app/core/enums/complained_content.dart'; import 'package:eatvio_app/core/enums/product_type.dart'; import 'package:eatvio_app/core/utils/localization_utils.dart'; -import 'package:eatvio_app/core/utils/string_extentions.dart'; -import 'package:eatvio_app/core/widgets/bookmark_button.dart'; import 'package:eatvio_app/core/widgets/custom_divider.dart'; -import 'package:eatvio_app/core/widgets/custom_popup.dart'; import 'package:eatvio_app/core/widgets/info_row.dart'; import 'package:eatvio_app/features/diets/presentation/widgets/author_card.dart'; import 'package:eatvio_app/core/widgets/cpfc_container.dart'; @@ -36,20 +31,11 @@ class DietPreviewScreen extends StatefulWidget { class _DietPreviewScreenState extends State { final ScrollController scrollController = ScrollController(); - final textEditingController = TextEditingController(); - bool isMoreOptionsVisible = false; late final Uint8List imageData; @override void initState() { super.initState(); imageData = base64.decode(widget.diet.image); - scrollController.addListener(() { - if (isMoreOptionsVisible) { - setState(() { - isMoreOptionsVisible = false; - }); - } - }); } @override @@ -58,86 +44,6 @@ class _DietPreviewScreenState extends State { return Scaffold( backgroundColor: appTheme.gray100, resizeToAvoidBottomInset: true, - bottomSheet: Container( - padding: const EdgeInsets.symmetric(vertical: 12) + (Platform.isIOS ? const EdgeInsets.only(bottom: 16) : EdgeInsets.zero), - decoration: BoxDecoration( - color: Colors.white, - border: Border(top: BorderSide(color: Colors.black.withValues(alpha: 0.1))), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Expanded( - child: BookmarkButton( - isFavorite: widget.diet.isFavorite, - onTap: () {}, - width: 52, - height: 52, - size: 24, - shape: BoxShape.circle, - color: appTheme.gray50, - ), - ), - ], - ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: SizedBox( - child: TextFormField( - textCapitalization: TextCapitalization.sentences, - onChanged: (_) { - setState(() {}); - }, - controller: textEditingController, - maxLines: 5, - minLines: 1, - keyboardType: TextInputType.multiline, - style: TextStyles.textMMedium.copyWith(color: appTheme.black), - decoration: InputDecoration( - isDense: true, - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - hintText: s.comment.capitalize(), - hintStyle: TextStyles.textMMedium.copyWith(color: appTheme.lightGray600), - filled: true, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), - ), - ), - ), - ), - ), - const SizedBox(width: 8), - GestureDetector( - onTap: () {}, - child: Container( - decoration: BoxDecoration(color: textEditingController.text.length > 2 ? appTheme.blueA400 : appTheme.gray50, shape: BoxShape.circle), - height: 32, - width: 32, - child: Icon(Icons.send_rounded, size: 18, color: textEditingController.text.length > 2 ? appTheme.whiteA700 : appTheme.black), - ), - ), - ], - ), - ], - ), - ), - ), appBar: AppBar( centerTitle: true, title: Text( @@ -148,15 +54,7 @@ class _DietPreviewScreenState extends State { softWrap: true, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), ), - actions: [ - CustomPopup( - isAuthor: widget.diet.author.authorIsMe, - path: 'diet/${widget.diet.slug}', - complainedContentType: ComplainedContent.diet, - userSlug: widget.diet.author.authorSlug, - ), - const SizedBox(width: 16), - ], + actions: const [], ), body: SingleChildScrollView( controller: scrollController, diff --git a/lib/features/create_recipe/presentation/pages/recipe_create_screen.dart b/lib/features/create_recipe/presentation/pages/recipe_create_screen.dart index c8a6d4ef..932852ac 100644 --- a/lib/features/create_recipe/presentation/pages/recipe_create_screen.dart +++ b/lib/features/create_recipe/presentation/pages/recipe_create_screen.dart @@ -33,7 +33,7 @@ class _RecipeCreateScreenState extends State { late final CreateRecipeCubit createRecipeCubit; final GlobalKey _mdKey = GlobalKey(); bool _mdToolbarVisible = false; - + bool _isRecipeBeingPublished = false; @override void initState() { super.initState(); @@ -58,9 +58,7 @@ class _RecipeCreateScreenState extends State { return Scaffold( backgroundColor: const Color(0xFFF0F2F5), floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, - floatingActionButton: _mdToolbarVisible && _mdKey.currentState != null - ? _mdKey.currentState!.buildToolbar(context) - : null, + floatingActionButton: _mdToolbarVisible && _mdKey.currentState != null ? _mdKey.currentState!.buildToolbar(context) : null, appBar: AppBar( title: widget.recipe != null ? const Text('Редактирование рецепта') : const Text('Добавление рецепта'), centerTitle: true, @@ -83,44 +81,55 @@ class _RecipeCreateScreenState extends State { builder: (context, state) { return Builder( builder: (context) { - return InkWell( - key: AppKeys.recipeCreateScreenPublishButton, - onTap: () async { - if (context.read().validateAll()) { - final isSuccess = await context.read().publishRecipe( - widget.recipe == null ? false : true, - widget.recipe?.slug, + return _isRecipeBeingPublished + ? const Center(child: Loader()) + : InkWell( + key: AppKeys.recipeCreateScreenPublishButton, + onTap: () async { + setState(() { + _isRecipeBeingPublished = true; + }); + if (context.read().validateAll()) { + final isSuccess = await context.read().publishRecipe( + widget.recipe == null ? false : true, + widget.recipe?.slug, + ); + setState(() { + _isRecipeBeingPublished = false; + }); + if (isSuccess) { + Future.delayed(const Duration(milliseconds: 500), () { + if (!context.mounted) return; + context.pop(); + showBottomSheetWithBlur( + userRootNavigator: true, + context: context, + child: SuccessPublishedRecipeBottomSheet(title: state.entity.title ?? 'Рецепт'), + ); + }); + } else { + if (!context.mounted) return; + showErrorDialog(context, title: '${s.could_not_publish_recipe} ${state.entity.title}'); + } + } + setState(() { + _isRecipeBeingPublished = false; + }); + }, + borderRadius: BorderRadius.circular(20), + child: Ink( + width: MediaQuery.of(context).size.width - 32, + decoration: BoxDecoration(color: appTheme.blueA400, borderRadius: BorderRadius.circular(20)), + child: Container( + padding: const EdgeInsets.all(16), + alignment: Alignment.center, + child: Text( + widget.recipe != null ? 'Обновить' : 'Опубликовать', + style: const TextStyle(fontSize: 16, color: Colors.white, fontWeight: FontWeight.w600), + ), + ), + ), ); - if (isSuccess) { - Future.delayed(const Duration(milliseconds: 500), () { - if (!context.mounted) return; - context.pop(); - showBottomSheetWithBlur( - userRootNavigator: true, - context: context, - child: SuccessPublishedRecipeBottomSheet(title: state.entity.title ?? 'Рецепт'), - ); - }); - } else { - if (!context.mounted) return; - showErrorDialog(context, title: '${s.could_not_publish_recipe} ${state.entity.title}'); - } - } - }, - borderRadius: BorderRadius.circular(20), - child: Ink( - width: MediaQuery.of(context).size.width - 32, - decoration: BoxDecoration(color: appTheme.blueA400, borderRadius: BorderRadius.circular(20)), - child: Container( - padding: const EdgeInsets.all(16), - alignment: Alignment.center, - child: Text( - widget.recipe != null ? 'Обновить' : 'Опубликовать', - style: const TextStyle(fontSize: 16, color: Colors.white, fontWeight: FontWeight.w600), - ), - ), - ), - ); }, ); }, diff --git a/lib/features/diary/presentation/pages/diary_screen.dart b/lib/features/diary/presentation/pages/diary_screen.dart index ae6f9e10..607e97d2 100644 --- a/lib/features/diary/presentation/pages/diary_screen.dart +++ b/lib/features/diary/presentation/pages/diary_screen.dart @@ -44,16 +44,24 @@ class UserDiaryScreen extends StatelessWidget { } } -class DiaryScreenContent extends StatelessWidget { +class DiaryScreenContent extends StatefulWidget { const DiaryScreenContent({super.key, this.diaryId, this.currentPlanDay}); final int? diaryId; final int? currentPlanDay; + + @override + State createState() => _DiaryScreenContentState(); +} + +class _DiaryScreenContentState extends State { + bool _isDiaryFinishing = false; + @override Widget build(BuildContext context) { final s = S.of(context); return MultiBlocProvider( providers: [ - BlocProvider(create: (BuildContext context) => sl()..fetchDiaryV2(needLoading: true, diaryId: diaryId)), + BlocProvider(create: (BuildContext context) => sl()..fetchDiaryV2(needLoading: true, diaryId: widget.diaryId)), BlocProvider(create: (context) => sl()), ], child: BlocBuilder( @@ -63,13 +71,13 @@ class DiaryScreenContent extends StatelessWidget { } if (state is DiaryStateError) { return Scaffold( - body: DataErrorWidget(onReload: () => context.read().fetchDiaryV2(needLoading: false, diaryId: diaryId)), + body: DataErrorWidget(onReload: () => context.read().fetchDiaryV2(needLoading: false, diaryId: widget.diaryId)), ); } return Scaffold( backgroundColor: Colors.grey.shade200, - appBar: diaryId == null + appBar: widget.diaryId == null ? AppBar(toolbarHeight: 0) : AppBar( title: Text( @@ -83,7 +91,7 @@ class DiaryScreenContent extends StatelessWidget { physics: const BouncingScrollPhysics(), child: Column( children: [ - if (diaryId == null) const CalendarWidget(), + if (widget.diaryId == null) const CalendarWidget(), if (state is DiaryStateLoading) ...[const SizedBox(height: 60), const Loader()], if (state is DiaryStateLoaded) WakeupAndRetireWidget( @@ -116,7 +124,23 @@ class DiaryScreenContent extends StatelessWidget { if (state is DiaryStateLoaded && !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented) Padding( padding: const EdgeInsets.only(top: 12), - child: FinishDayButtonWidget(diaryDay: state.diary), + child: _isDiaryFinishing + ? const Center(child: Loader()) + : FinishDayButtonWidget( + onPressed: () async { + setState(() { + _isDiaryFinishing = true; + }); + await context.read().finishDiaryDay( + diaryId: state.diary.id, + wakeUp: state.diary.wakeup.toString(), + retire: state.diary.retire.toString(), + ); + setState(() { + _isDiaryFinishing = false; + }); + }, + ), ), if (state is DiaryStateLoaded && state.isConsultantView && !state.isDiaryCommented) ...[ const SizedBox(height: 16), diff --git a/lib/features/diary/presentation/widgets/finish_day_button_widget.dart b/lib/features/diary/presentation/widgets/finish_day_button_widget.dart index b3f170ec..af58216a 100644 --- a/lib/features/diary/presentation/widgets/finish_day_button_widget.dart +++ b/lib/features/diary/presentation/widgets/finish_day_button_widget.dart @@ -1,13 +1,10 @@ -import 'package:eatvio_app/features/diary/entities/diary_day_entity.dart'; -import 'package:eatvio_app/features/diary/presentation/bloc/diary_cubit.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; class FinishDayButtonWidget extends StatefulWidget { - final DiaryDayEntity diaryDay; + final Future Function() onPressed; - const FinishDayButtonWidget({super.key, required this.diaryDay}); + const FinishDayButtonWidget({super.key, required this.onPressed}); @override State createState() => _FinishDayButtonWidgetState(); @@ -17,11 +14,7 @@ class _FinishDayButtonWidgetState extends State { @override Widget build(BuildContext context) { return InkWell( - onTap: () => context.read().finishDiaryDay( - diaryId: widget.diaryDay.id, - wakeUp: widget.diaryDay.wakeup.toString(), - retire: widget.diaryDay.retire.toString(), - ), + onTap: widget.onPressed, borderRadius: BorderRadius.circular(20), child: Ink( padding: const EdgeInsets.symmetric(horizontal: 8), diff --git a/lib/features/settings/models/oauth_account_model.dart b/lib/features/settings/models/oauth_account_model.dart new file mode 100644 index 00000000..27942119 --- /dev/null +++ b/lib/features/settings/models/oauth_account_model.dart @@ -0,0 +1,44 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'oauth_account_model.freezed.dart'; +part 'oauth_account_model.g.dart'; + +@freezed +abstract class OAuthAccountModel with _$OAuthAccountModel { + const factory OAuthAccountModel({ + @JsonKey(name: 'oauth_id') required String oauthId, + required String name, + required String email, + required String phone, + @JsonKey(name: 'linked_at') required DateTime linkedAt, + }) = _OAuthAccountModel; + + factory OAuthAccountModel.fromJson(Map json) => _$OAuthAccountModelFromJson(json); +} + +@freezed +abstract class OAuthAccountsResponseModel with _$OAuthAccountsResponseModel { + const factory OAuthAccountsResponseModel({required bool success, required OAuthAccountsDataModel data}) = _OAuthAccountsResponseModel; + + factory OAuthAccountsResponseModel.fromJson(Map json) => _$OAuthAccountsResponseModelFromJson(json); +} + +@freezed +abstract class OAuthAccountsDataModel with _$OAuthAccountsDataModel { + const factory OAuthAccountsDataModel({ + @JsonKey(fromJson: _oauthAccountFromJson) OAuthAccountModel? vk, + @JsonKey(fromJson: _oauthAccountFromJson) OAuthAccountModel? yandex, + }) = _OAuthAccountsDataModel; + + factory OAuthAccountsDataModel.fromJson(Map json) => _$OAuthAccountsDataModelFromJson(json); +} + +// Custom converter to handle both empty array [] and object +OAuthAccountModel? _oauthAccountFromJson(dynamic json) { + if (json == null) return null; + if (json is List && json.isEmpty) return null; + if (json is Map) { + return OAuthAccountModel.fromJson(json); + } + return null; +} diff --git a/lib/features/settings/models/oauth_attach_request.dart b/lib/features/settings/models/oauth_attach_request.dart new file mode 100644 index 00000000..600fb320 --- /dev/null +++ b/lib/features/settings/models/oauth_attach_request.dart @@ -0,0 +1,17 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'oauth_attach_request.freezed.dart'; +part 'oauth_attach_request.g.dart'; + +@freezed +abstract class OAuthAttachRequest with _$OAuthAttachRequest { + const factory OAuthAttachRequest({ + required String provider, + @JsonKey(name: 'client_id') String? clientId, + @JsonKey(name: 'access_token') required String accessToken, + @JsonKey(name: 'refresh_token') required String refreshToken, + }) = _OAuthAttachRequest; + + factory OAuthAttachRequest.fromJson(Map json) => _$OAuthAttachRequestFromJson(json); +} + diff --git a/lib/features/settings/presentation/bloc/settings_cubit/settings_cubit.dart b/lib/features/settings/presentation/bloc/settings_cubit/settings_cubit.dart index 2a64e2e9..ed123203 100644 --- a/lib/features/settings/presentation/bloc/settings_cubit/settings_cubit.dart +++ b/lib/features/settings/presentation/bloc/settings_cubit/settings_cubit.dart @@ -4,6 +4,8 @@ import 'package:eatvio_app/features/consultations/repositories/consultations_rep import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart'; import 'package:eatvio_app/features/profile/models/profile_model.dart'; import 'package:eatvio_app/features/profile/repositories/profile_repository.dart'; +import 'package:eatvio_app/features/settings/models/oauth_account_model.dart'; +import 'package:eatvio_app/features/settings/models/oauth_attach_request.dart'; import 'package:eatvio_app/features/settings/models/profile_settings_model.dart'; import 'package:eatvio_app/features/settings/presentation/bloc/settings_cubit/settings_state.dart'; import 'package:eatvio_app/features/settings/repositories/service_repository.dart'; @@ -26,6 +28,7 @@ class SettingsCubit extends Cubit { final profile = await profileRepository.getMyProfile(); late final ConsultationRequestModel? consultantInfo; late final bool hasActiveConsultation; + late final OAuthAccountsDataModel? oauthAccounts; try { if (profile.phoneConfirmationState.contains('confirmed')) { @@ -43,6 +46,13 @@ class SettingsCubit extends Cubit { hasActiveConsultation = false; } + try { + final oauthResponse = await settingsRepository.getOAuthAccounts(); + oauthAccounts = oauthResponse.data; + } catch (e) { + oauthAccounts = null; + } + try { emit( SettingsState.loaded( @@ -51,6 +61,7 @@ class SettingsCubit extends Cubit { profile: profile, consultationRequestModel: consultantInfo, hasActiveConsultation: hasActiveConsultation, + oauthAccounts: oauthAccounts, ), ); } catch (e) { @@ -87,6 +98,35 @@ class SettingsCubit extends Cubit { } } + Future linkOAuthAccount({ + required String provider, + required String accessToken, + required String refreshToken, + String? clientId, + }) async { + try { + final request = OAuthAttachRequest( + provider: provider, + clientId: clientId, + accessToken: accessToken, + refreshToken: refreshToken, + ); + await settingsRepository.linkOAuthAccount(request: request); + await init(); + } catch (e) { + rethrow; + } + } + + Future unlinkOAuthAccount({required String provider}) async { + try { + await settingsRepository.unlinkOAuthAccount(provider: provider); + await init(); + } catch (e) { + rethrow; + } + } + Future _checkActiveConsultation() async { try { final userPurchases = await consultationsRepository.getUserPurchases(null, null); diff --git a/lib/features/settings/presentation/bloc/settings_cubit/settings_state.dart b/lib/features/settings/presentation/bloc/settings_cubit/settings_state.dart index 86471f31..b8746dd9 100644 --- a/lib/features/settings/presentation/bloc/settings_cubit/settings_state.dart +++ b/lib/features/settings/presentation/bloc/settings_cubit/settings_state.dart @@ -1,5 +1,6 @@ import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; import 'package:eatvio_app/features/profile/models/profile_model.dart'; +import 'package:eatvio_app/features/settings/models/oauth_account_model.dart'; import 'package:eatvio_app/features/settings/models/profile_settings_model.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -17,6 +18,7 @@ abstract class SettingsState with _$SettingsState { required ProfileModel profile, ConsultationRequestModel? consultationRequestModel, required bool hasActiveConsultation, + OAuthAccountsDataModel? oauthAccounts, }) = SettingsStateLoaded; const factory SettingsState.error({ diff --git a/lib/features/settings/presentation/pages/settings_screen.dart b/lib/features/settings/presentation/pages/settings_screen.dart index ad42a7b4..d6ecfd92 100644 --- a/lib/features/settings/presentation/pages/settings_screen.dart +++ b/lib/features/settings/presentation/pages/settings_screen.dart @@ -6,6 +6,7 @@ import 'package:eatvio_app/core/widgets/custom_outlined_button.dart'; import 'package:eatvio_app/core/widgets/data_error_widget.dart'; import 'package:eatvio_app/core/widgets/loader.dart'; import 'package:eatvio_app/core/widgets/show_error_dialog.dart'; +import 'package:eatvio_app/features/auth/sdk/social_auth_facade.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; import 'package:eatvio_app/features/settings/entities/settings_entity.dart'; import 'package:eatvio_app/features/settings/presentation/bloc/settings_cubit/settings_cubit.dart'; @@ -16,6 +17,7 @@ import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; class SettingsScreen extends StatefulWidget { @@ -39,12 +41,7 @@ class _SettingsScreenState extends State { return AlertDialog( title: const Text('Предупреждение'), content: const Text('Во время консультации этот параметр нельзя отключить'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Понятно'), - ), - ], + actions: [TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Понятно'))], ); }, ); @@ -74,6 +71,8 @@ class _SettingsScreenState extends State { physics: const BouncingScrollPhysics(), child: Column( children: [ + _oauthAccounts(state, settingsCubit), + const SizedBox(height: 12), _additionalInfo(state), const SizedBox(height: 12), _notifications(state), @@ -328,6 +327,326 @@ class _SettingsScreenState extends State { ), ); } + + Widget _oauthAccounts(SettingsStateLoaded state, SettingsCubit settingsCubit) { + final isVkLinked = state.oauthAccounts?.vk != null; + final isYandexLinked = state.oauthAccounts?.yandex != null; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'Привязанные аккаунты', + style: TextStyle(fontSize: 16, height: 1, color: Colors.grey, fontFamily: 'Mulish'), + ), + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _OAuthAccountButton( + onTap: () => _onVkPressed(state, settingsCubit), + backgroundColor: const Color(0xFF2787F5), + tooltip: 'VK ID', + icon: SvgPicture.asset('assets/icons/vk_logo.svg', height: 24, width: 24), + isLinked: isVkLinked, + accountName: state.oauthAccounts?.vk?.name, + ), + const SizedBox(width: 12), + _OAuthAccountButton( + onTap: () => _onOkPressed(state, settingsCubit), + backgroundColor: const Color(0xFFF58220), + tooltip: 'Одноклассники', + icon: SvgPicture.asset('assets/icons/ok_logo.svg', height: 24, width: 24), + isLinked: isVkLinked, + accountName: state.oauthAccounts?.vk?.name, + ), + const SizedBox(width: 12), + _OAuthAccountButton( + onTap: () => _onMailRuPressed(state, settingsCubit), + backgroundColor: const Color(0xFF005FF9), + tooltip: 'Mail.ru', + icon: SvgPicture.asset('assets/icons/mail_logo.svg', height: 24, width: 24), + isLinked: isVkLinked, + accountName: state.oauthAccounts?.vk?.name, + ), + const SizedBox(width: 12), + _OAuthAccountButton( + onTap: () => _onYandexPressed(state, settingsCubit), + backgroundColor: const Color(0xFFFFCC00), + tooltip: 'Яндекс ID', + icon: SvgPicture.asset('assets/icons/yandex_logo.svg', height: 24, width: 24), + isLinked: isYandexLinked, + accountName: state.oauthAccounts?.yandex?.name, + ), + ], + ), + ), + const SizedBox(height: 12), + ], + ); + } + + Future _onVkPressed(SettingsStateLoaded state, SettingsCubit settingsCubit) async { + if (state.oauthAccounts?.vk != null) { + // Отвязываем аккаунт + if (!mounted) return; + final shouldUnlink = await _showUnlinkConfirmationDialog(context, 'VK ID'); + if (shouldUnlink == true) { + try { + await settingsCubit.unlinkOAuthAccount(provider: 'vk'); + } catch (e) { + if (mounted) { + showErrorDialog(context, description: 'Не удалось отвязать аккаунт'); + } + } + } + return; + } + + try { + if (!mounted) return; + + final authResult = await SocialAuthFacade.signInWithVkAndFetchProfile(context: context); + if (authResult == null) return; + + try { + await settingsCubit.linkOAuthAccount( + provider: 'vk', + accessToken: authResult.accessToken, + refreshToken: authResult.refreshToken ?? '', + clientId: authResult.clientId, + ); + } catch (e) { + if (mounted) { + showErrorDialog(context, description: 'Не удалось привязать аккаунт'); + } + } + } catch (e) { + if (mounted) { + showErrorDialog(context, description: 'Не удалось привязать аккаунт'); + } + } + } + + Future _onOkPressed(SettingsStateLoaded state, SettingsCubit settingsCubit) async { + if (state.oauthAccounts?.vk != null) { + // Отвязываем аккаунт (OK использует тот же VK ID провайдер) + if (!mounted) return; + final shouldUnlink = await _showUnlinkConfirmationDialog(context, 'Одноклассники'); + if (shouldUnlink == true) { + try { + await settingsCubit.unlinkOAuthAccount(provider: 'vk'); + } catch (e) { + if (mounted) { + showErrorDialog(context, description: 'Не удалось отвязать аккаунт'); + } + } + } + return; + } + + try { + if (!mounted) return; + + final authResult = await SocialAuthFacade.signInWithOkAndFetchProfile(); + if (authResult == null) return; + + try { + await settingsCubit.linkOAuthAccount( + provider: 'vk', + accessToken: authResult.accessToken, + refreshToken: authResult.refreshToken ?? '', + clientId: authResult.clientId, + ); + } catch (e) { + if (mounted) { + showErrorDialog(context, description: 'Не удалось привязать аккаунт'); + } + } + } catch (e) { + if (mounted) { + showErrorDialog(context, description: 'Не удалось привязать аккаунт'); + } + } + } + + Future _onMailRuPressed(SettingsStateLoaded state, SettingsCubit settingsCubit) async { + if (state.oauthAccounts?.vk != null) { + // Отвязываем аккаунт (Mail.ru использует тот же VK ID провайдер) + if (!mounted) return; + final shouldUnlink = await _showUnlinkConfirmationDialog(context, 'Mail.ru'); + if (shouldUnlink == true) { + try { + await settingsCubit.unlinkOAuthAccount(provider: 'vk'); + } catch (e) { + if (mounted) { + showErrorDialog(context, description: 'Не удалось отвязать аккаунт'); + } + } + } + return; + } + + try { + if (!mounted) return; + + final authResult = await SocialAuthFacade.signInWithMailRuAndFetchProfile(); + if (authResult == null) return; + + try { + await settingsCubit.linkOAuthAccount( + provider: 'vk', + accessToken: authResult.accessToken, + refreshToken: authResult.refreshToken ?? '', + clientId: authResult.clientId, + ); + } catch (e) { + if (mounted) { + showErrorDialog(context, description: 'Не удалось привязать аккаунт'); + } + } + } catch (e) { + if (mounted) { + showErrorDialog(context, description: 'Не удалось привязать аккаунт'); + } + } + } + + Future _onYandexPressed(SettingsStateLoaded state, SettingsCubit settingsCubit) async { + if (state.oauthAccounts?.yandex != null) { + // Отвязываем аккаунт + if (!mounted) return; + final shouldUnlink = await _showUnlinkConfirmationDialog(context, 'Яндекс ID'); + if (shouldUnlink == true) { + try { + await settingsCubit.unlinkOAuthAccount(provider: 'yandex'); + } catch (e) { + if (mounted) { + showErrorDialog(context, description: 'Не удалось отвязать аккаунт'); + } + } + } + return; + } + + try { + if (!mounted) return; + + final authResult = await SocialAuthFacade.signInWithYandex(); + if (authResult == null) { + if (mounted) { + showErrorDialog(context, description: 'Не удалось авторизоваться через Яндекс'); + } + return; + } + + // Проверяем, что refreshToken не пустой для Яндекс + if (authResult.refreshToken == null || authResult.refreshToken!.isEmpty) { + if (mounted) { + showErrorDialog(context, description: 'Не удалось получить токен обновления от Яндекс'); + } + return; + } + + // Проверяем, что clientId не пустой для Яндекс + if (authResult.clientId.isEmpty) { + if (mounted) { + showErrorDialog(context, description: 'Не удалось получить client ID от Яндекс'); + } + return; + } + + try { + await settingsCubit.linkOAuthAccount( + provider: 'yandex', + accessToken: authResult.accessToken, + refreshToken: authResult.refreshToken!, + clientId: authResult.clientId, + ); + } catch (e) { + if (mounted) { + showErrorDialog(context, description: 'Не удалось привязать аккаунт: ${e.toString()}'); + } + } + } catch (e) { + if (mounted) { + showErrorDialog(context, description: 'Не удалось привязать аккаунт: ${e.toString()}'); + } + } + } + + Future _showUnlinkConfirmationDialog(BuildContext context, String providerName) { + return showDialog( + context: context, + builder: (context) => CupertinoAlertDialog( + title: const Text('Отвязать аккаунт'), + content: Text('Вы точно хотите отвязать аккаунт $providerName?'), + actions: [ + CupertinoDialogAction( + onPressed: () { + Navigator.of(context).pop(false); + }, + child: const Text('Отмена'), + ), + CupertinoDialogAction( + onPressed: () { + Navigator.of(context).pop(true); + }, + isDestructiveAction: true, + child: const Text('Отвязать'), + ), + ], + ), + ); + } +} + +class _OAuthAccountButton extends StatelessWidget { + const _OAuthAccountButton({ + required this.onTap, + required this.icon, + required this.backgroundColor, + required this.tooltip, + required this.isLinked, + this.accountName, + }); + + final VoidCallback onTap; + final Widget icon; + final Color backgroundColor; + final String tooltip; + final bool isLinked; + final String? accountName; + + @override + Widget build(BuildContext context) { + return Expanded( + child: Opacity( + opacity: isLinked ? 0.5 : 1.0, + child: Tooltip( + message: tooltip, + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: onTap, + child: Ink( + height: 48, + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFDBDBDB), width: 1), + ), + child: Center(child: icon), + ), + ), + ), + ), + ); + } } class DeleteAccountBottomSheet extends StatefulWidget { diff --git a/lib/features/settings/repositories/service_repository.dart b/lib/features/settings/repositories/service_repository.dart index eb462de3..139ee31e 100644 --- a/lib/features/settings/repositories/service_repository.dart +++ b/lib/features/settings/repositories/service_repository.dart @@ -1,4 +1,6 @@ import 'package:eatvio_app/features/profile/models/profile_model.dart'; +import 'package:eatvio_app/features/settings/models/oauth_account_model.dart'; +import 'package:eatvio_app/features/settings/models/oauth_attach_request.dart'; import 'package:eatvio_app/features/settings/models/profile_settings_model.dart'; import 'package:eatvio_app/features/settings/services/settings_service.dart'; @@ -15,25 +17,41 @@ class SettingsRepository { } } - Future updateProfileSettings({ - required ProfileSettingsModel profileSettingsModel, - }) async { + Future updateProfileSettings({required ProfileSettingsModel profileSettingsModel}) async { try { - return await settingsService.updateProfileSettings( - data: profileSettingsModel.toJson(), - ); + return await settingsService.updateProfileSettings(data: profileSettingsModel.toJson()); } catch (e) { rethrow; } } - Future updateProfileDetails({ - required ProfileDetailsModel profileDetailsModel, - }) async { + Future updateProfileDetails({required ProfileDetailsModel profileDetailsModel}) async { try { - return await settingsService.updateProfileDetails( - data: profileDetailsModel.toJson(), - ); + return await settingsService.updateProfileDetails(data: profileDetailsModel.toJson()); + } catch (e) { + rethrow; + } + } + + Future getOAuthAccounts() async { + try { + return await settingsService.getOAuthAccounts(); + } catch (e) { + rethrow; + } + } + + Future linkOAuthAccount({required OAuthAttachRequest request}) async { + try { + return await settingsService.linkOAuthAccount(request); + } catch (e) { + rethrow; + } + } + + Future unlinkOAuthAccount({required String provider}) async { + try { + return await settingsService.unlinkOAuthAccount(provider); } catch (e) { rethrow; } diff --git a/lib/features/settings/services/settings_service.dart b/lib/features/settings/services/settings_service.dart index d17b5842..c83f4c41 100644 --- a/lib/features/settings/services/settings_service.dart +++ b/lib/features/settings/services/settings_service.dart @@ -1,5 +1,7 @@ import 'package:dio/dio.dart'; import 'package:eatvio_app/features/profile/models/profile_model.dart'; +import 'package:eatvio_app/features/settings/models/oauth_account_model.dart'; +import 'package:eatvio_app/features/settings/models/oauth_attach_request.dart'; import 'package:eatvio_app/features/settings/models/profile_settings_model.dart'; import 'package:retrofit/retrofit.dart'; @@ -13,12 +15,17 @@ abstract class SettingsService { Future getProfileSettings(); @PUT('/api/profile/settings/update') - Future updateProfileSettings({ - @Body() required Map data, - }); + Future updateProfileSettings({@Body() required Map data}); @PUT('/api/profile/update') - Future updateProfileDetails({ - @Body() required Map data, - }); + Future updateProfileDetails({@Body() required Map data}); + + @GET('/api/oauth/accounts') + Future getOAuthAccounts(); + + @POST('/api/oauth/attach') + Future linkOAuthAccount(@Body() OAuthAttachRequest request); + + @DELETE('/api/oauth/accounts/{provider}') + Future unlinkOAuthAccount(@Path('provider') String provider); } diff --git a/pubspec.yaml b/pubspec.yaml index 5e3c7d25..940fdcd6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.3+82 +version: 1.3.4+83 publish_to: 'none' environment: @@ -25,10 +25,10 @@ dependencies: flutter_form_builder: ^10.2.0 image_picker: ^1.2.0 flutter_typeahead: ^5.2.0 - go_router: ^16.1.0 + go_router: ^17.0.0 keyboard_dismisser: ^3.0.0 retrofit: ^4.7.1 - get_it: ^8.2.0 + get_it: ^9.0.5 dio: ^5.9.0 dart_pusher_channels: ^1.2.3 photo_view: ^0.15.0 @@ -37,11 +37,11 @@ dependencies: easy_debounce: ^2.0.3 badges: ^3.1.2 google_fonts: ^6.3.0 - syncfusion_flutter_charts: ^30.2.5 - syncfusion_flutter_gauges: ^30.2.5 + syncfusion_flutter_charts: ^31.2.10 + syncfusion_flutter_gauges: ^31.2.10 table_calendar: ^3.2.0 flutter_keyboard_visibility: ^6.0.0 - share_plus: ^11.1.0 + share_plus: ^12.0.1 flutter_html: ^3.0.0 file_picker: ^10.3.1 audioplayers: ^6.5.0 @@ -49,7 +49,7 @@ dependencies: mask_text_input_formatter: ^2.9.0 lottie: ^3.3.1 flutter_portal: ^1.1.4 - fluttertoast: ^8.2.12 + fluttertoast: ^9.0.0 flutter_rating_bar: ^4.0.1 flutter_image_compress: ^2.4.0 image: ^4.5.4 @@ -57,8 +57,8 @@ dependencies: http: ^1.5.0 flutter_native_splash: ^2.4.6 webview_flutter: ^4.13.0 - sentry_flutter: ^9.6.0 - connectivity_plus: ^6.1.5 + sentry_flutter: ^9.8.0 + connectivity_plus: ^7.0.0 visibility_detector: ^0.4.0+2 firebase_core: ^4.0.0 firebase_messaging: ^16.0.0 @@ -67,17 +67,18 @@ dependencies: collection: ^1.19.1 shared_preferences: ^2.5.3 version: ^3.0.2 - package_info_plus: ^8.3.1 - device_info_plus: ^11.5.0 + package_info_plus: ^9.0.0 + device_info_plus: ^12.2.0 permission_handler: ^12.0.1 qr_code_scanner_plus: ^2.0.10+1 - flutter_dotenv: ^5.2.1 - uuid: ^4.5.1 + flutter_dotenv: ^6.0.0 + uuid: ^4.5.2 appmetrica_plugin: ^3.3.0 vk_id: ^1.0.3 flutter_login_yandex: ^0.0.5 markdown: ^7.3.0 gpassword: ^1.0.0 + mobile_scanner: ^7.1.3 dev_dependencies: build_runner: ^2.7.0 diff --git a/test/features/create_diet/integration_widget_test.dart b/test/features/create_diet/integration_widget_test.dart index 76f7de0b..0d8c7b31 100644 --- a/test/features/create_diet/integration_widget_test.dart +++ b/test/features/create_diet/integration_widget_test.dart @@ -35,7 +35,7 @@ void main() { setUp(() async { await sl.reset(); // Ensure dotenv has required keys before AppConfig constructor reads them - dotenv.testLoad(fileInput: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); + dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); // Register minimal AppConfig for tests to satisfy dependencies (e.g. image URL building) sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); mockCreateDietRepository = MockCreateDietRepository(); diff --git a/test/features/create_recipe/widget_test.dart b/test/features/create_recipe/widget_test.dart index 1b85ac35..6a60b0b3 100644 --- a/test/features/create_recipe/widget_test.dart +++ b/test/features/create_recipe/widget_test.dart @@ -35,7 +35,7 @@ void main() { setUp(() async { await sl.reset(); - dotenv.testLoad(fileInput: 'DEV_BASE_URL=\nBASE_URL=\n'); + dotenv.loadFromString(envString: 'DEV_BASE_URL=\nBASE_URL=\n'); mockRecipeRepository = MockRecipeRepository(); mockGlossaryRepository = MockGlossaryRepository(); createRecipeCubit = CreateRecipeCubit(recipeRepository: mockRecipeRepository, glossaryRepository: mockGlossaryRepository); diff --git a/test/features/diary/presentation/widgets/basic_widget_test.dart b/test/features/diary/presentation/widgets/basic_widget_test.dart index 05daded2..02c97f23 100644 --- a/test/features/diary/presentation/widgets/basic_widget_test.dart +++ b/test/features/diary/presentation/widgets/basic_widget_test.dart @@ -220,7 +220,7 @@ void main() { testWidgets('should display FinishDayButtonWidget', (WidgetTester tester) async { // Arrange - final widget = FinishDayButtonWidget(diaryDay: testDiaryEntity); + final widget = FinishDayButtonWidget(onPressed: () async {}); // Act await tester.pumpWidget(createTestWidget(widget)); @@ -234,7 +234,7 @@ void main() { // Arrange diaryCubit.emit(DiaryState.loaded(diary: testDiaryEntity, nutrition: testNutrition, selectedDay: DateTime(2024, 1, 15), selectedMeal: 1)); - final widget = FinishDayButtonWidget(diaryDay: testDiaryEntity); + final widget = FinishDayButtonWidget(onPressed: () async {}); await tester.pumpWidget(createTestWidget(widget)); await tester.pumpAndSettle();