diff --git a/lib/core/app_initializer.dart b/lib/core/app_initializer.dart index a30364df..601a7984 100644 --- a/lib/core/app_initializer.dart +++ b/lib/core/app_initializer.dart @@ -1,15 +1,11 @@ import 'dart:async'; -import 'dart:developer'; import 'dart:io' as io; -import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/env_config.dart'; import 'package:eatvio_app/core/error_reporting/error_report_policy.dart'; import 'package:eatvio_app/core/locator_service.dart' as di; -import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_fcm_handler.dart'; import 'package:eatvio_app/firebase_options.dart'; import 'package:firebase_core/firebase_core.dart'; -import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; @@ -18,7 +14,6 @@ import 'package:sentry_flutter/sentry_flutter.dart'; /// Класс для инициализации приложения class AppInitializer { - /// Инициализация всех сервисов приложения static Future initialize({required AppBuildType buildType}) async { await _initializeEnvironment(); @@ -42,25 +37,21 @@ class AppInitializer { /// Инициализация Firebase static Future _initializeFirebase() async { - await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform).timeout( + await Firebase.initializeApp( + options: DefaultFirebaseOptions.currentPlatform, + ).timeout( const Duration(seconds: 10), onTimeout: () { throw TimeoutException('Firebase initialization timeout'); }, ); VideoCallFcmHandler.registerBackgroundHandler(); - if (sl().buildType == AppBuildType.prod) { - try { - await FirebaseMessaging.instance.requestPermission().timeout(const Duration(seconds: 5)); - } catch (e) { - // Разрешение не критично, продолжаем работу - log('Failed to request Firebase messaging permission: $e'); - } - } } /// Инициализация системы внедрения зависимостей - static Future _initializeDependencyInjection({required AppBuildType buildType}) async { + static Future _initializeDependencyInjection({ + required AppBuildType buildType, + }) async { await di.init(buildType: buildType); } diff --git a/lib/core/firebase_api.dart b/lib/core/firebase_api.dart index a371faa3..d4f702f0 100644 --- a/lib/core/firebase_api.dart +++ b/lib/core/firebase_api.dart @@ -3,6 +3,7 @@ import 'dart:developer'; import 'dart:io'; import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_initializer.dart'; +import 'package:eatvio_app/core/notifications/notification_permission_requester.dart'; import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:flutter/services.dart'; import 'package:device_info_plus/device_info_plus.dart'; @@ -16,9 +17,14 @@ import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; class FirebaseApi { final ErrorReporter _errorReporter; + final NotificationPermissionRequester _notificationPermissionRequester; final _firebaseMessaging = FirebaseMessaging.instance; - FirebaseApi({required ErrorReporter errorReporter}) : _errorReporter = errorReporter; + FirebaseApi({ + required ErrorReporter errorReporter, + NotificationPermissionRequester? notificationPermissionRequester, + }) : _errorReporter = errorReporter, + _notificationPermissionRequester = notificationPermissionRequester ?? NotificationPermissionRequester(); Future getToken() async { String? token; @@ -29,16 +35,6 @@ class FirebaseApi { onTimeout: () => null, ); } else { - if ((sl().buildType == AppBuildType.prod)) { - try { - await _firebaseMessaging.requestPermission().timeout( - const Duration(seconds: 5), - ); - } catch (e) { - // Разрешение не критично, продолжаем - log('Failed to request Firebase messaging permission: $e'); - } - } token = await _firebaseMessaging.getToken().timeout( const Duration(seconds: 5), onTimeout: () => null, @@ -47,8 +43,20 @@ class FirebaseApi { return token; } + Future _requestNotificationPermission() async { + if (sl().buildType != AppBuildType.prod) return; + try { + await _notificationPermissionRequester.requestOnce( + () => _firebaseMessaging.requestPermission(), + ); + } catch (e) { + log('Failed to request Firebase messaging permission: $e'); + } + } + Future registerAnonymousPushToken() async { try { + await _requestNotificationPermission(); final deviceInfo = DeviceInfoPlugin(); await sl().post( '/api/anonymous_token', @@ -73,6 +81,7 @@ class FirebaseApi { Future registerPushToken() async { if (await TokenStorage.haveTokenInStorage()) { try { + await _requestNotificationPermission(); final deviceInfo = DeviceInfoPlugin(); final isHuawei = Platform.isAndroid ? await _isHuaweiDevice() : false; await sl().post( diff --git a/lib/core/notifications/notification_permission_requester.dart b/lib/core/notifications/notification_permission_requester.dart new file mode 100644 index 00000000..aefcea48 --- /dev/null +++ b/lib/core/notifications/notification_permission_requester.dart @@ -0,0 +1,22 @@ +class NotificationPermissionRequester { + Future? _inFlight; + + /// Coalesces overlapping calls so the OS prompt is shown at most once at a time. + Future requestOnce(Future Function() requestPermission) async { + final inFlight = _inFlight; + if (inFlight != null) { + await inFlight; + return; + } + + final future = requestPermission(); + _inFlight = future; + try { + await future; + } finally { + if (identical(_inFlight, future)) { + _inFlight = null; + } + } + } +} diff --git a/lib/features/complaining/presentation/complaining_bottom_sheet.dart b/lib/features/complaining/presentation/complaining_bottom_sheet.dart index aa5e1b90..185738c2 100644 --- a/lib/features/complaining/presentation/complaining_bottom_sheet.dart +++ b/lib/features/complaining/presentation/complaining_bottom_sheet.dart @@ -33,9 +33,7 @@ class _ComplainingBottomSheetState extends State { create: (context) => sl()..setContentTypeAndIdentifier(contentType: widget.complainedContentType, slug: widget.slug, id: widget.id), child: Container( padding: const EdgeInsets.only(top: 8, bottom: 24, left: 16, right: 16), - decoration: const BoxDecoration( - borderRadius: BorderRadius.vertical(top: Radius.circular(12)), - ), + decoration: const BoxDecoration(borderRadius: BorderRadius.vertical(top: Radius.circular(12))), child: BlocBuilder( builder: (context, state) { final category = state.category; @@ -72,10 +70,7 @@ class _CategoryWidget extends StatelessWidget { children: [ const BottomSheetHandlerBar(), const SizedBox(height: 20), - Text( - s.complaint, - style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), - ), + Text(s.complaint, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)), const SizedBox(height: 8), Text( s.complaint_question, @@ -114,10 +109,7 @@ class _SpamWidget extends StatelessWidget { children: [ const BottomSheetHandlerBar(), const SizedBox(height: 20), - Text( - s.spam, - style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), - ), + Text(s.spam, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)), const SizedBox(height: 8), SizedBox( width: MediaQuery.sizeOf(context).width * 0.7, @@ -145,10 +137,7 @@ class _ExplicitImageWidget extends StatelessWidget { children: [ const BottomSheetHandlerBar(), const SizedBox(height: 20), - Text( - s.explicitImages, - style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), - ), + Text(s.explicitImages, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)), const SizedBox(height: 8), SizedBox( width: MediaQuery.sizeOf(context).width * 0.7, @@ -202,10 +191,7 @@ class _LawViolationWidgetState extends State<_LawViolationWidget> { children: [ const BottomSheetHandlerBar(), const SizedBox(height: 20), - Text( - s.lawViolation, - style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), - ), + Text(s.lawViolation, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)), const SizedBox(height: 8), SizedBox( width: MediaQuery.sizeOf(context).width * 0.7, @@ -235,10 +221,7 @@ class _LawViolationWidgetState extends State<_LawViolationWidget> { }); }, ), - Text( - element.$2, - style: TextStyles.textLSemibold.copyWith(color: appTheme.black), - ), + Text(element.$2, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)), ], ); }), @@ -254,12 +237,10 @@ class _LawViolationWidgetState extends State<_LawViolationWidget> { if ((state.complainInfo.description?.length ?? 10) < 10) ...[ const SizedBox(height: 4), const ValidationErrorWidget(error: 'Минимум 10 символов'), - ] + ], ], const SizedBox(height: 20), - _ComplainButton( - isDisabled: (state.complainInfo.description?.length ?? 0) < 10, - ), + _ComplainButton(isDisabled: tags.firstWhere((tag) => tag.$2 == s.other).$1 && (state.complainInfo.description?.length ?? 0) < 10), ], ); }, @@ -301,10 +282,7 @@ class _ViolenceAndHostilityState extends State<_ViolenceAndHostility> { children: [ const BottomSheetHandlerBar(), const SizedBox(height: 20), - Text( - s.lawViolation, - style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), - ), + Text(s.lawViolation, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)), const SizedBox(height: 8), SizedBox( width: MediaQuery.sizeOf(context).width * 0.7, @@ -332,10 +310,7 @@ class _ViolenceAndHostilityState extends State<_ViolenceAndHostility> { }, ), Expanded( - child: Text( - element.$2, - style: TextStyles.textLSemibold.copyWith(color: appTheme.black), - ), + child: Text(element.$2, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)), ), ], ); @@ -363,15 +338,9 @@ class _DeceptionWidgetState extends State<_DeceptionWidget> { super.didChangeDependencies(); final s = S.of(context); if (tags.isEmpty && descriptions.isEmpty) { - tags.addAll([ - (false, s.misrepresentation), - (false, s.fraud), - ]); + tags.addAll([(false, s.misrepresentation), (false, s.fraud)]); - descriptions.addAll([ - s.misrepresentation_description, - s.fraud_description, - ]); + descriptions.addAll([s.misrepresentation_description, s.fraud_description]); } } @@ -383,10 +352,7 @@ class _DeceptionWidgetState extends State<_DeceptionWidget> { children: [ const BottomSheetHandlerBar(), const SizedBox(height: 20), - Text( - s.lawViolation, - style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), - ), + Text(s.lawViolation, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)), const SizedBox(height: 8), SizedBox( width: MediaQuery.sizeOf(context).width * 0.7, @@ -419,16 +385,10 @@ class _DeceptionWidgetState extends State<_DeceptionWidget> { crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 8), - Text( - element.$2, - style: TextStyles.textLSemibold.copyWith(color: appTheme.black), - ), + Text(element.$2, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)), SizedBox( width: MediaQuery.sizeOf(context).width * 0.7, - child: Text( - descriptions[index], - style: TextStyles.textSSemibold.copyWith(color: appTheme.lightGray600), - ), + child: Text(descriptions[index], style: TextStyles.textSSemibold.copyWith(color: appTheme.lightGray600)), ), ], ), @@ -453,10 +413,7 @@ class _ComplaintSentWidget extends StatelessWidget { children: [ const BottomSheetHandlerBar(), const SizedBox(height: 20), - Text( - s.complaint_sent, - style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), - ), + Text(s.complaint_sent, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)), const SizedBox(height: 8), SizedBox( width: MediaQuery.sizeOf(context).width * 0.7, @@ -471,10 +428,7 @@ class _ComplaintSentWidget extends StatelessWidget { text: s.ok, height: 52, buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), - buttonStyle: ButtonStyle( - side: const WidgetStatePropertyAll(BorderSide.none), - backgroundColor: WidgetStatePropertyAll(appTheme.blueA400), - ), + buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)), onPressed: () { AppRouter.navigatorKey.currentContext?.pop(); }, @@ -495,10 +449,7 @@ class _ComplaintFailedWidget extends StatelessWidget { children: [ const BottomSheetHandlerBar(), const SizedBox(height: 20), - Text( - s.could_not_send_complain, - style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), - ), + Text(s.could_not_send_complain, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)), const SizedBox(height: 8), SizedBox( width: MediaQuery.sizeOf(context).width * 0.7, @@ -513,10 +464,7 @@ class _ComplaintFailedWidget extends StatelessWidget { text: s.ok, height: 52, buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), - buttonStyle: ButtonStyle( - side: const WidgetStatePropertyAll(BorderSide.none), - backgroundColor: WidgetStatePropertyAll(appTheme.blueA400), - ), + buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)), onPressed: () { AppRouter.navigatorKey.currentContext?.pop(); }, @@ -535,16 +483,13 @@ class _ComplainButton extends StatelessWidget { return BlocBuilder( builder: (context, state) { if (state.isLoading) { - return const Loader(); + return const SizedBox(height: 52, child: Loader()); } return CustomOutlinedButton( text: s.send_complain, height: 52, buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), - buttonStyle: ButtonStyle( - side: const WidgetStatePropertyAll(BorderSide.none), - backgroundColor: WidgetStatePropertyAll(appTheme.blueA400), - ), + buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)), onPressed: () async { if (isDisabled) { context.read().setDescription(''); @@ -570,10 +515,7 @@ class _CategoryButton extends StatelessWidget { child: Ink( child: Row( children: [ - Text( - category.getTranslatedText(), - style: TextStyles.textLSemibold.copyWith(color: appTheme.black), - ), + Text(category.getTranslatedText(), style: TextStyles.textLSemibold.copyWith(color: appTheme.black)), const Spacer(), SvgPicture.asset('assets/icons/right_arrow.svg'), ], @@ -591,18 +533,18 @@ enum ComplainType { explicitImage; String getTranslatedText() => switch (this) { - ComplainType.spam => S.current.spam, - ComplainType.lawViolation => S.current.lawViolation, - ComplainType.deception => S.current.deception, - ComplainType.violenceDndHostility => S.current.violenceAndHostility, - ComplainType.explicitImage => S.current.explicitImage, - }; + ComplainType.spam => S.current.spam, + ComplainType.lawViolation => S.current.lawViolation, + ComplainType.deception => S.current.deception, + ComplainType.violenceDndHostility => S.current.violenceAndHostility, + ComplainType.explicitImage => S.current.explicitImage, + }; String getValue() => switch (this) { - ComplainType.spam => 'spam', - ComplainType.lawViolation => 'legalViolation', - ComplainType.deception => 'fraud', - ComplainType.violenceDndHostility => 'violenceAndHatred', - ComplainType.explicitImage => 'explicitImage', - }; + ComplainType.spam => 'spam', + ComplainType.lawViolation => 'legalViolation', + ComplainType.deception => 'fraud', + ComplainType.violenceDndHostility => 'violenceAndHatred', + ComplainType.explicitImage => 'explicitImage', + }; } diff --git a/lib/features/complaining/presentation/cubit/complaining_cubit.dart b/lib/features/complaining/presentation/cubit/complaining_cubit.dart index b531e6b8..67f92984 100644 --- a/lib/features/complaining/presentation/cubit/complaining_cubit.dart +++ b/lib/features/complaining/presentation/cubit/complaining_cubit.dart @@ -36,6 +36,10 @@ class ComplainingCubit extends Cubit { } Future complain() async { + if (state.isLoading) { + return; + } + emit(state.copyWith(isLoading: true)); try { final category = state.category; final id = state.contentId; @@ -46,37 +50,37 @@ class ComplainingCubit extends Cubit { case ComplainedContent.diet: if (id != null) { await repository.sendDietComplain(id: id, category: category.getValue(), body: state.complainInfo); - emit(state.copyWith(wasSentSuccessfully: true)); + emit(state.copyWith(wasSentSuccessfully: true, isLoading: false)); return; } case ComplainedContent.plan: if (slug != null) { await repository.sendPlanComplain(slug: slug, category: category.getValue(), body: state.complainInfo); - emit(state.copyWith(wasSentSuccessfully: true)); + emit(state.copyWith(wasSentSuccessfully: true, isLoading: false)); return; } case ComplainedContent.recipe: if (slug != null) { await repository.sendRecipeComplain(slug: slug, category: category.getValue(), body: state.complainInfo); - emit(state.copyWith(wasSentSuccessfully: true)); + emit(state.copyWith(wasSentSuccessfully: true, isLoading: false)); return; } case ComplainedContent.profile: if (slug != null) { await repository.sendProfileComplain(slug: slug, category: category.getValue(), body: state.complainInfo); - emit(state.copyWith(wasSentSuccessfully: true)); + emit(state.copyWith(wasSentSuccessfully: true, isLoading: false)); return; } case ComplainedContent.posts: if (id != null) { await repository.sendBlogPostsComplain(id: id, category: category.getValue(), body: state.complainInfo); - emit(state.copyWith(wasSentSuccessfully: true)); + emit(state.copyWith(wasSentSuccessfully: true, isLoading: false)); return; } case ComplainedContent.comment: if (id != null) { await repository.sendCommentComplain(id: id, category: category.getValue(), body: state.complainInfo); - emit(state.copyWith(wasSentSuccessfully: true)); + emit(state.copyWith(wasSentSuccessfully: true, isLoading: false)); return; } } @@ -85,6 +89,6 @@ class ComplainingCubit extends Cubit { await reportFrom(e, st, this); log(e.toString(), stackTrace: st); } - emit(state.copyWith(wasSentSuccessfully: false)); + emit(state.copyWith(wasSentSuccessfully: false, isLoading: false)); } } diff --git a/lib/features/create_plan/presentation/create_plan_screen.dart b/lib/features/create_plan/presentation/create_plan_screen.dart index 1b0a3e5c..b9e8979c 100644 --- a/lib/features/create_plan/presentation/create_plan_screen.dart +++ b/lib/features/create_plan/presentation/create_plan_screen.dart @@ -53,7 +53,7 @@ class _CreatePlanScreenContentState extends State { if (state.planCreated) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!context.mounted) return; - context.pop(); + context.pop(true); showBottomSheetWithBlur( userRootNavigator: true, context: context, diff --git a/lib/features/diary/presentation/widgets/calendar_widget.dart b/lib/features/diary/presentation/widgets/calendar_widget.dart index bcf1ab09..56361371 100644 --- a/lib/features/diary/presentation/widgets/calendar_widget.dart +++ b/lib/features/diary/presentation/widgets/calendar_widget.dart @@ -16,6 +16,9 @@ class CalendarWidget extends StatefulWidget { } class _CalendarWidgetState extends State { + static const _kDaysOfWeekHeight = 16.0; + static const _kRowHeight = 52.0; + CalendarFormat _calendarFormat = CalendarFormat.week; DateTime _focusedDay = DateTime.now(); DateTime _selectedDay = DateTime.now(); @@ -40,6 +43,8 @@ class _CalendarWidgetState extends State { @override Widget build(BuildContext context) { final s = S.of(context); + final textScaler = MediaQuery.textScalerOf(context); + final daysOfWeekHeight = _scaledDaysOfWeekHeight(context); return BlocConsumer( listener: (BuildContext context, DiaryState state) { if (state is DiaryStateLoaded) { @@ -61,6 +66,8 @@ class _CalendarWidgetState extends State { locale: 'ru_RU', startingDayOfWeek: StartingDayOfWeek.monday, calendarFormat: _calendarFormat, + daysOfWeekHeight: daysOfWeekHeight, + rowHeight: textScaler.scale(_kRowHeight), headerStyle: const HeaderStyle(titleCentered: true, formatButtonVisible: false, leftChevronVisible: false, rightChevronVisible: false), onDaySelected: (selectedDay, focusedDay) async { if (!isSameDay(_selectedDay, selectedDay)) { @@ -116,6 +123,18 @@ class _CalendarWidgetState extends State { ); } + double _scaledDaysOfWeekHeight(BuildContext context) { + final painter = TextPainter( + text: TextSpan(text: 'Щy', style: DefaultTextStyle.of(context).style), + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + maxLines: 1, + )..layout(); + final height = painter.height; + painter.dispose(); + return height.clamp(_kDaysOfWeekHeight, double.infinity); + } + void _nextCalendarFormat() { setState(() { _calendarFormat = CalendarFormat.values[(_calendarFormat.index - 1) % CalendarFormat.values.length]; diff --git a/lib/features/feed/presentation/bloc/diets_cubit/diets_cubit.dart b/lib/features/feed/presentation/bloc/diets_cubit/diets_cubit.dart index 5d1d5357..78ddb568 100644 --- a/lib/features/feed/presentation/bloc/diets_cubit/diets_cubit.dart +++ b/lib/features/feed/presentation/bloc/diets_cubit/diets_cubit.dart @@ -92,6 +92,7 @@ class DietsCubit extends Cubit { }) async { try { _query = query; + currentPage = 1; final type = _types[state.filterState.typeIndex]; final duration = _durationValues[state.filterState.durationIndex]; final sorting = _sortingValues[state.filterState.sortingIndex]; diff --git a/lib/features/feed/presentation/bloc/plans_cubit/plans_cubit.dart b/lib/features/feed/presentation/bloc/plans_cubit/plans_cubit.dart index b99dbcac..20da68a0 100644 --- a/lib/features/feed/presentation/bloc/plans_cubit/plans_cubit.dart +++ b/lib/features/feed/presentation/bloc/plans_cubit/plans_cubit.dart @@ -149,6 +149,7 @@ class PlansCubit extends Cubit { }) async { try { _query = query; + currentPage = 1; final type = _types[state.filterState.typeIndex]; final duration = _durationValues[state.filterState.durationIndex]; final minPrice = state.filterState.priceFilter.$1; diff --git a/lib/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart b/lib/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart index 233cf7aa..afc19f7c 100644 --- a/lib/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart +++ b/lib/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart @@ -46,6 +46,7 @@ class RecipesCubit extends Cubit { }) async { try { _query = query; + currentPage = 1; final recipesList = await feedRepository.getRecipes( tags: _selectedTags.toList(), query: query, diff --git a/lib/features/feed/presentation/pages/feed_screen.dart b/lib/features/feed/presentation/pages/feed_screen.dart index 58664992..0dfb28e8 100644 --- a/lib/features/feed/presentation/pages/feed_screen.dart +++ b/lib/features/feed/presentation/pages/feed_screen.dart @@ -123,6 +123,19 @@ class _FeedScreenContentState extends State { super.dispose(); } + Future _openCreateAndReloadFeed({ + required Future Function() openCreate, + required Future Function() reload, + }) async { + final result = await openCreate(); + if (!mounted) { + return; + } + if (shouldReloadFeedAfterCreate(result)) { + await reload(); + } + } + @override Widget build(BuildContext context) { FlutterNativeSplash.remove(); @@ -254,26 +267,43 @@ class _FeedScreenContentState extends State { const SizedBox(width: 12), switch (_selectedChipIndex) { 1 when !hidePlans => AddItemButton( - onTap: state.profile?.roles.contains('consultant') ?? false ? () => GoRouter.of(context).pushNamed(CreatePlanScreen.name) : null, + onTap: state.profile?.roles.contains('consultant') ?? false + ? () => _openCreateAndReloadFeed( + openCreate: () => GoRouter.of(context).pushNamed(CreatePlanScreen.name), + reload: () => context.read().loadPlans(), + ) + : null, ), 2 when hidePlans => AddItemButton( - onTap: state.profile?.roles.contains('consultant') ?? false ? () => GoRouter.of(context).pushNamed(RecipeCreateScreen.name) : null, + onTap: state.profile?.roles.contains('consultant') ?? false + ? () => _openCreateAndReloadFeed( + openCreate: () => GoRouter.of(context).pushNamed(RecipeCreateScreen.name), + reload: () => context.read().loadRecipesList(), + ) + : null, ), 3 when !hidePlans => AddItemButton( - onTap: state.profile?.roles.contains('consultant') ?? false ? () => GoRouter.of(context).pushNamed(RecipeCreateScreen.name) : null, + onTap: state.profile?.roles.contains('consultant') ?? false + ? () => _openCreateAndReloadFeed( + openCreate: () => GoRouter.of(context).pushNamed(RecipeCreateScreen.name), + reload: () => context.read().loadRecipesList(), + ) + : null, ), 3 when hidePlans => AddItemButton( onTap: state.profile?.roles.contains('consultant') ?? false - ? () async { - await GoRouter.of(context).pushNamed(CreateDietScreen.name, extra: {'isEditing': false}); - } + ? () => _openCreateAndReloadFeed( + openCreate: () => GoRouter.of(context).pushNamed(CreateDietScreen.name, extra: {'isEditing': false}), + reload: () => context.read().loadDiets(), + ) : null, ), 4 when !hidePlans => AddItemButton( onTap: state.profile?.roles.contains('consultant') ?? false - ? () async { - await GoRouter.of(context).pushNamed(CreateDietScreen.name); - } + ? () => _openCreateAndReloadFeed( + openCreate: () => GoRouter.of(context).pushNamed(CreateDietScreen.name), + reload: () => context.read().loadDiets(), + ) : null, ), _ => const AddItemButton(key: AppKeys.feedScreenBlogPostCreationButton), @@ -301,6 +331,9 @@ class _FeedScreenContentState extends State { } } +/// Create screens pop `true` (diet/plan) or `'active'` (recipe) after a successful publish. +bool shouldReloadFeedAfterCreate(Object? result) => result == true || result == 'active'; + enum ViewableContentType { plans, posts, diff --git a/lib/features/settings/presentation/pages/general_info_screen.dart b/lib/features/settings/presentation/pages/general_info_screen.dart index 2f799bf7..3fad0dc4 100644 --- a/lib/features/settings/presentation/pages/general_info_screen.dart +++ b/lib/features/settings/presentation/pages/general_info_screen.dart @@ -221,49 +221,51 @@ class _GeneralInfoScreenState extends State { } Widget _saveButton() { - return isLoading - ? const Center(child: Loader()) - : InkWell( - onTap: () async { - final hasError = _checkFields(); - if (hasError) { - return; - } - setState(() { - isLoading = true; - }); - await widget.settingsEntity.settingsCubit.updateProfileDetails( - profileDetailsModel: (widget.settingsEntity.settingsCubit.state as SettingsStateLoaded).profileDetails.copyWith( - firstName: firstNameController.text, - lastName: lastNameController.text, - birthdate: selectedDate.toString(), - gender: gender.value, - country: countryController.text, - city: cityController.text, - lang: languageController.text, - ), - ); - await widget.settingsEntity.settingsCubit.updateProfileSettings( - profileSettingsModel: (widget.settingsEntity.settingsCubit.state as SettingsStateLoaded).profileSettings.copyWith( - isPublicProfile: isPublicProfile.value, - ), - ); - setState(() { - isLoading = false; - }); - }, - borderRadius: BorderRadius.circular(20), - child: Ink( - height: 52, - width: MediaQuery.of(context).size.width - 32, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400), - child: const Center( - child: Text( - 'Сохранить', - style: TextStyle(color: Colors.white, height: 1, fontWeight: FontWeight.w600, fontSize: 18), - ), - ), - ), - ); + if (isLoading) { + return const SizedBox(height: 52, child: Loader()); + } + return InkWell( + onTap: () async { + final hasError = _checkFields(); + if (hasError) { + return; + } + setState(() { + isLoading = true; + }); + await widget.settingsEntity.settingsCubit.updateProfileDetails( + profileDetailsModel: (widget.settingsEntity.settingsCubit.state as SettingsStateLoaded).profileDetails.copyWith( + firstName: firstNameController.text, + lastName: lastNameController.text, + birthdate: selectedDate.toString(), + gender: gender.value, + country: countryController.text, + city: cityController.text, + lang: languageController.text, + ), + ); + await widget.settingsEntity.settingsCubit.updateProfileSettings( + profileSettingsModel: (widget.settingsEntity.settingsCubit.state as SettingsStateLoaded).profileSettings.copyWith( + isPublicProfile: isPublicProfile.value, + ), + ); + if (!mounted) return; + setState(() { + isLoading = false; + }); + }, + borderRadius: BorderRadius.circular(20), + child: Ink( + height: 52, + width: MediaQuery.of(context).size.width - 32, + decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400), + child: const Center( + child: Text( + 'Сохранить', + style: TextStyle(color: Colors.white, height: 1, fontWeight: FontWeight.w600, fontSize: 18), + ), + ), + ), + ); } } diff --git a/lib/features/video_calls/services/video_call_callkit_coordinator.dart b/lib/features/video_calls/services/video_call_callkit_coordinator.dart index 8facc589..a21f8060 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -92,17 +92,9 @@ class VideoCallCallKitCoordinator { onResumed: _onAndroidAppResumed, ); WidgetsBinding.instance.addObserver(_androidResumeObserver!); - _androidCallPushChannel.setMethodCallHandler(_onAndroidCallPushMethodCall); - await FlutterCallkitIncoming.requestNotificationPermission(< - String, - dynamic - >{ - 'title': 'Уведомления', - 'rationaleMessagePermission': - 'Разрешите уведомления, чтобы получать входящие видеозвонки.', - 'postNotificationMessageRequired': - 'Разрешите уведомления в настройках, чтобы получать входящие видеозвонки.', - }); + _androidCallPushChannel.setMethodCallHandler( + _onAndroidCallPushMethodCall, + ); WidgetsBinding.instance.addPostFrameCallback((_) { unawaited(_pollNativePendingAcceptAfterResume()); }); diff --git a/test/core/notifications/notification_permission_requester_test.dart b/test/core/notifications/notification_permission_requester_test.dart new file mode 100644 index 00000000..c8e7d6cc --- /dev/null +++ b/test/core/notifications/notification_permission_requester_test.dart @@ -0,0 +1,64 @@ +import 'dart:io'; + +import 'package:eatvio_app/core/notifications/notification_permission_requester.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('NotificationPermissionRequester', () { + test('вызывает requestPermission один раз', () async { + final requester = NotificationPermissionRequester(); + var calls = 0; + + await requester.requestOnce(() async { + calls++; + }); + + expect(calls, 1); + }); + + test('параллельные вызовы ждут один и тот же запрос', () async { + final requester = NotificationPermissionRequester(); + var calls = 0; + + Future request() { + return requester.requestOnce(() async { + calls++; + await Future.delayed(const Duration(milliseconds: 50)); + }); + } + + await Future.wait([request(), request(), request()]); + + expect(calls, 1); + }); + + test('после завершения можно запросить снова', () async { + final requester = NotificationPermissionRequester(); + var calls = 0; + + Future request() { + return requester.requestOnce(() async { + calls++; + }); + } + + await request(); + await request(); + + expect(calls, 2); + }); + }); + + group('единая точка запроса разрешения на уведомления', () { + test('старт приложения и CallKit не показывают отдельные системные окна', () { + final initializer = File('lib/core/app_initializer.dart').readAsStringSync(); + final callkit = File('lib/features/video_calls/services/video_call_callkit_coordinator.dart').readAsStringSync(); + final firebaseApi = File('lib/core/firebase_api.dart').readAsStringSync(); + + expect(initializer.contains('requestPermission'), isFalse); + expect(callkit.contains('requestNotificationPermission'), isFalse); + expect(firebaseApi.contains('_requestNotificationPermission'), isTrue); + expect(firebaseApi.contains('_firebaseMessaging.requestPermission()'), isTrue); + }); + }); +} diff --git a/test/features/complaining/presentation/complaining_bottom_sheet_smoke_test.dart b/test/features/complaining/presentation/complaining_bottom_sheet_smoke_test.dart index 55591318..b678c863 100644 --- a/test/features/complaining/presentation/complaining_bottom_sheet_smoke_test.dart +++ b/test/features/complaining/presentation/complaining_bottom_sheet_smoke_test.dart @@ -1,8 +1,11 @@ +import 'dart:async'; + import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_initializer.dart'; import 'package:eatvio_app/core/enums/complained_content.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/widgets/loader.dart'; import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart'; import 'package:eatvio_app/features/complaining/presentation/cubit/complaining_cubit.dart'; import 'package:eatvio_app/features/complaining/repositories/complaining_repository.dart'; @@ -12,6 +15,7 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; import 'complaining_bottom_sheet_smoke_test.mocks.dart'; @@ -59,7 +63,40 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(ComplainingBottomSheet), findsOneWidget); + }); + testWidgets('replaces send button with Loader while request is in progress', (tester) async { + final completer = Completer(); + when( + repository.sendPlanComplain( + slug: anyNamed('slug'), + category: anyNamed('category'), + body: anyNamed('body'), + ), + ).thenAnswer((_) => completer.future); + + await tester.pumpWidget( + wrap( + const ComplainingBottomSheet( + complainedContentType: ComplainedContent.plan, + slug: 'test-slug', + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Спам')); + await tester.pumpAndSettle(); + + expect(find.text('Отправить жалобу'), findsOneWidget); + await tester.tap(find.text('Отправить жалобу')); + await tester.pump(); + + expect(find.byType(Loader), findsOneWidget); + expect(find.text('Отправить жалобу'), findsNothing); + + completer.complete(); + await tester.pump(); }); }); } diff --git a/test/features/complaining/presentation/cubit/complaining_cubit_test.dart b/test/features/complaining/presentation/cubit/complaining_cubit_test.dart index b1045650..29d33822 100644 --- a/test/features/complaining/presentation/cubit/complaining_cubit_test.dart +++ b/test/features/complaining/presentation/cubit/complaining_cubit_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:eatvio_app/core/enums/complained_content.dart'; import 'package:eatvio_app/features/complaining/models/complain_model.dart'; import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart'; @@ -97,6 +99,7 @@ void main() { await cubit.complain(); expect(cubit.state.wasSentSuccessfully, true); + expect(cubit.state.isLoading, false); verify(() => mockRepository.sendDietComplain( id: id, category: any(named: 'category'), @@ -104,6 +107,30 @@ void main() { )).called(1); }); + test('complain - ставит isLoading на время запроса', () async { + const id = 1; + final completer = Completer(); + + when(() => mockRepository.sendDietComplain( + id: id, + category: any(named: 'category'), + body: any(named: 'body'), + )).thenAnswer((_) => completer.future); + + cubit.setContentTypeAndIdentifier(contentType: ComplainedContent.diet, id: id); + cubit.setCategory(ComplainType.spam); + + final future = cubit.complain(); + expect(cubit.state.isLoading, true); + expect(cubit.state.wasSentSuccessfully, isNull); + + completer.complete(); + await future; + + expect(cubit.state.isLoading, false); + expect(cubit.state.wasSentSuccessfully, true); + }); + test('complain - возвращает false при ошибке', () async { const id = 1; @@ -119,6 +146,7 @@ void main() { await cubit.complain(); expect(cubit.state.wasSentSuccessfully, false); + expect(cubit.state.isLoading, false); }); test('complain - отправляет жалобу на plan', () async { diff --git a/test/features/diary/presentation/widgets/calendar_widget_smoke_test.dart b/test/features/diary/presentation/widgets/calendar_widget_smoke_test.dart index 1cb59eb0..5bd61bda 100644 --- a/test/features/diary/presentation/widgets/calendar_widget_smoke_test.dart +++ b/test/features/diary/presentation/widgets/calendar_widget_smoke_test.dart @@ -4,9 +4,12 @@ import 'package:eatvio_app/features/diary/presentation/bloc/diary_state.dart'; import 'package:eatvio_app/features/diary/presentation/widgets/calendar_widget.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:table_calendar/table_calendar.dart'; class FakeDiaryCubit extends Cubit implements DiaryCubit { FakeDiaryCubit(super.initialState); @@ -16,9 +19,20 @@ class FakeDiaryCubit extends Cubit implements DiaryCubit { void main() { group('CalendarWidget smoke', () { - Widget wrap(Widget child, DiaryCubit cubit) { + setUpAll(() async { + await initializeDateFormatting('ru', null); + await initializeDateFormatting('ru_RU', null); + }); + + Widget wrap(Widget child, DiaryCubit cubit, {double textScale = 1}) { return MaterialApp( theme: ThemeHelper().themeData(), + builder: (context, appChild) { + return MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(textScale)), + child: appChild!, + ); + }, localizationsDelegates: const [ S.delegate, GlobalMaterialLocalizations.delegate, @@ -37,9 +51,26 @@ void main() { final cubit = FakeDiaryCubit(const DiaryState.loading()); await tester.pumpWidget(wrap(const CalendarWidget(), cubit)); await tester.pump(); - // Just make sure it builds. expect(find.byType(CalendarWidget), findsOneWidget); await cubit.close(); }); + + testWidgets('scales weekday row when system font is large', (tester) async { + const textScale = 2.0; + final cubit = FakeDiaryCubit(const DiaryState.loading()); + await tester.pumpWidget(wrap(const CalendarWidget(), cubit, textScale: textScale)); + await tester.pump(); + + final calendar = tester.widget>(find.byType(TableCalendar)); + expect(calendar.daysOfWeekHeight, greaterThan(16 * textScale * 0.9)); + expect(calendar.rowHeight, 52 * textScale); + + expect(find.text('пн'), findsWidgets); + final weekday = tester.renderObject(find.text('пн').first); + expect(weekday.size.height, lessThanOrEqualTo(calendar.daysOfWeekHeight + 0.5)); + expect(tester.takeException(), isNull); + + await cubit.close(); + }); }); } diff --git a/test/features/feed/presentation/bloc/diets_cubit/diets_cubit_test.dart b/test/features/feed/presentation/bloc/diets_cubit/diets_cubit_test.dart index c64197ca..fb112102 100644 --- a/test/features/feed/presentation/bloc/diets_cubit/diets_cubit_test.dart +++ b/test/features/feed/presentation/bloc/diets_cubit/diets_cubit_test.dart @@ -156,6 +156,44 @@ void main() { expect(dietsCubit!.state.errorMessage, isNull); }); + test('loadDiets - сбрасывает currentPage при повторной загрузке', () async { + dietsCubit = DietsCubit( + feedRepository: mockFeedRepository, + dietsRepository: mockDietRepository, + ); + + const dietsPagination = DietsPagination( + data: [], + links: Links( + first: 'https://example.com?page=1', + last: 'https://example.com?page=1', + prev: null, + next: null, + ), + meta: Meta( + currentPage: 1, + from: null, + lastPage: 1, + links: [], + perPage: 10, + to: null, + total: 0, + ), + ); + + when(mockFeedRepository.getDiets( + query: anyNamed('query'), + types: anyNamed('types'), + duration: anyNamed('duration'), + sorting: anyNamed('sorting'), + )).thenAnswer((_) async => dietsPagination); + + dietsCubit!.currentPage = 4; + await dietsCubit!.loadDiets(); + + expect(dietsCubit!.currentPage, 1); + }); + test('loadDiets - обработка ошибки', () async { dietsCubit = DietsCubit( feedRepository: mockFeedRepository, diff --git a/test/features/feed/presentation/bloc/plans_cubit/plans_cubit_test.dart b/test/features/feed/presentation/bloc/plans_cubit/plans_cubit_test.dart index 94b9a839..7a22e09c 100644 --- a/test/features/feed/presentation/bloc/plans_cubit/plans_cubit_test.dart +++ b/test/features/feed/presentation/bloc/plans_cubit/plans_cubit_test.dart @@ -131,6 +131,31 @@ void main() { expect(plansCubit!.state.errorMessage, isNull); }); + test('loadPlans - сбрасывает currentPage при повторной загрузке', () async { + plansCubit = PlansCubit(feedRepository: mockFeedRepository, planRepository: mockPlanRepository); + + const plansPagination = PlanPaginationModel( + data: [], + links: Links(first: 'https://example.com?page=1', last: 'https://example.com?page=1', prev: null, next: null), + meta: Meta(currentPage: 1, from: 1, lastPage: 1, links: [], perPage: 10, to: 1, total: 0), + ); + + when( + mockFeedRepository.getPlans( + query: anyNamed('query'), + types: anyNamed('types'), + duration: anyNamed('duration'), + minPrice: anyNamed('minPrice'), + maxPrice: anyNamed('maxPrice'), + ), + ).thenAnswer((_) async => plansPagination); + + plansCubit!.currentPage = 4; + await plansCubit!.loadPlans(); + + expect(plansCubit!.currentPage, 1); + }); + test('loadPlans - обработка ошибки', () async { plansCubit = PlansCubit(feedRepository: mockFeedRepository, planRepository: mockPlanRepository); diff --git a/test/features/feed/presentation/bloc/recipes_cubit/recipes_cubit_test.dart b/test/features/feed/presentation/bloc/recipes_cubit/recipes_cubit_test.dart index 4aee71a6..1544699f 100644 --- a/test/features/feed/presentation/bloc/recipes_cubit/recipes_cubit_test.dart +++ b/test/features/feed/presentation/bloc/recipes_cubit/recipes_cubit_test.dart @@ -110,6 +110,18 @@ void main() { expect(recipesCubit!.state.errorMessage, isNull); }); + test('loadRecipesList - сбрасывает currentPage при повторной загрузке', () async { + recipesCubit = RecipesCubit(feedRepository: mockFeedRepository, recipeRepository: mockRecipeRepository, aboutRecipeRepository: mockAboutRecipeRepository); + + when(mockFeedRepository.getRecipes(tags: anyNamed('tags'), query: anyNamed('query'))).thenAnswer((_) async => []); + when(mockRecipeRepository.getRecipesTags()).thenAnswer((_) async => []); + + recipesCubit!.currentPage = 4; + await recipesCubit!.loadRecipesList(); + + expect(recipesCubit!.currentPage, 1); + }); + test('updateTags - добавляет и удаляет теги', () { recipesCubit = RecipesCubit(feedRepository: mockFeedRepository, recipeRepository: mockRecipeRepository, aboutRecipeRepository: mockAboutRecipeRepository); diff --git a/test/features/feed/presentation/pages/feed_screen_smoke_test.dart b/test/features/feed/presentation/pages/feed_screen_smoke_test.dart index 3f7923b2..b986e350 100644 --- a/test/features/feed/presentation/pages/feed_screen_smoke_test.dart +++ b/test/features/feed/presentation/pages/feed_screen_smoke_test.dart @@ -136,4 +136,19 @@ void main() { expect(find.byType(FeedScreen), findsOneWidget); }); }); + + group('shouldReloadFeedAfterCreate', () { + test('перезагружает ленту после публикации диеты или плана', () { + expect(shouldReloadFeedAfterCreate(true), isTrue); + }); + + test('перезагружает ленту после публикации рецепта', () { + expect(shouldReloadFeedAfterCreate('active'), isTrue); + }); + + test('не перезагружает ленту при отмене или черновике рецепта', () { + expect(shouldReloadFeedAfterCreate(null), isFalse); + expect(shouldReloadFeedAfterCreate('drafted'), isFalse); + }); + }); } diff --git a/test/features/settings/presentation/pages/general_info_screen_test.dart b/test/features/settings/presentation/pages/general_info_screen_test.dart index 9a51a2a2..2f0fa437 100644 --- a/test/features/settings/presentation/pages/general_info_screen_test.dart +++ b/test/features/settings/presentation/pages/general_info_screen_test.dart @@ -1,7 +1,10 @@ +import 'dart:async'; + import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_initializer.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/widgets/loader.dart'; import 'package:eatvio_app/features/profile/models/profile_model.dart'; import 'package:eatvio_app/features/settings/entities/settings_entity.dart'; import 'package:eatvio_app/features/settings/models/profile_settings_model.dart'; @@ -130,5 +133,52 @@ void main() { expect(find.byType(GeneralInfoScreen), findsOneWidget); }); + + testWidgets('лоадер при сохранении появляется вместо кнопки, а не поверх экрана', (tester) async { + final cubit = MockSettingsCubit(); + const state = SettingsState.loaded( + profileSettings: mockProfileSettings, + profileDetails: mockProfileDetails, + profile: mockProfile, + hasActiveConsultation: false, + ); + final saveCompleter = Completer(); + + when(cubit.state).thenReturn(state); + when(cubit.stream).thenAnswer((_) => Stream.value(state)); + when(cubit.close()).thenAnswer((_) async => Future.value()); + when(cubit.updateProfileDetails(profileDetailsModel: anyNamed('profileDetailsModel'))).thenAnswer((_) => saveCompleter.future); + when(cubit.updateProfileSettings(profileSettingsModel: anyNamed('profileSettingsModel'))).thenAnswer((_) async {}); + + final settingsEntity = SettingsEntity(profileSettingsModel: mockProfileSettings, profileDetailsModel: mockProfileDetails, settingsCubit: cubit); + + await tester.pumpWidget( + BlocProvider.value( + value: cubit, + child: wrap(GeneralInfoScreen(settingsEntity: settingsEntity)), + ), + ); + await tester.pump(); + + expect(find.text('Сохранить'), findsOneWidget); + expect(find.byType(Loader), findsNothing); + + await tester.tap(find.text('Сохранить')); + await tester.pump(); + + expect(find.text('Сохранить'), findsNothing); + expect(find.byType(Loader), findsOneWidget); + expect(find.text('Введите имя'), findsOneWidget); + + final loaderSize = tester.getSize(find.byType(Loader)); + expect(loaderSize.height, lessThan(60)); + + saveCompleter.complete(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.text('Сохранить'), findsOneWidget); + expect(find.byType(Loader), findsNothing); + }); }); }