From 4a5c67632e34befe5300070e32045c9169d3c5c8 Mon Sep 17 00:00:00 2001 From: misha Date: Thu, 29 Oct 2026 13:55:38 +0300 Subject: [PATCH 01/10] feat(video_calls): app opening from terminated state fix --- ios/Runner/AppDelegate.swift | 82 ++++--------------- ios/Runner/SceneDelegate.swift | 4 + ios/Runner/VideoCallDeclineClient.swift | 26 ++++-- .../video_call_callkit_coordinator.dart | 25 ++++++ 4 files changed, 63 insertions(+), 74 deletions(-) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 488ea9c0..8dcf4f5f 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -437,73 +437,21 @@ import flutter_foreground_task return payload } - private func bringAppToForeground() { - DispatchQueue.main.async { - for scene in UIApplication.shared.connectedScenes { - guard let windowScene = scene as? UIWindowScene else { continue } - for window in windowScene.windows { - window.makeKeyAndVisible() - } - } - self.window?.makeKeyAndVisible() - } - } - - private func openAppForAcceptedCall(_ call: Call) { - guard let conversationUuid = conversationUuid(fromCall: call), !conversationUuid.isEmpty else { - recordVoipDiagnostic( - "accepted_call_missing_conversation_uuid", - data: ["callKitId": call.data.uuid] - ) + /// The answer callback runs before the scene is visible. A custom-scheme + /// open from here is an untrusted user action and SpringBoard denies it. + /// CallKit foregrounds a video call itself after `fulfill`. Flutter reads + /// the stored payload once that scene is actually active. + func deliverStoredAcceptedCall() { + guard voipDiagnosticsChannel != nil else { return } + guard let payload = UserDefaults.standard.dictionary(forKey: pendingAcceptedVoipCallKey) else { return } - - var components = URLComponents() - components.scheme = appUrlScheme - components.host = "video-call" - var queryItems = [ - URLQueryItem(name: "callKitId", value: call.data.uuid), - URLQueryItem(name: "conversationUuid", value: conversationUuid) - ] - let serverCallId = extraServerCallId(call.data.extra) - if !serverCallId.isEmpty { - queryItems.append(URLQueryItem(name: "call_id", value: serverCallId)) - } - components.queryItems = queryItems - - guard let url = components.url else { - recordVoipDiagnostic( - "accepted_call_url_build_failed", - data: [ - "callKitId": call.data.uuid, - "conversationUuid": conversationUuid, - ] - ) + let acceptedAt = (payload[pendingAcceptedAtKey] as? NSNumber)?.doubleValue + if let acceptedAt, Date().timeIntervalSince1970 - acceptedAt > pendingAcceptedCallTtl { + UserDefaults.standard.removeObject(forKey: pendingAcceptedVoipCallKey) return } - - recordVoipDiagnostic( - "accepted_call_open_app_requested", - data: [ - "callKitId": call.data.uuid, - "conversationUuid": conversationUuid, - "url": url.absoluteString, - "appState": UIApplication.shared.applicationState.rawValue, - ] - ) - - bringAppToForeground() - - UIApplication.shared.open(url, options: [:]) { [weak self] success in - self?.recordVoipDiagnostic( - success ? "accepted_call_open_app_succeeded" : "accepted_call_open_app_failed", - data: [ - "callKitId": call.data.uuid, - "conversationUuid": conversationUuid, - "url": url.absoluteString, - ] - ) - } + voipDiagnosticsChannel?.invokeMethod("acceptedVoipCall", arguments: payload) } private func sanitizedDiagnosticValue(_ value: Any?) -> Any { @@ -1177,6 +1125,8 @@ import flutter_foreground_task // activation and can start I/O before didActivate. prepareAvAudioSessionCategory() storePendingAcceptedCall(call) + let extra = call.data.extra as? [String: Any] ?? [:] + VideoCallDeclineClient.sendAccept(extra: extra, handle: call.data.handle) recordVoipDiagnostic( "callkit_accept_native", data: [ @@ -1185,11 +1135,7 @@ import flutter_foreground_task "data": call.data.toJSON(), ] ) - voipDiagnosticsChannel?.invokeMethod("acceptedVoipCall", arguments: call.data.toJSON()) - DispatchQueue.main.async { [weak self] in - self?.bringAppToForeground() - self?.openAppForAcceptedCall(call) - } + deliverStoredAcceptedCall() action.fulfill() } diff --git a/ios/Runner/SceneDelegate.swift b/ios/Runner/SceneDelegate.swift index f27a5751..fd717aed 100644 --- a/ios/Runner/SceneDelegate.swift +++ b/ios/Runner/SceneDelegate.swift @@ -19,6 +19,10 @@ class SceneDelegate: FlutterSceneDelegate { override func sceneDidBecomeActive(_ scene: UIScene) { super.sceneDidBecomeActive(scene) registerCallChannels() + guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { + return + } + appDelegate.deliverStoredAcceptedCall() } override func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { diff --git a/ios/Runner/VideoCallDeclineClient.swift b/ios/Runner/VideoCallDeclineClient.swift index fb24ce50..2e3a20d7 100644 --- a/ios/Runner/VideoCallDeclineClient.swift +++ b/ios/Runner/VideoCallDeclineClient.swift @@ -8,25 +8,37 @@ enum VideoCallDeclineClient { /// `POST /video-call/decline` from the CallKit callback. The process is /// awake here even when the user never opens the app afterwards. static func send(extra: [String: Any], handle: String) { + post(action: "decline", extra: extra, handle: handle) + } + + /// `POST /video-call/accept` from the CallKit answer callback. Flutter may + /// still be frozen on the splash snapshot, so the other device must be + /// told from here. A later Dart retry with the same hw_id is idempotent. + static func sendAccept(extra: [String: Any], handle: String) { + post(action: "accept", extra: extra, handle: handle) + } + + private static func post(action: String, extra: [String: Any], handle: String) { let fields = declineFields(extra: extra, handle: handle) guard let request = makeRequest( baseUrl: prefsString("apiBaseUrl") ?? prefsString("customBaseUrl"), token: prefsString("token"), hwId: UIDevice.current.identifierForVendor?.uuidString, - fields: fields + fields: fields, + action: action ) else { NSLog( - "[EatvioVoIP] decline POST skipped conversation=\(fields.conversationUuid) ended=\(fields.endedPush)" + "[EatvioVoIP] \(action) POST skipped conversation=\(fields.conversationUuid) ended=\(fields.endedPush)" ) return } let task = EatvioBackgroundTask() - task.begin(name: "video-call-decline") + task.begin(name: "video-call-\(action)") let urlTask = URLSession.shared.dataTask(with: request) { _, response, error in let code = (response as? HTTPURLResponse)?.statusCode ?? -1 NSLog( - "[EatvioVoIP] decline POST status=\(code) conversation=\(fields.conversationUuid) error=\(error?.localizedDescription ?? "-")" + "[EatvioVoIP] \(action) POST status=\(code) conversation=\(fields.conversationUuid) error=\(error?.localizedDescription ?? "-")" ) task.end() } @@ -37,7 +49,8 @@ enum VideoCallDeclineClient { baseUrl: String?, token: String?, hwId: String?, - fields: VideoCallDeclineFields + fields: VideoCallDeclineFields, + action: String = "decline" ) -> URLRequest? { if fields.endedPush { return nil } let conversation = fields.conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines) @@ -50,8 +63,9 @@ enum VideoCallDeclineClient { guard !conversation.isEmpty, !authToken.isEmpty else { return nil } guard deviceId.count >= 8, deviceId.count <= 64 else { return nil } guard root.hasPrefix("http://") || root.hasPrefix("https://") else { return nil } + guard action == "accept" || action == "decline" else { return nil } guard let encoded = conversation.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed), - let url = URL(string: "\(root)/api/v2/chat/\(encoded)/video-call/decline") + let url = URL(string: "\(root)/api/v2/chat/\(encoded)/video-call/\(action)") else { return nil } var body: [String: String] = ["hw_id": deviceId] 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 5d9b456a..ddfe654d 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -52,6 +52,7 @@ class VideoCallCallKitCoordinator { _PendingIncomingAccept? _pendingIncomingAccept; _AndroidAcceptResumeObserver? _androidResumeObserver; + _AppResumeObserver? _iosResumeObserver; bool _pendingAcceptDrainScheduled = false; Future initialize() async { @@ -86,6 +87,12 @@ class VideoCallCallKitCoordinator { } }); await _firebaseApi.registerPushToken(); + _iosResumeObserver = _AppResumeObserver( + onResumed: () { + unawaited(_drainPendingNativeAcceptedVoipCall()); + }, + ); + WidgetsBinding.instance.addObserver(_iosResumeObserver!); await _drainPendingNativeAcceptedVoipCall(); WidgetsBinding.instance.addPostFrameCallback((_) { _scheduleNativeVoipDiagnosticsDrain(); @@ -257,6 +264,11 @@ class VideoCallCallKitCoordinator { WidgetsBinding.instance.removeObserver(observer); _androidResumeObserver = null; } + final iosObserver = _iosResumeObserver; + if (iosObserver != null) { + WidgetsBinding.instance.removeObserver(iosObserver); + _iosResumeObserver = null; + } unawaited(_eventSubscription?.cancel()); _eventSubscription = null; _callKitParamsById.clear(); @@ -1228,6 +1240,19 @@ class _PendingIncomingAccept { final bool? counterpartIsConsultant; } +class _AppResumeObserver with WidgetsBindingObserver { + _AppResumeObserver({required this.onResumed}); + + final VoidCallback onResumed; + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + onResumed(); + } + } +} + class _AndroidAcceptResumeObserver with WidgetsBindingObserver { _AndroidAcceptResumeObserver({required this.onResumed}); From 508c32191388db8394622e15deafb90bbc2c5e19 Mon Sep 17 00:00:00 2001 From: misha Date: Thu, 29 Oct 2026 13:56:06 +0300 Subject: [PATCH 02/10] version changed --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 4f5d4984..4d9cf965 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.68+157 +version: 1.3.69+158 publish_to: 'none' environment: From 6a31880a85f0c841059cb4ae7f439b08fde10ff8 Mon Sep 17 00:00:00 2001 From: alex-chevski Date: Thu, 29 Oct 2026 17:27:12 +0300 Subject: [PATCH 03/10] fix(consultations): open chat from consultation card The chat icon on the consultant consultation card had an empty onTap handler. Resolve the conversation with the client via the chat API and navigate to it. related to #1106 --- lib/core/di/cubit_registrations.dart | 2 +- .../cubit/consultations_cubit.dart | 15 +++++- .../widgets/consultation_card_widget.dart | 18 ++++++- .../cubit/consultations_cubit_test.dart | 38 ++++++++++++++- .../consultation_card_widget_test.dart | 48 ++++++++++++++++++- 5 files changed, 115 insertions(+), 6 deletions(-) diff --git a/lib/core/di/cubit_registrations.dart b/lib/core/di/cubit_registrations.dart index 05bfd7d1..19a1962b 100644 --- a/lib/core/di/cubit_registrations.dart +++ b/lib/core/di/cubit_registrations.dart @@ -263,7 +263,7 @@ class CubitRegistrations { ), ); sl.registerFactory( - () => ConsultationsCubit(consultationsRepository: sl()), + () => ConsultationsCubit(consultationsRepository: sl(), chatRepository: sl()), ); sl.registerFactory( () => ConsultantCommentsCubit(diaryRepository: sl()), diff --git a/lib/features/consultations/presentation/cubit/consultations_cubit.dart b/lib/features/consultations/presentation/cubit/consultations_cubit.dart index 80f8b206..ea830c88 100644 --- a/lib/features/consultations/presentation/cubit/consultations_cubit.dart +++ b/lib/features/consultations/presentation/cubit/consultations_cubit.dart @@ -1,6 +1,7 @@ import 'dart:developer'; import 'package:eatvio_app/core/enums/base_state.dart'; +import 'package:eatvio_app/features/chat/repositories/chat_repository.dart'; import 'package:eatvio_app/features/consultations/presentation/cubit/consultations_state.dart'; import 'package:eatvio_app/features/consultations/repositories/consultations_repository.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -8,7 +9,8 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart'; class ConsultationsCubit extends Cubit { final ConsultationsRepository consultationsRepository; - ConsultationsCubit({required this.consultationsRepository}) : super(ConsultationsState()); + final ChatRepository chatRepository; + ConsultationsCubit({required this.consultationsRepository, required this.chatRepository}) : super(ConsultationsState()); Future loadConsultations() async { try { @@ -20,4 +22,15 @@ class ConsultationsCubit extends Cubit { emit(state.copyWith(state: BaseState.error)); } } + + Future getChatIdByUserSlug(String slug) async { + try { + final chatIdOrError = await chatRepository.getChatByUserSlug(slug); + return chatIdOrError == '404' ? null : chatIdOrError; + } catch (e, st) { + await reportFrom(e, st, this); + log(e.toString(), stackTrace: st); + return null; + } + } } diff --git a/lib/features/consultations/presentation/widgets/consultation_card_widget.dart b/lib/features/consultations/presentation/widgets/consultation_card_widget.dart index 7efa25f7..e579c9b3 100644 --- a/lib/features/consultations/presentation/widgets/consultation_card_widget.dart +++ b/lib/features/consultations/presentation/widgets/consultation_card_widget.dart @@ -3,14 +3,17 @@ import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/widgets/custom_divider.dart'; import 'package:eatvio_app/core/widgets/custom_outlined_button.dart'; +import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:eatvio_app/core/widgets/show_error_dialog.dart'; import 'package:eatvio_app/features/consultations/models/consultation_model.dart'; +import 'package:eatvio_app/features/consultations/presentation/cubit/consultations_cubit.dart'; import 'package:eatvio_app/features/diary/presentation/pages/diary_screen.dart'; import 'package:eatvio_app/features/my_finance/presentation/pages/card_settings.dart'; import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart'; import 'package:eatvio_app/features/user_details/presentation/user_details_screen.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; class ConsultationCardWidget extends StatefulWidget { @@ -34,6 +37,7 @@ class _ConsultationCardWidgetState extends State { final List legendColors = const [Colors.white, Color(0xFFF0C497), Color(0xFFFF9500), Color(0xFF2F80ED), Color(0xFF4CD964)]; late final List<(int, DiaryModel)> givenDaysWithDayNumber; + bool _isOpeningChat = false; final List> legendStates = [ [DiaryStateEnum.init], [DiaryStateEnum.started, DiaryStateEnum.lastDayStarted], @@ -279,7 +283,7 @@ class _ConsultationCardWidgetState extends State { const SizedBox(width: 8), InkWell( borderRadius: BorderRadius.circular(20), - onTap: () {}, + onTap: _isOpeningChat ? null : _openChat, child: Ink( height: 52, width: 52, @@ -308,6 +312,18 @@ class _ConsultationCardWidgetState extends State { ); } + Future _openChat() async { + setState(() => _isOpeningChat = true); + final chatId = await context.read().getChatIdByUserSlug(widget.consultationsModel.userSlug); + if (!mounted) return; + setState(() => _isOpeningChat = false); + if (chatId == null) { + showErrorToast(S.of(context).msg_something_went_wrong); + return; + } + GoRouter.of(context).pushNamed('chatScreen', extra: {'uuid': chatId}); + } + Row _legendTitle(String text, Color color) { return Row( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/test/features/consultations/presentation/cubit/consultations_cubit_test.dart b/test/features/consultations/presentation/cubit/consultations_cubit_test.dart index 26ab5974..c9fe20b5 100644 --- a/test/features/consultations/presentation/cubit/consultations_cubit_test.dart +++ b/test/features/consultations/presentation/cubit/consultations_cubit_test.dart @@ -1,4 +1,5 @@ import 'package:eatvio_app/core/enums/base_state.dart'; +import 'package:eatvio_app/features/chat/repositories/chat_repository.dart'; import 'package:eatvio_app/features/consultations/models/consultation_model.dart'; import 'package:eatvio_app/features/consultations/presentation/cubit/consultations_cubit.dart'; import 'package:eatvio_app/features/consultations/presentation/cubit/consultations_state.dart'; @@ -8,14 +9,21 @@ import 'package:mocktail/mocktail.dart'; class MockConsultationsRepository extends Mock implements ConsultationsRepository {} +class MockChatRepository extends Mock implements ChatRepository {} + void main() { group('ConsultationsCubit', () { late ConsultationsCubit cubit; late MockConsultationsRepository mockRepository; + late MockChatRepository mockChatRepository; setUp(() { mockRepository = MockConsultationsRepository(); - cubit = ConsultationsCubit(consultationsRepository: mockRepository); + mockChatRepository = MockChatRepository(); + cubit = ConsultationsCubit( + consultationsRepository: mockRepository, + chatRepository: mockChatRepository, + ); }); tearDown(() { @@ -47,5 +55,33 @@ void main() { expect(cubit.state.state, BaseState.error); }); + + test('getChatIdByUserSlug - возвращает id чата', () async { + when(() => mockChatRepository.getChatByUserSlug('john-doe')) + .thenAnswer((_) async => 'chat-uuid'); + + final chatId = await cubit.getChatIdByUserSlug('john-doe'); + + expect(chatId, 'chat-uuid'); + verify(() => mockChatRepository.getChatByUserSlug('john-doe')).called(1); + }); + + test('getChatIdByUserSlug - возвращает null при ошибке', () async { + when(() => mockChatRepository.getChatByUserSlug(any())) + .thenThrow(Exception('Error')); + + final chatId = await cubit.getChatIdByUserSlug('john-doe'); + + expect(chatId, isNull); + }); + + test('getChatIdByUserSlug - возвращает null когда чат не найден', () async { + when(() => mockChatRepository.getChatByUserSlug(any())) + .thenAnswer((_) async => '404'); + + final chatId = await cubit.getChatIdByUserSlug('john-doe'); + + expect(chatId, isNull); + }); }); } diff --git a/test/features/consultations/presentation/widgets/consultation_card_widget_test.dart b/test/features/consultations/presentation/widgets/consultation_card_widget_test.dart index c897d8e2..c170f224 100644 --- a/test/features/consultations/presentation/widgets/consultation_card_widget_test.dart +++ b/test/features/consultations/presentation/widgets/consultation_card_widget_test.dart @@ -3,16 +3,21 @@ 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/features/consultations/models/consultation_model.dart'; +import 'package:eatvio_app/features/consultations/presentation/cubit/consultations_cubit.dart'; import 'package:eatvio_app/features/consultations/presentation/widgets/consultation_card_widget.dart'; import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:network_image_mock/network_image_mock.dart'; +class MockConsultationsCubit extends Mock implements ConsultationsCubit {} + void main() { group('ConsultationCardWidget smoke', () { setUpAll(() async { @@ -64,7 +69,8 @@ void main() { personalDietId: null, ); - Widget wrap(Widget child) { + Widget wrap(Widget child, {ConsultationsCubit? cubit}) { + final consultationsCubit = cubit ?? MockConsultationsCubit(); return MaterialApp.router( theme: ThemeHelper().themeData(), localizationsDelegates: const [ @@ -77,7 +83,20 @@ void main() { routerConfig: GoRouter( initialLocation: '/', routes: [ - GoRoute(path: '/', builder: (context, state) => child), + GoRoute( + path: '/', + builder: (context, state) => BlocProvider.value( + value: consultationsCubit, + child: child, + ), + ), + GoRoute( + path: '/chat-screen', + name: 'chatScreen', + builder: (context, state) => Scaffold( + body: Text('chat:${(state.extra as Map)['uuid']}'), + ), + ), GoRoute( path: '/profile-screen', name: 'profileScreen', @@ -163,5 +182,30 @@ void main() { }); }, ); + + testWidgets('opens chat with consultation user when chat icon tapped', ( + tester, + ) async { + final cubit = MockConsultationsCubit(); + when(() => cubit.stream).thenAnswer((_) => const Stream.empty()); + when(() => cubit.getChatIdByUserSlug('john-doe')) + .thenAnswer((_) async => 'chat-uuid'); + + await mockNetworkImagesFor(() async { + await tester.pumpWidget( + wrap( + ConsultationCardWidget(consultationsModel: mockConsultation), + cubit: cubit, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.chat_outlined)); + await tester.pumpAndSettle(); + + expect(find.text('chat:chat-uuid'), findsOneWidget); + verify(() => cubit.getChatIdByUserSlug('john-doe')).called(1); + }); + }); }); } From f14f42638133ab588ca90cd05f900edea2d03acf Mon Sep 17 00:00:00 2001 From: alex-chevski Date: Thu, 29 Oct 2026 18:09:08 +0300 Subject: [PATCH 04/10] fix(consultations): open chat via consultation chat_id Use chat_id from the consultations payload for direct navigation and keep the chat lookup as a fallback for legacy rows without chat_id. - validate chat id in ChatRepository and make it nullable - guard repeated taps while opening chat - handle nullable chat id in ProfileCubit and EmptyChatScreen related to #1106 --- .../presentation/pages/empty_chat_screen.dart | 24 ++-- .../chat/repositories/chat_repository.dart | 9 +- .../models/consultation_model.dart | 1 + .../cubit/consultations_cubit.dart | 3 +- .../widgets/consultation_card_widget.dart | 8 +- .../bloc/profile_cubit/profile_cubit.dart | 10 +- .../repositories/chat_repository_test.dart | 6 +- .../models/consultation_model_test.dart | 49 ++++++++ .../cubit/consultations_cubit_test.dart | 4 +- .../consultation_card_widget_test.dart | 105 ++++++++++++++++++ test/fixtures/doubles/profile_doubles.dart | 2 +- 11 files changed, 190 insertions(+), 31 deletions(-) diff --git a/lib/features/chat/presentation/pages/empty_chat_screen.dart b/lib/features/chat/presentation/pages/empty_chat_screen.dart index a0ddf60a..43f20390 100644 --- a/lib/features/chat/presentation/pages/empty_chat_screen.dart +++ b/lib/features/chat/presentation/pages/empty_chat_screen.dart @@ -135,9 +135,9 @@ class _EmptyChatScreenState extends State { textEditingController.clear(); }); } - final chatIdOrError = await sl().getChatByUserSlug(widget.user.slug); - if (context.mounted) { - AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError}); + final chatId = await sl().getChatByUserSlug(widget.user.slug); + if (chatId != null && chatId.isNotEmpty && context.mounted) { + AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId}); } } }, @@ -189,9 +189,9 @@ class _EmptyChatScreenState extends State { if (images.isNotEmpty) { List files = images.map((image) => File(image.path)).toList(); await sl().sendMessage(files: files, slug: widget.user.slug); - final chatIdOrError = await sl().getChatByUserSlug(widget.user.slug); - if (mounted) { - AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError}); + final chatId = await sl().getChatByUserSlug(widget.user.slug); + if (chatId != null && chatId.isNotEmpty && mounted) { + AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId}); } } }, @@ -205,9 +205,9 @@ class _EmptyChatScreenState extends State { if (images.isNotEmpty) { List files = images.map((image) => File(image.path)).toList(); await sl().sendMessage(files: files, slug: widget.user.slug); - final chatIdOrError = await sl().getChatByUserSlug(widget.user.slug); - if (mounted) { - AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError}); + final chatId = await sl().getChatByUserSlug(widget.user.slug); + if (chatId != null && chatId.isNotEmpty && mounted) { + AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId}); } } }, @@ -243,9 +243,9 @@ class _EmptyChatScreenState extends State { if (result != null) { List files = result.paths.map((path) => File(path!)).toList(); await sl().sendMessage(files: files, slug: widget.user.slug); - final chatIdOrError = await sl().getChatByUserSlug(widget.user.slug); - if (mounted) { - AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError}); + final chatId = await sl().getChatByUserSlug(widget.user.slug); + if (chatId != null && chatId.isNotEmpty && mounted) { + AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId}); } } }, diff --git a/lib/features/chat/repositories/chat_repository.dart b/lib/features/chat/repositories/chat_repository.dart index ade2ed26..9230e4d8 100644 --- a/lib/features/chat/repositories/chat_repository.dart +++ b/lib/features/chat/repositories/chat_repository.dart @@ -137,11 +137,12 @@ class ChatRepository { } - Future getChatByUserSlug(String slug) async { + Future getChatByUserSlug(String slug) async { final response = await chatService.getChatByUserSlug(slug: slug); - if (response.response.statusCode == 404) { - return '404'; + final data = response.data; + if (data is Map && data['id'] is String && (data['id'] as String).isNotEmpty) { + return data['id'] as String; } - return response.data['id']; + return null; } } diff --git a/lib/features/consultations/models/consultation_model.dart b/lib/features/consultations/models/consultation_model.dart index c73a8d37..50280cfe 100644 --- a/lib/features/consultations/models/consultation_model.dart +++ b/lib/features/consultations/models/consultation_model.dart @@ -9,6 +9,7 @@ part 'consultation_model.g.dart'; abstract class ConsultationModel with _$ConsultationModel { factory ConsultationModel({ required int id, + @JsonKey(name: 'chat_id') String? chatId, @JsonKey(name: 'last_state') required ChosenPlanStateEnum chosenPlanState, @JsonKey(name: 'plan_title') required String planTitle, @JsonKey(name: 'user_name') required String userName, diff --git a/lib/features/consultations/presentation/cubit/consultations_cubit.dart b/lib/features/consultations/presentation/cubit/consultations_cubit.dart index ea830c88..3b94fa8c 100644 --- a/lib/features/consultations/presentation/cubit/consultations_cubit.dart +++ b/lib/features/consultations/presentation/cubit/consultations_cubit.dart @@ -25,8 +25,7 @@ class ConsultationsCubit extends Cubit { Future getChatIdByUserSlug(String slug) async { try { - final chatIdOrError = await chatRepository.getChatByUserSlug(slug); - return chatIdOrError == '404' ? null : chatIdOrError; + return await chatRepository.getChatByUserSlug(slug); } catch (e, st) { await reportFrom(e, st, this); log(e.toString(), stackTrace: st); diff --git a/lib/features/consultations/presentation/widgets/consultation_card_widget.dart b/lib/features/consultations/presentation/widgets/consultation_card_widget.dart index e579c9b3..380ad7a9 100644 --- a/lib/features/consultations/presentation/widgets/consultation_card_widget.dart +++ b/lib/features/consultations/presentation/widgets/consultation_card_widget.dart @@ -313,11 +313,15 @@ class _ConsultationCardWidgetState extends State { } Future _openChat() async { + if (_isOpeningChat) return; setState(() => _isOpeningChat = true); - final chatId = await context.read().getChatIdByUserSlug(widget.consultationsModel.userSlug); + var chatId = widget.consultationsModel.chatId; + if (chatId == null || chatId.isEmpty) { + chatId = await context.read().getChatIdByUserSlug(widget.consultationsModel.userSlug); + } if (!mounted) return; setState(() => _isOpeningChat = false); - if (chatId == null) { + if (chatId == null || chatId.isEmpty) { showErrorToast(S.of(context).msg_something_went_wrong); return; } diff --git a/lib/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart b/lib/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart index 2801c2b4..304aeefe 100644 --- a/lib/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart +++ b/lib/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart @@ -141,13 +141,13 @@ class ProfileCubit extends Cubit { Future getChatRoomIdBySlug({required String slug}) async { try { - final chatIdOrError = await chatRepository.getChatByUserSlug(slug); - final userProfile = await profileRepository.getProfileBySlug(slug); - if (chatIdOrError == '404') { + final chatId = await chatRepository.getChatByUserSlug(slug); + if (chatId == null || chatId.isEmpty) { + final userProfile = await profileRepository.getProfileBySlug(slug); AppRouter.navigatorKey.currentContext?.pushNamed('emptyChatScreen', extra: userProfile); } else { - AppRouter.navigatorKey.currentContext?.pushNamed('chatScreen', extra: {'uuid': chatIdOrError}); - return chatIdOrError; + AppRouter.navigatorKey.currentContext?.pushNamed('chatScreen', extra: {'uuid': chatId}); + return chatId; } } catch (e, st) { await reportFrom(e, st, this); diff --git a/test/features/chat/repositories/chat_repository_test.dart b/test/features/chat/repositories/chat_repository_test.dart index 247d1311..e0c271ab 100644 --- a/test/features/chat/repositories/chat_repository_test.dart +++ b/test/features/chat/repositories/chat_repository_test.dart @@ -526,11 +526,11 @@ void main() { }, ); - test('getChatByUserSlug - возвращает 404 при отсутствии чата', () async { + test('getChatByUserSlug - возвращает null, если в ответе нет id', () async { const slug = 'user-slug'; final response = HttpResponse( {}, - Response(requestOptions: RequestOptions(path: ''), statusCode: 404), + Response(requestOptions: RequestOptions(path: ''), statusCode: 200), ); when( @@ -539,7 +539,7 @@ void main() { final result = await chatRepository.getChatByUserSlug(slug); - expect(result, '404'); + expect(result, isNull); verify(mockChatService.getChatByUserSlug(slug: slug)).called(1); }); diff --git a/test/features/consultations/models/consultation_model_test.dart b/test/features/consultations/models/consultation_model_test.dart index 6d927618..64ac25f9 100644 --- a/test/features/consultations/models/consultation_model_test.dart +++ b/test/features/consultations/models/consultation_model_test.dart @@ -156,6 +156,55 @@ void main() { }); group('ConsultationModel', () { + test('fromJson parses chat_id', () { + final json = { + 'id': 1, + 'chat_id': 'direct-chat-uuid', + 'last_state': 'started', + 'plan_title': 'Test Plan', + 'user_name': 'User 1', + 'user_slug': 'user-1', + 'consultant_payment': 1000.0, + 'duration': 30, + 'current_diary_day': 5, + 'user_profile_photo_url': 'photo.jpg', + 'consultation_option_price': 2000, + 'start_date': '2024-01-01T00:00:00Z', + 'end_date': '2024-01-31T00:00:00Z', + 'consultant_id': 1, + 'diaries': [], + 'personal_diet_id': null, + }; + + final model = ConsultationModel.fromJson(json); + + expect(model.chatId, 'direct-chat-uuid'); + }); + + test('fromJson allows missing chat_id', () { + final json = { + 'id': 1, + 'last_state': 'started', + 'plan_title': 'Test Plan', + 'user_name': 'User 1', + 'user_slug': 'user-1', + 'consultant_payment': 1000.0, + 'duration': 30, + 'current_diary_day': 5, + 'user_profile_photo_url': 'photo.jpg', + 'consultation_option_price': 2000, + 'start_date': '2024-01-01T00:00:00Z', + 'end_date': '2024-01-31T00:00:00Z', + 'consultant_id': 1, + 'diaries': [], + 'personal_diet_id': null, + }; + + final model = ConsultationModel.fromJson(json); + + expect(model.chatId, isNull); + }); + test('toJson serializes ConsultationModel correctly', () { final model = ConsultationModel( id: 1, diff --git a/test/features/consultations/presentation/cubit/consultations_cubit_test.dart b/test/features/consultations/presentation/cubit/consultations_cubit_test.dart index c9fe20b5..0e3fe5ef 100644 --- a/test/features/consultations/presentation/cubit/consultations_cubit_test.dart +++ b/test/features/consultations/presentation/cubit/consultations_cubit_test.dart @@ -75,9 +75,9 @@ void main() { expect(chatId, isNull); }); - test('getChatIdByUserSlug - возвращает null когда чат не найден', () async { + test('getChatIdByUserSlug - возвращает null когда чата нет', () async { when(() => mockChatRepository.getChatByUserSlug(any())) - .thenAnswer((_) async => '404'); + .thenAnswer((_) async => null); final chatId = await cubit.getChatIdByUserSlug('john-doe'); diff --git a/test/features/consultations/presentation/widgets/consultation_card_widget_test.dart b/test/features/consultations/presentation/widgets/consultation_card_widget_test.dart index c170f224..46c5e042 100644 --- a/test/features/consultations/presentation/widgets/consultation_card_widget_test.dart +++ b/test/features/consultations/presentation/widgets/consultation_card_widget_test.dart @@ -1,3 +1,5 @@ +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'; @@ -8,6 +10,7 @@ import 'package:eatvio_app/features/consultations/presentation/widgets/consultat import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; @@ -33,6 +36,20 @@ void main() { AppConfig(buildType: AppBuildType.tests) ..setBaseUrl('http://localhost'), ); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('PonnamKarthik/fluttertoast'), + (MethodCall methodCall) async => null, + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('PonnamKarthik/fluttertoast'), + null, + ); }); final mockConsultation = ConsultationModel( @@ -207,5 +224,93 @@ void main() { verify(() => cubit.getChatIdByUserSlug('john-doe')).called(1); }); }); + + testWidgets( + 'opens chat directly with chat id without requesting it again', + (tester) async { + final cubit = MockConsultationsCubit(); + when(() => cubit.stream).thenAnswer((_) => const Stream.empty()); + final consultationWithChatId = mockConsultation.copyWith( + chatId: 'direct-chat-uuid', + ); + + await mockNetworkImagesFor(() async { + await tester.pumpWidget( + wrap( + ConsultationCardWidget( + consultationsModel: consultationWithChatId, + ), + cubit: cubit, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.chat_outlined)); + await tester.pumpAndSettle(); + + expect(find.text('chat:direct-chat-uuid'), findsOneWidget); + verifyNever(() => cubit.getChatIdByUserSlug(any())); + }); + }, + ); + + testWidgets('shows error and stays on card when chat id is null', ( + tester, + ) async { + final cubit = MockConsultationsCubit(); + when(() => cubit.stream).thenAnswer((_) => const Stream.empty()); + when(() => cubit.getChatIdByUserSlug('john-doe')) + .thenAnswer((_) async => null); + + await mockNetworkImagesFor(() async { + await tester.pumpWidget( + wrap( + ConsultationCardWidget(consultationsModel: mockConsultation), + cubit: cubit, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.chat_outlined)); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 3)); + + expect(find.textContaining('chat:'), findsNothing); + expect( + find.byType(ConsultationCardWidget), + findsOneWidget, + ); + verify(() => cubit.getChatIdByUserSlug('john-doe')).called(1); + }); + }); + + testWidgets('does not request chat id twice while first is pending', ( + tester, + ) async { + final cubit = MockConsultationsCubit(); + when(() => cubit.stream).thenAnswer((_) => const Stream.empty()); + final completer = Completer(); + when(() => cubit.getChatIdByUserSlug('john-doe')) + .thenAnswer((_) => completer.future); + + await mockNetworkImagesFor(() async { + await tester.pumpWidget( + wrap( + ConsultationCardWidget(consultationsModel: mockConsultation), + cubit: cubit, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.chat_outlined)); + await tester.tap(find.byIcon(Icons.chat_outlined)); + + completer.complete('chat-uuid'); + await tester.pumpAndSettle(); + + expect(find.text('chat:chat-uuid'), findsOneWidget); + verify(() => cubit.getChatIdByUserSlug('john-doe')).called(1); + }); + }); }); } diff --git a/test/fixtures/doubles/profile_doubles.dart b/test/fixtures/doubles/profile_doubles.dart index dfc8e935..0522fc1d 100644 --- a/test/fixtures/doubles/profile_doubles.dart +++ b/test/fixtures/doubles/profile_doubles.dart @@ -69,7 +69,7 @@ class FixtureChatRepository extends ChatRepository { : super(chatService: _NoopChatService()); @override - Future getChatByUserSlug(String slug) async { + Future getChatByUserSlug(String slug) async { return 'chat-uuid-123'; } } From f58892bdeb18169976e247887959dd75b584cf13 Mon Sep 17 00:00:00 2001 From: alex-chevski Date: Fri, 30 Oct 2026 13:31:05 +0300 Subject: [PATCH 05/10] fix(consultant): keep registration button above system navigation bar --- .../pages/become_consultant_screen.dart | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/lib/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart index 2955ee09..c147d12a 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart @@ -14,6 +14,8 @@ class BecomeConsultantScreen extends StatefulWidget { class _BecomeConsultantScreenState extends State { @override Widget build(BuildContext context) { + final bottomInset = MediaQuery.paddingOf(context).bottom; + return Scaffold( appBar: AppBar( title: const Text('Стать консультантом'), @@ -24,14 +26,18 @@ class _BecomeConsultantScreenState extends State { icon: const Icon(Icons.close), ), ), - body: const Padding( - padding: EdgeInsets.all(16), - child: Text( + body: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + 52 + 8 + bottomInset), + child: const Text( 'Чтобы стать консультантом необходимо пройти короткую регистрацию. Подготовьте свои дипломы и/или сертификаты в формате jpg, они Вам пригодятся для проверки и подтверждения. Также необходимо будет указать ссылки на Ваши соц. сети и указать тип лица (физ. лицо или юр. лицо). На данный момент доступна регистрация в качестве физ. лица.\n\nВнутри Итвио разница между обычным пользователем и консультантом заключается в дополнительных возможностях последнего создавать публичные диеты, рецепты и планы питания; участвовать в ежемесячных рейтингах.', style: TextStyle(fontSize: 16), ), ), - bottomSheet: _startRegistrationButton(), + bottomSheet: Padding( + padding: EdgeInsets.fromLTRB(16, 0, 16, 8 + bottomInset), + child: _startRegistrationButton(), + ), ); } @@ -44,7 +50,7 @@ class _BecomeConsultantScreenState extends State { borderRadius: BorderRadius.circular(20), child: Ink( height: 52, - width: MediaQuery.of(context).size.width - 32, + width: double.infinity, decoration: BoxDecoration( borderRadius: BorderRadius.circular(20), color: appTheme.blueA400, From 85bc2c6d5c611d982dbc53e44186a6699191e59c Mon Sep 17 00:00:00 2001 From: alex-chevski Date: Fri, 30 Oct 2026 13:31:07 +0300 Subject: [PATCH 06/10] test(consultant): verify bottom action stays above system navigation area --- .../pages/become_consultant_screen_test.dart | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/features/consultant/become_consultant/presentation/pages/become_consultant_screen_test.dart b/test/features/consultant/become_consultant/presentation/pages/become_consultant_screen_test.dart index 128c3225..5175b19a 100644 --- a/test/features/consultant/become_consultant/presentation/pages/become_consultant_screen_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/become_consultant_screen_test.dart @@ -16,5 +16,29 @@ void main() { expect(find.byType(BecomeConsultantScreen), findsOneWidget); expect(find.text('Стать консультантом'), findsOneWidget); }); + + testWidgets('keeps the bottom action above the system navigation area', (tester) async { + const bottomInset = 34.0; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeHelper().themeData(), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(padding: const EdgeInsets.only(bottom: bottomInset)), + child: child!, + ), + home: const BecomeConsultantScreen(), + ), + ); + await tester.pumpAndSettle(); + + final buttonRect = tester.getRect( + find.ancestor(of: find.text('Начать регистрацию'), matching: find.byType(Ink)).first, + ); + final viewHeight = tester.view.physicalSize.height / tester.view.devicePixelRatio; + + expect(buttonRect.bottom, lessThanOrEqualTo(viewHeight - bottomInset)); + expect(tester.takeException(), isNull); + }); }); } From 5f0b7e026be11096e2dc1997b6b2aba1d38b1769 Mon Sep 17 00:00:00 2001 From: alex-chevski Date: Fri, 30 Oct 2026 15:15:21 +0300 Subject: [PATCH 07/10] fix(profile): refresh bottom-nav avatar on account switch Reset ProfileAvatarCubit and user-scoped feed, chat and notification state so stale data from the previous account cannot be rendered. Refresh the avatar when the stored token changes and rebuild nav tabs when the profile identity or consultant role changes. Refs: #1113 --- lib/core/routing/main_navigation_routes.dart | 11 +-- .../new_messages_cubit.dart | 7 ++ .../profile_avatar_cubit.dart | 75 ++++++++++++++++--- .../widgets/bottom_navigation_bar_widget.dart | 13 +++- .../bloc/chats_cubit/chats_cubit.dart | 11 +++ .../bloc/feed_cubit/feed_cubit.dart | 28 +++++-- .../notifications_cubit.dart | 5 ++ .../bloc/profile_cubit/profile_cubit.dart | 4 + 8 files changed, 129 insertions(+), 25 deletions(-) diff --git a/lib/core/routing/main_navigation_routes.dart b/lib/core/routing/main_navigation_routes.dart index d0746c88..36ca1a71 100644 --- a/lib/core/routing/main_navigation_routes.dart +++ b/lib/core/routing/main_navigation_routes.dart @@ -1,6 +1,5 @@ import 'package:eatvio_app/core/routing/route_constants.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart'; -import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/widgets/bottom_nav_scope.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart'; import 'package:eatvio_app/features/chat/presentation/pages/chats_screen.dart'; @@ -24,14 +23,10 @@ class MainNavigationRoutes { return [ ShellRoute( builder: (context, state, child) { - // Инициализация ProfileAvatarCubit при первом использовании ShellRoute final profileAvatarCubit = context.read(); - if (profileAvatarCubit.state is ProfileAvatarStateLoading) { - // Вызываем getMyProfile() только если состояние loading (первый запуск) - WidgetsBinding.instance.addPostFrameCallback((_) { - profileAvatarCubit.getMyProfile(); - }); - } + WidgetsBinding.instance.addPostFrameCallback((_) { + profileAvatarCubit.ensureFreshForCurrentSession(); + }); return _MainShellWithNavScope(currentLocation: state.matchedLocation, child: child); }, diff --git a/lib/features/bottom_navigation_bar/presentation/bloc/new_messages_cubit/new_messages_cubit.dart b/lib/features/bottom_navigation_bar/presentation/bloc/new_messages_cubit/new_messages_cubit.dart index 8aef9871..8293bdc7 100644 --- a/lib/features/bottom_navigation_bar/presentation/bloc/new_messages_cubit/new_messages_cubit.dart +++ b/lib/features/bottom_navigation_bar/presentation/bloc/new_messages_cubit/new_messages_cubit.dart @@ -6,6 +6,13 @@ class NewMessagesCubit extends Cubit { NewMessagesCubit() : super(const NewMessagesState.empty()); + void reset() { + _messagesCount = 0; + if (!isClosed) { + emit(const NewMessagesState.empty()); + } + } + void incrementCounter() { _messagesCount++; emit(NewMessagesState.loaded(messagesCount: _messagesCount)); diff --git a/lib/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart b/lib/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart index 2a51eca4..ffde009e 100644 --- a/lib/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart +++ b/lib/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart @@ -1,18 +1,22 @@ import 'dart:async'; import 'package:appmetrica_plugin/appmetrica_plugin.dart'; -import 'package:eatvio_app/core/app_router.dart'; +import 'package:eatvio_app/core/token_storage.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart'; import 'package:eatvio_app/features/profile/repositories/profile_repository.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:eatvio_app/core/error_reporting/report_from.dart'; -import 'package:go_router/go_router.dart'; import 'package:permission_handler/permission_handler.dart'; class ProfileAvatarCubit extends Cubit { final ProfileRepository profileRepository; - ProfileAvatarCubit({required this.profileRepository}) : super(const ProfileAvatarState.loading()); + ProfileAvatarCubit({required this.profileRepository}) + : super(const ProfileAvatarState.loading()); + + int _requestId = 0; + int? _sessionFingerprint; + bool _ensuring = false; int _calculateAge(String birthdate) { try { @@ -25,7 +29,8 @@ class ProfileAvatarCubit extends Cubit { final now = DateTime.now(); int age = now.year - birthYear; - if (now.month < birthMonth || (now.month == birthMonth && now.day < birthDay)) { + if (now.month < birthMonth || + (now.month == birthMonth && now.day < birthDay)) { age--; } @@ -38,30 +43,82 @@ class ProfileAvatarCubit extends Cubit { return 0; } + void reset() { + _requestId++; + _sessionFingerprint = null; + unawaited(AppMetrica.setUserProfileID(null).catchError((Object _) {})); + if (!isClosed) emit(const ProfileAvatarState.loading()); + } + + Future ensureFreshForCurrentSession() async { + final token = await TokenStorage.getTokenFromStorage(); + if (token == null || token.isEmpty) { + reset(); + return; + } + + final fingerprint = token.hashCode; + if (_sessionFingerprint == fingerprint && + state is ProfileAvatarStateLoaded) { + return; + } + + if (_ensuring) { + return; + } + + _ensuring = true; + try { + await getMyProfile(); + } finally { + _ensuring = false; + } + } + Future getMyProfile() async { + final requestId = ++_requestId; + final token = await TokenStorage.getTokenFromStorage(); + final fingerprint = token?.hashCode; + if (isClosed || requestId != _requestId) return; + emit(const ProfileAvatarState.loading()); try { final profile = await profileRepository.getMyProfile(); + if (isClosed || requestId != _requestId) return; + final profileDetails = await profileRepository.getMyProfileDetails(); + if (isClosed || requestId != _requestId) return; + + _sessionFingerprint = fingerprint; emit(ProfileAvatarState.loaded(profile: profile)); + try { final notificationStatus = await Permission.notification.status; + if (isClosed || requestId != _requestId) return; final isNotificationEnabled = notificationStatus.isGranted; AppMetricaUserProfile userProfile = AppMetricaUserProfile([ AppMetricaNameAttribute.withValue(profile.name), - AppMetricaGenderAttribute.withValue(profileDetails.gender == 'male' ? AppMetricaGender.male : AppMetricaGender.female), - AppMetricaBirthDateAttribute.withAge(_calculateAge(profileDetails.birthdate)), - AppMetricaNotificationEnabledAttribute.withValue(isNotificationEnabled), + AppMetricaGenderAttribute.withValue( + profileDetails.gender == 'male' + ? AppMetricaGender.male + : AppMetricaGender.female, + ), + AppMetricaBirthDateAttribute.withAge( + _calculateAge(profileDetails.birthdate), + ), + AppMetricaNotificationEnabledAttribute.withValue( + isNotificationEnabled, + ), ]); AppMetrica.setUserProfileID(profile.slug); AppMetrica.reportUserProfile(userProfile); } catch (e, st) { await reportFrom(e, st, this); - // Игнорируем ошибки плагинов, профиль уже загружен } } catch (e, st) { + if (isClosed || requestId != _requestId) return; await reportFrom(e, st, this); - AppRouter.navigatorKey.currentContext?.go('/auth-screen'); + if (isClosed || requestId != _requestId) return; emit(ProfileAvatarState.error(errorMessage: e.toString())); } } diff --git a/lib/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart b/lib/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart index c3317d8c..b46ae479 100644 --- a/lib/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart +++ b/lib/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart @@ -39,6 +39,8 @@ class BottomNavigationBarWidget extends StatefulWidget { class _BottomNavigationBarWidgetState extends State { int currentBottomNavBarIndex = 1; List? pages; + int? _pagesProfileId; + bool? _pagesIsConsultant; int tapsCount = 0; List _getPages(bool isConsultant) { @@ -179,7 +181,16 @@ class _BottomNavigationBarWidgetState extends State { final isConsultant = profileModel.roles.contains('consultant'); // Инициализируем pages на основе profileModel - pages ??= _getPages(isConsultant); + if (pages == null || + _pagesProfileId != profileModel.id || + _pagesIsConsultant != isConsultant) { + _pagesProfileId = profileModel.id; + _pagesIsConsultant = isConsultant; + pages = _getPages(isConsultant); + if (currentBottomNavBarIndex >= pages!.length) { + currentBottomNavBarIndex = 0; + } + } // Expose tab switching to descendants (e.g. notification widget) final controller = BottomNavScope.maybeOf(context); diff --git a/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart b/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart index 499766f6..363d02fd 100644 --- a/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart +++ b/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart @@ -57,6 +57,17 @@ class ChatsCubit extends Cubit { int page = 1; late int lastPage; + /// Clears all user-scoped state. WS subscriptions stay intact so that + /// `getChatRoomsWithLoading` can re-load the new user's chats. + void reset() { + _query = null; + page = 1; + lastPage = 1; + if (!isClosed) { + emit(ChatsState()); + } + } + Future connectToWebSocket() async { try { await newIncomingMessagesSubscription?.cancel(); diff --git a/lib/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart b/lib/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart index a88d4623..0aa05206 100644 --- a/lib/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart +++ b/lib/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart @@ -12,9 +12,19 @@ class FeedCubit extends Cubit { bool _hasMore = true; bool isLoading = false; String? _query; + int _requestId = 0; FeedCubit({required this.feedRepository}) : super(const FeedState.loading()); + void reset() { + _requestId++; + _nextCursor = null; + _hasMore = true; + isLoading = false; + _query = null; + if (!isClosed) emit(const FeedState.loading()); + } + void updateFeedBlogPostList(FeedBlogPostModel? feedBlogPostModel, bool wasLikedByMe) { if (feedBlogPostModel == null || isClosed || state is! FeedStateLoaded) return; final List feedBlogPostList = [...(state as FeedStateLoaded).feedBlogPostList]; @@ -39,17 +49,18 @@ class FeedCubit extends Cubit { } Future loadFeedBlogPostList({String? query, int? currentUserId}) async { + final requestId = ++_requestId; try { _query = query; _nextCursor = null; _hasMore = true; final feed = await feedRepository.getFeed(search: query, currentUserId: currentUserId); + if (isClosed || requestId != _requestId) return; _nextCursor = feed.nextCursor; _hasMore = _nextCursor != null; - if (!isClosed) { - emit(FeedState.loaded(feedBlogPostList: feed.posts, isLoading: false)); - } + emit(FeedState.loaded(feedBlogPostList: feed.posts, isLoading: false)); } catch (e, st) { + if (isClosed || requestId != _requestId) return; await reportFrom(e, st, this); if (!isClosed) { emit(FeedState.error(errorMessage: e.toString())); @@ -62,14 +73,16 @@ class FeedCubit extends Cubit { return; } + final requestId = _requestId; isLoading = true; emit((state as FeedStateLoaded).copyWith(isLoading: true)); try { final feed = await feedRepository.getFeed(search: _query, cursor: _nextCursor, currentUserId: currentUserId); + if (isClosed || requestId != _requestId) return; _nextCursor = feed.nextCursor; _hasMore = _nextCursor != null; - if (!isClosed && state is FeedStateLoaded) { + if (state is FeedStateLoaded) { emit( FeedState.loaded( feedBlogPostList: [...(state as FeedStateLoaded).feedBlogPostList, ...feed.posts], @@ -78,14 +91,15 @@ class FeedCubit extends Cubit { ); } } catch (e, st) { + if (isClosed || requestId != _requestId) return; await reportFrom(e, st, this); - if (!isClosed && state is FeedStateLoaded) { + if (state is FeedStateLoaded) { emit((state as FeedStateLoaded).copyWith(isLoading: false)); - } else if (!isClosed) { + } else { emit(FeedState.error(errorMessage: e.toString())); } } finally { - isLoading = false; + if (requestId == _requestId) isLoading = false; } } diff --git a/lib/features/notifications/presentation/notifications_cubit/notifications_cubit.dart b/lib/features/notifications/presentation/notifications_cubit/notifications_cubit.dart index a3c5827d..c750c513 100644 --- a/lib/features/notifications/presentation/notifications_cubit/notifications_cubit.dart +++ b/lib/features/notifications/presentation/notifications_cubit/notifications_cubit.dart @@ -16,6 +16,11 @@ class NotificationsCubit extends Cubit { StreamSubscription? _newNotificationsSubscription; NotificationsCubit({required this.wsManager, required this.notificationsRepository}) : super(const NotificationsState.empty()); + void reset() { + _notifications.clear(); + if (!isClosed) emit(const NotificationsState.empty()); + } + Future connectToWebSocket() async { try { await _newNotificationsSubscription?.cancel(); diff --git a/lib/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart b/lib/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart index 2801c2b4..c85a7e63 100644 --- a/lib/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart +++ b/lib/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart @@ -22,6 +22,10 @@ class ProfileCubit extends Cubit { /// Bottom-nav / own profile instance, used to refresh posts after create/update/delete. static ProfileCubit? myProfileCubit; + static void clearMyProfileReference() { + myProfileCubit = null; + } + ProfileCubit({required this.profileRepository, required this.chatRepository, required this.consultantRepository}) : super(const ProfileState.loading()); Future getProfileBySlug(String slug) async { From cba2689406e0d3477a243f30e4dfa03e9c2a2bb1 Mon Sep 17 00:00:00 2001 From: alex-chevski Date: Fri, 30 Oct 2026 15:15:28 +0300 Subject: [PATCH 08/10] feat(session): centralize session teardown and refresh Add SessionLifecycleService to reset user-scoped cubits, analytics, socket id and account-scoped preferences on logout, 401 or account deletion. Run the same refresh after every successful auth path, including registration v1/v2, and ignore 401 responses from stale tokens so an old request cannot terminate the new user's session. Refs: #1113 --- lib/core/di/cubit_registrations.dart | 26 +++- lib/core/di/session_registrations.dart | 15 +++ lib/core/interceptors/token_interceptor.dart | 20 ++- lib/core/locator_service.dart | 3 + .../session/session_lifecycle_service.dart | 122 ++++++++++++++++++ .../bloc/auth_cubit/auth_cubit.dart | 19 ++- .../bloc/oauth_cubit/oauth_cubit.dart | 8 +- .../auth/presentation/pages/auth_screen.dart | 38 +----- .../pages/register_via_social_screen.dart | 27 +--- .../pages/scan_qr_code_screen.dart | 47 +++---- .../bloc/menu_cubit/menu_cubit.dart | 12 +- .../menu/presentation/pages/menu_screen.dart | 29 +---- .../registration_cubit.dart | 9 +- .../cubit/registration_cubit.dart | 5 +- .../bloc/settings_cubit/settings_cubit.dart | 53 +++++--- .../presentation/pages/settings_screen.dart | 1 - 16 files changed, 288 insertions(+), 146 deletions(-) create mode 100644 lib/core/di/session_registrations.dart create mode 100644 lib/core/session/session_lifecycle_service.dart diff --git a/lib/core/di/cubit_registrations.dart b/lib/core/di/cubit_registrations.dart index 05bfd7d1..8428bae8 100644 --- a/lib/core/di/cubit_registrations.dart +++ b/lib/core/di/cubit_registrations.dart @@ -97,6 +97,7 @@ class CubitRegistrations { systemPipCoordinator: sl(), ), ); + sl.registerLazySingleton(() => FeedCubit(feedRepository: sl())); } /// Регистрация Cubit'ов как фабрик @@ -108,10 +109,15 @@ class CubitRegistrations { firebaseApi: sl(), prefs: sl(), profileRepository: sl(), + sessionLifecycleService: sl(), ), ); sl.registerFactory( - () => RegistrationCubit(registrationRepository: sl(), prefs: sl()), + () => RegistrationCubit( + registrationRepository: sl(), + prefs: sl(), + sessionLifecycleService: sl(), + ), ); sl.registerFactory( () => ResetPasswordCubit(resetPasswordRepository: sl()), @@ -120,14 +126,21 @@ class CubitRegistrations { () => VerifyEmailCubit(profileRepository: sl(), prefs: sl()), ); sl.registerFactory( - () => OAuthCubit(authRepository: sl(), firebaseApi: sl(), prefs: sl()), + () => OAuthCubit( + authRepository: sl(), + firebaseApi: sl(), + prefs: sl(), + sessionLifecycleService: sl(), + ), ); sl.registerFactory( - () => - RegistrationCubitV2(registrationRepository: sl(), firebaseApi: sl()), + () => RegistrationCubitV2( + registrationRepository: sl(), + firebaseApi: sl(), + sessionLifecycleService: sl(), + ), ); // Feed related cubits - sl.registerFactory(() => FeedCubit(feedRepository: sl())); sl.registerFactory( () => RecipesCubit( feedRepository: sl(), @@ -242,6 +255,7 @@ class CubitRegistrations { settingsRepository: sl(), consultantRepository: sl(), consultationsRepository: sl(), + sessionLifecycleService: sl(), ), ); sl.registerFactory( @@ -297,7 +311,7 @@ class CubitRegistrations { sl.registerFactory( () => UserDetailsCubit(profileRepository: sl()), ); - sl.registerFactory(() => MenuCubit(sl())); + sl.registerFactory(() => MenuCubit(sl(), sl())); sl.registerFactory(() => MarketingCubit(sl())); sl.registerFactory( () => PersonalDietSuggestionCubit( diff --git a/lib/core/di/session_registrations.dart b/lib/core/di/session_registrations.dart new file mode 100644 index 00000000..04e27f60 --- /dev/null +++ b/lib/core/di/session_registrations.dart @@ -0,0 +1,15 @@ +import 'package:eatvio_app/core/error_reporting/error_reporter.dart'; +import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/session/session_lifecycle_service.dart'; +import 'package:eatvio_app/core/websockets/ws_manager.dart'; + +class SessionRegistrations { + static void registerAll() { + sl.registerLazySingleton( + () => SessionLifecycleService( + wsManager: sl(), + errorReporter: sl(), + ), + ); + } +} diff --git a/lib/core/interceptors/token_interceptor.dart b/lib/core/interceptors/token_interceptor.dart index 05f6be0c..2d65d04f 100644 --- a/lib/core/interceptors/token_interceptor.dart +++ b/lib/core/interceptors/token_interceptor.dart @@ -1,9 +1,7 @@ import 'package:dio/dio.dart'; -import 'package:eatvio_app/core/app_router.dart'; +import 'package:eatvio_app/core/session/session_lifecycle_service.dart'; import 'package:eatvio_app/core/token_storage.dart'; -import 'package:eatvio_app/core/websockets/ws_manager.dart'; import 'package:eatvio_app/core/locator_service.dart'; -import 'package:go_router/go_router.dart'; class TokenInterceptor extends Interceptor { @override @@ -14,6 +12,7 @@ class TokenInterceptor extends Interceptor { final token = await TokenStorage.getTokenFromStorage(); if (token != null && token.isNotEmpty) { options.headers['Authorization'] = 'Bearer $token'; + options.extra['authToken'] = token; } super.onRequest(options, handler); @@ -25,9 +24,18 @@ class TokenInterceptor extends Interceptor { ErrorInterceptorHandler handler, ) async { if (err.response?.statusCode == 401) { - await sl().clear(); - await TokenStorage.removeTokenInStorage(); - AppRouter.navigatorKey.currentContext?.go('/auth-screen'); + final requestCarriedAuth = err.requestOptions.headers.containsKey( + 'Authorization', + ); + final requestToken = err.requestOptions.extra['authToken']; + final currentToken = await TokenStorage.getTokenFromStorage(); + if (requestCarriedAuth && + requestToken != null && + requestToken == currentToken) { + await sl().endSession( + reason: SessionEndReason.unauthorized, + ); + } } super.onError(err, handler); } diff --git a/lib/core/locator_service.dart b/lib/core/locator_service.dart index cb2e63cc..46128cd3 100644 --- a/lib/core/locator_service.dart +++ b/lib/core/locator_service.dart @@ -5,6 +5,7 @@ import 'package:eatvio_app/core/di/cubit_registrations.dart'; import 'package:eatvio_app/core/di/external_registrations.dart'; import 'package:eatvio_app/core/di/repository_registrations.dart'; import 'package:eatvio_app/core/di/service_registrations.dart'; +import 'package:eatvio_app/core/di/session_registrations.dart'; import 'package:eatvio_app/core/prefs.dart'; import 'package:eatvio_app/core/websockets/ws_manager.dart'; import 'package:get_it/get_it.dart'; @@ -38,4 +39,6 @@ Future init({required AppBuildType buildType}) async { sl.registerLazySingleton( () => WsManager(wsService: sl(), errorReporter: sl()), ); + + SessionRegistrations.registerAll(); } diff --git a/lib/core/session/session_lifecycle_service.dart b/lib/core/session/session_lifecycle_service.dart new file mode 100644 index 00000000..1019a338 --- /dev/null +++ b/lib/core/session/session_lifecycle_service.dart @@ -0,0 +1,122 @@ +import 'dart:async'; + +import 'package:appmetrica_plugin/appmetrica_plugin.dart'; +import 'package:dio/dio.dart'; +import 'package:eatvio_app/core/app_config.dart'; +import 'package:eatvio_app/core/app_router.dart'; +import 'package:eatvio_app/core/error_reporting/error_reporter.dart'; +import 'package:eatvio_app/core/firebase_api.dart'; +import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/prefs.dart'; +import 'package:eatvio_app/core/prefs_keys.dart'; +import 'package:eatvio_app/core/routing/route_constants.dart'; +import 'package:eatvio_app/core/token_storage.dart'; +import 'package:eatvio_app/core/websockets/ws_manager.dart'; +import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/new_messages_cubit/new_messages_cubit.dart'; +import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart'; +import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart'; +import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; +import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart'; +import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart'; +import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; +import 'package:go_router/go_router.dart'; + +enum SessionEndReason { logout, unauthorized, accountDeleted } + +class SessionLifecycleService { + SessionLifecycleService({ + required WsManager wsManager, + required ErrorReporter errorReporter, + }) : _wsManager = wsManager, + _errorReporter = errorReporter; + + final WsManager _wsManager; + final ErrorReporter _errorReporter; + + int? _authenticatedFingerprint; + bool _endingSession = false; + + Future endSession({required SessionEndReason reason}) async { + if (_endingSession) return; + _endingSession = true; + try { + await _run(() => _wsManager.clear()); + await _run(TokenStorage.removeTokenInStorage); + await _run(SocketIdStorage.removeSocketIdInStorage); + await _run(() => sl().reset()); + await _run(() => sl().reset()); + await _run(() => sl().reset()); + await _run(() => sl().reset()); + await _run(() => sl().reset()); + await _run(() async => ProfileCubit.clearMyProfileReference()); + await _run(() => sl().endCall()); + await _run(() async => AppMetrica.setUserProfileID(null)); + await _run( + () => sl().remove(PrefsKeys.isEmailVerificationSkipped), + ); + await _run(() => sl().remove(PrefsKeys.registrationDate)); + _authenticatedFingerprint = null; + await _run(() async { + final context = AppRouter.navigatorKey.currentContext; + if (context == null) return; + final path = + AppRouter.appRouter.routeInformationProvider.value.uri.path; + if (path != RouteConstants.authScreen) { + context.go(RouteConstants.authScreen); + } + }); + } finally { + _endingSession = false; + } + } + + Future onAuthenticated() async { + String? token; + try { + token = await TokenStorage.getTokenFromStorage(); + } catch (e, st) { + await _report(e, st); + return; + } + if (token == null || token.isEmpty) return; + final fingerprint = token.hashCode; + if (_authenticatedFingerprint == fingerprint) return; + _authenticatedFingerprint = fingerprint; + + await _run(() async { + await _wsManager.clear(); + await _wsManager.initialize(); + }); + await _run(() => sl().getMyProfile()); + await _run(() => sl().getChatRoomsWithLoading()); + await _run(() => sl().getNotifications()); + await _run(() => sl().registerPushToken()); + } + + Future logout() async { + try { + await _run(() => sl().unregisterPushTokens()); + await _run( + () => sl().post('${sl().baseUrl}/api/profile/logout'), + ); + } finally { + await endSession(reason: SessionEndReason.logout); + } + } + + Future _run(FutureOr Function() action) async { + try { + await action(); + } catch (e, st) { + await _report(e, st); + } + } + + Future _report(Object error, StackTrace stackTrace) { + return _errorReporter.report( + error, + stackTrace, + origin: 'SessionLifecycleService', + ); + } +} diff --git a/lib/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart b/lib/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart index f36f43cf..98865cf2 100644 --- a/lib/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart +++ b/lib/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart @@ -3,6 +3,7 @@ import 'dart:developer'; import 'package:eatvio_app/core/firebase_api.dart'; import 'package:eatvio_app/core/prefs.dart'; import 'package:eatvio_app/core/prefs_keys.dart'; +import 'package:eatvio_app/core/session/session_lifecycle_service.dart'; import 'package:eatvio_app/core/token_storage.dart'; import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:eatvio_app/features/auth/presentation/bloc/auth_cubit/auth_state.dart'; @@ -17,15 +18,26 @@ class AuthCubit extends Cubit { final ProfileRepository profileRepository; final FirebaseApi firebaseApi; final Prefs prefs; + final SessionLifecycleService sessionLifecycleService; - AuthCubit({required this.authRepository, required this.prefs, required this.profileRepository, required this.firebaseApi}) : super(const AuthState()); + AuthCubit({ + required this.authRepository, + required this.prefs, + required this.profileRepository, + required this.firebaseApi, + required this.sessionLifecycleService, + }) : super(const AuthState()); Future getTokenByCredentials(String email, String password) async { emit(const AuthState.loading()); try { - final token = (await authRepository.getTokenByCredentials(email, password)).token; + final token = (await authRepository.getTokenByCredentials( + email, + password, + )).token; emit(AuthState.loaded(token: token)); await TokenStorage.saveTokenInStorage(token); + await sessionLifecycleService.onAuthenticated(); return true; } catch (e, st) { await reportFrom(e, st, this); @@ -48,7 +60,8 @@ class AuthCubit extends Cubit { Future registerAnonymousPushToken() async { try { - final hasAnonymousTokenBeenSent = prefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent) ?? false; + final hasAnonymousTokenBeenSent = + prefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent) ?? false; if (!hasAnonymousTokenBeenSent) { await firebaseApi.registerAnonymousPushToken(); prefs.setBool(PrefsKeys.hasAnonymousTokenBeenSent, true); diff --git a/lib/features/auth/presentation/bloc/oauth_cubit/oauth_cubit.dart b/lib/features/auth/presentation/bloc/oauth_cubit/oauth_cubit.dart index f6f27797..22193998 100644 --- a/lib/features/auth/presentation/bloc/oauth_cubit/oauth_cubit.dart +++ b/lib/features/auth/presentation/bloc/oauth_cubit/oauth_cubit.dart @@ -3,6 +3,7 @@ import 'package:dio/dio.dart'; import 'package:eatvio_app/core/firebase_api.dart'; import 'package:eatvio_app/core/mixins/registration_date_cache_mixin.dart'; import 'package:eatvio_app/core/prefs.dart'; +import 'package:eatvio_app/core/session/session_lifecycle_service.dart'; import 'package:eatvio_app/core/token_storage.dart'; import 'package:eatvio_app/features/auth/models/oauth_verification_request_response.dart'; import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_state.dart'; @@ -13,10 +14,12 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart'; class OAuthCubit extends Cubit with RegistrationDateCacheMixin { final AuthRepository authRepository; final FirebaseApi firebaseApi; + final SessionLifecycleService sessionLifecycleService; OAuthCubit({ required this.authRepository, required this.firebaseApi, + required this.sessionLifecycleService, required Prefs prefs, }) : super(const OAuthState()) { this.prefs = prefs; @@ -40,6 +43,7 @@ class OAuthCubit extends Cubit with RegistrationDateCacheMixin { log('OAuthCubit: Got token model: ${tokenModel.token}'); await TokenStorage.saveTokenInStorage(tokenModel.token); + await sessionLifecycleService.onAuthenticated(); emit(OAuthState.loaded(token: tokenModel.token)); } on DioException catch (e, st) { await reportFrom(e, st, this); @@ -122,7 +126,7 @@ class OAuthCubit extends Cubit with RegistrationDateCacheMixin { clientId: clientId, ); await TokenStorage.saveTokenInStorage(token); - await firebaseApi.registerPushToken(); + await sessionLifecycleService.onAuthenticated(); cacheRegistrationDate(DateTime.now()); emit(OAuthState.loaded(token: token)); } on DioException catch (e, st) { @@ -241,7 +245,7 @@ class OAuthCubit extends Cubit with RegistrationDateCacheMixin { 'OAuthCubit: OAuth verification confirmed successfully, token received', ); await TokenStorage.saveTokenInStorage(response.token); - await firebaseApi.registerPushToken(); + await sessionLifecycleService.onAuthenticated(); emit(OAuthState.loaded(token: response.token)); } catch (e, st) { await reportFrom(e, st, this); diff --git a/lib/features/auth/presentation/pages/auth_screen.dart b/lib/features/auth/presentation/pages/auth_screen.dart index d1c1ccfd..80442c1f 100644 --- a/lib/features/auth/presentation/pages/auth_screen.dart +++ b/lib/features/auth/presentation/pages/auth_screen.dart @@ -4,12 +4,10 @@ import 'package:eatvio_app/app_keys.dart'; import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/const.dart'; -import 'package:eatvio_app/core/firebase_api.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/utils/image_constant.dart'; import 'package:eatvio_app/core/utils/toasts.dart'; -import 'package:eatvio_app/core/websockets/ws_manager.dart'; import 'package:eatvio_app/core/widgets/custom_divider.dart'; import 'package:eatvio_app/core/widgets/custom_text_form_field.dart'; import 'package:eatvio_app/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart'; @@ -18,8 +16,6 @@ import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_cub import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_state.dart'; import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart'; import 'package:eatvio_app/core/di/cubit_registrations.dart'; -import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart'; -import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart'; import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/cupertino.dart'; @@ -68,23 +64,8 @@ class _AuthScreenState extends State { super.dispose(); } - Future _reconnectWebSocket(BuildContext context) async { - try { - await sl().clear(); - await sl().initialize(); - } catch (e) { - rethrow; - } - if (context.mounted) { - try { - await sl().getMyProfile(); - await sl().getChatRoomsWithLoading(); - await sl().registerPushToken(); - showSuccessToast('Успешный вход'); - } catch (e) { - rethrow; - } - } + Future _reconnectWebSocket() async { + showSuccessToast('Успешный вход'); AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false}); } @@ -202,18 +183,7 @@ class _AuthScreenState extends State { } Future _reconnectAfterAuth() async { - final context = AppRouter.navigatorKey.currentContext; - if (context == null) return; - try { - await sl().clear(); - await sl().initialize(); - await sl().getMyProfile(); - await sl().getChatRoomsWithLoading(); - await sl().registerPushToken(); - showSuccessToast('Успешный вход'); - } catch (e) { - log(e.toString()); - } + showSuccessToast('Успешный вход'); AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false}); } @@ -395,7 +365,7 @@ class _AuthScreenState extends State { final isAuthenticated = await authCubit.getTokenByCredentials(emailController.text, passwordController.text); if (isAuthenticated) { if (context.mounted) { - await _reconnectWebSocket(context); + await _reconnectWebSocket(); } } setState(() { diff --git a/lib/features/auth/presentation/pages/register_via_social_screen.dart b/lib/features/auth/presentation/pages/register_via_social_screen.dart index 1ea307f0..9aed6116 100644 --- a/lib/features/auth/presentation/pages/register_via_social_screen.dart +++ b/lib/features/auth/presentation/pages/register_via_social_screen.dart @@ -9,11 +9,7 @@ import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_sta import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart'; import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/locator_service.dart'; -import 'package:eatvio_app/core/websockets/ws_manager.dart'; -import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart'; -import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart'; import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart'; -import 'package:eatvio_app/core/firebase_api.dart'; import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:eatvio_app/core/utils/validation_utils.dart'; import 'package:flutter/gestures.dart'; @@ -62,23 +58,8 @@ class _RegisterViaSocialScreenState extends State { _initializeFieldsFromAuthResult(); } - Future _reconnectWebSocket(BuildContext context) async { - try { - await sl().clear(); - await sl().initialize(); - } catch (e) { - rethrow; - } - if (context.mounted) { - try { - await sl().getMyProfile(); - await sl().getChatRoomsWithLoading(); - await sl().registerPushToken(); - showSuccessToast('Успешная регистрация'); - } catch (e) { - rethrow; - } - } + Future _reconnectWebSocket() async { + showSuccessToast('Успешная регистрация'); AppRouter.appRouter.go('/feed-screen', extra: {'needToInitializeWebSocket': false}); } @@ -451,7 +432,7 @@ class _RegisterViaSocialScreenState extends State { Builder( builder: (context) { WidgetsBinding.instance.addPostFrameCallback((_) { - _reconnectWebSocket(context); + _reconnectWebSocket(); }); return const SizedBox.shrink(); }, @@ -795,7 +776,7 @@ class _RegisterViaSocialScreenState extends State { // Перезагружаем веб-сокет и переходим на главный экран if (widgetContext.mounted) { - _reconnectWebSocket(widgetContext); + _reconnectWebSocket(); } } catch (e, stackTrace) { log('OAuth Verification Confirm Error: $e', error: e, stackTrace: stackTrace); diff --git a/lib/features/auth/presentation/pages/scan_qr_code_screen.dart b/lib/features/auth/presentation/pages/scan_qr_code_screen.dart index 4b90fe70..f4a1dd56 100644 --- a/lib/features/auth/presentation/pages/scan_qr_code_screen.dart +++ b/lib/features/auth/presentation/pages/scan_qr_code_screen.dart @@ -3,13 +3,10 @@ import 'dart:io'; import 'package:dio/dio.dart'; import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_router.dart'; -import 'package:eatvio_app/core/firebase_api.dart'; import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/session/session_lifecycle_service.dart'; import 'package:eatvio_app/core/token_storage.dart'; import 'package:eatvio_app/core/utils/toasts.dart'; -import 'package:eatvio_app/core/websockets/ws_manager.dart'; -import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart'; -import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:qr_code_scanner_plus/qr_code_scanner_plus.dart'; @@ -39,7 +36,10 @@ class _ScanQRCodeScreenState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('Сканирование QR-кода'), centerTitle: true), + appBar: AppBar( + title: const Text('Сканирование QR-кода'), + centerTitle: true, + ), body: Column( children: [ Expanded( @@ -60,30 +60,33 @@ class _ScanQRCodeScreenState extends State { try { final scannedUrl = scanData.code; final uri = Uri.parse(scannedUrl ?? ''); - final scannedBaseUrl = (uri.hasAuthority) ? '${uri.scheme}://${uri.host}' : ''; + final scannedBaseUrl = (uri.hasAuthority) + ? '${uri.scheme}://${uri.host}' + : ''; sl().setBaseUrl(scannedBaseUrl); sl().options.baseUrl = scannedBaseUrl; - final response = await Dio().getUri(uri, options: Options(headers: {'accept': 'application/json', 'content-type': 'application/json'})); + final response = await Dio().getUri( + uri, + options: Options( + headers: { + 'accept': 'application/json', + 'content-type': 'application/json', + }, + ), + ); final token = response.data['token']; await TokenStorage.saveTokenInStorage(token); - try { - await sl().clear(); - await sl().initialize(); - } catch (e) { - rethrow; - } - try { - await sl().getMyProfile(); - await sl().getChatRoomsWithLoading(); - await sl().registerPushToken(); - } catch (e) { - rethrow; - } - AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false}); + await sl().onAuthenticated(); + AppRouter.navigatorKey.currentContext?.go( + '/feed-screen', + extra: {'needToInitializeWebSocket': false}, + ); showSuccessToast('Добро пожаловать!'); } catch (e) { await controller.resumeCamera(); - showErrorToast('Что-то пошло не так. Попробуйте обновить страницу с QR-кодом или войдите с помощью логина и пароля.'); + showErrorToast( + 'Что-то пошло не так. Попробуйте обновить страницу с QR-кодом или войдите с помощью логина и пароля.', + ); } }); } diff --git a/lib/features/menu/presentation/bloc/menu_cubit/menu_cubit.dart b/lib/features/menu/presentation/bloc/menu_cubit/menu_cubit.dart index 3d5b94ed..c9e80d18 100644 --- a/lib/features/menu/presentation/bloc/menu_cubit/menu_cubit.dart +++ b/lib/features/menu/presentation/bloc/menu_cubit/menu_cubit.dart @@ -1,5 +1,6 @@ import 'dart:developer'; +import 'package:eatvio_app/core/session/session_lifecycle_service.dart'; import 'package:eatvio_app/features/menu/presentation/bloc/menu_cubit/menu_state.dart'; import 'package:eatvio_app/features/profile/repositories/profile_repository.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -7,7 +8,11 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart'; class MenuCubit extends Cubit { final ProfileRepository profileRepository; - MenuCubit(this.profileRepository) : super(const MenuState.initial()); + final SessionLifecycleService sessionLifecycleService; + MenuCubit(this.profileRepository, this.sessionLifecycleService) + : super(const MenuState.initial()); + + Future logout() => sessionLifecycleService.logout(); Future getMyProfile() async { emit(const MenuState.loading()); @@ -23,7 +28,10 @@ class MenuCubit extends Cubit { Future makePassKey() async { try { - final passKey = await profileRepository.getPassKey(routeName: 'product_nutritional_value.index', routeParams: {}); + final passKey = await profileRepository.getPassKey( + routeName: 'product_nutritional_value.index', + routeParams: {}, + ); return passKey; } catch (e, st) { await reportFrom(e, st, this); diff --git a/lib/features/menu/presentation/pages/menu_screen.dart b/lib/features/menu/presentation/pages/menu_screen.dart index 84d78dbb..ba53911f 100644 --- a/lib/features/menu/presentation/pages/menu_screen.dart +++ b/lib/features/menu/presentation/pages/menu_screen.dart @@ -1,22 +1,15 @@ -import 'package:dio/dio.dart'; -import 'package:eatvio_app/core/app_config.dart'; -import 'package:eatvio_app/core/error_reporting/error_reporter.dart'; import 'package:eatvio_app/core/const.dart'; -import 'package:eatvio_app/core/firebase_api.dart'; import 'package:eatvio_app/core/theme/custom_text_style.dart'; -import 'package:eatvio_app/core/token_storage.dart'; import 'package:eatvio_app/core/utils/image_constant.dart'; import 'package:eatvio_app/core/utils/date_time_utils.dart'; import 'package:eatvio_app/core/widgets/data_error_widget.dart'; import 'package:eatvio_app/core/widgets/loader.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; -import 'package:eatvio_app/core/websockets/ws_manager.dart'; import 'package:eatvio_app/features/consultant_rating/presentation/consultant_rating_screen.dart'; import 'package:eatvio_app/features/finances_and_consultations_archive/presentation/consultant_finances_screen.dart'; import 'package:eatvio_app/features/finances_and_consultations_archive/presentation/user_purchases_screen.dart'; import 'package:eatvio_app/features/menu/presentation/bloc/menu_cubit/menu_cubit.dart'; -import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; import 'package:eatvio_app/features/menu/presentation/bloc/menu_cubit/menu_state.dart'; import 'package:eatvio_app/features/my_diets/presentation/my_diets_screen.dart'; import 'package:eatvio_app/features/my_plans/presentation/my_plans_screen.dart'; @@ -155,6 +148,7 @@ class MenuScreen extends StatelessWidget { Future Function() _onTapLogout(BuildContext context) { return () async { + final menuCubit = context.read(); showDialog( context: context, builder: (context) => CupertinoAlertDialog( @@ -162,25 +156,8 @@ class MenuScreen extends StatelessWidget { actions: [ CupertinoDialogAction( onPressed: () async { - try { - // Logout revokes the Sanctum token. Unregister first, or - // FCM / VoIP tokens stay on the server and calls still ring. - await sl().unregisterPushTokens(); - } catch (e, s) { - await sl().report(e, s, origin: 'MenuScreen'); - } - try { - await sl().post('${sl().baseUrl}/api/profile/logout'); - } catch (e, s) { - await sl().report(e, s, origin: 'MenuScreen'); - } finally { - await sl().endCall(); - await sl().clear(); - await TokenStorage.removeTokenInStorage(); - if (context.mounted) { - GoRouter.of(context).go('/auth-screen', extra: true); - } - } + Navigator.of(context).pop(); + await menuCubit.logout(); }, isDestructiveAction: true, child: const Text("Выйти"), diff --git a/lib/features/registration/presentation/bloc/registration_cubit/registration_cubit.dart b/lib/features/registration/presentation/bloc/registration_cubit/registration_cubit.dart index e9260cfd..d02ec877 100644 --- a/lib/features/registration/presentation/bloc/registration_cubit/registration_cubit.dart +++ b/lib/features/registration/presentation/bloc/registration_cubit/registration_cubit.dart @@ -1,11 +1,9 @@ import 'dart:developer'; import 'package:eatvio_app/core/app_router.dart'; -import 'package:eatvio_app/core/firebase_api.dart'; -import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/mixins/registration_date_cache_mixin.dart'; import 'package:eatvio_app/core/prefs.dart'; - +import 'package:eatvio_app/core/session/session_lifecycle_service.dart'; import 'package:eatvio_app/core/token_storage.dart'; import 'package:eatvio_app/features/registration/models/user_registration_model.dart'; import 'package:eatvio_app/features/registration/presentation/bloc/registration_cubit/registration_state.dart'; @@ -18,7 +16,8 @@ import 'package:go_router/go_router.dart'; class RegistrationCubit extends Cubit with RegistrationDateCacheMixin { final RegistrationRepository registrationRepository; - RegistrationCubit({required this.registrationRepository, required Prefs prefs}) + final SessionLifecycleService sessionLifecycleService; + RegistrationCubit({required this.registrationRepository, required this.sessionLifecycleService, required Prefs prefs}) : super( const RegistrationState( sheet: UserRegistrationSheet( @@ -62,7 +61,7 @@ class RegistrationCubit extends Cubit with RegistrationDateCa state.sheet.copyWith(phone: state.sheet.phone.replaceAll(' ', '').replaceAll('(', '').replaceAll(')', ''), responseBody: '{}'), ); await TokenStorage.saveTokenInStorage(token); - await sl().registerPushToken(); + await sessionLifecycleService.onAuthenticated(); RegistrationAnalyticsService.reportRegistrationComplete(); cacheRegistrationDate(DateTime.now()); AppRouter.navigatorKey.currentContext?.replaceNamed('verifyEmailScreen'); diff --git a/lib/features/registration_v2/cubit/registration_cubit.dart b/lib/features/registration_v2/cubit/registration_cubit.dart index 8dee14d3..9246a5b1 100644 --- a/lib/features/registration_v2/cubit/registration_cubit.dart +++ b/lib/features/registration_v2/cubit/registration_cubit.dart @@ -2,6 +2,7 @@ import 'dart:developer'; import 'package:dio/dio.dart'; import 'package:eatvio_app/core/firebase_api.dart'; +import 'package:eatvio_app/core/session/session_lifecycle_service.dart'; import 'package:eatvio_app/core/token_storage.dart'; import 'package:eatvio_app/core/utils/date_time_utils.dart'; import 'package:eatvio_app/core/utils/validation_utils.dart'; @@ -15,9 +16,11 @@ import 'package:gpassword/gpassword.dart'; class RegistrationCubitV2 extends Cubit { final RegistrationRepositoryV2 registrationRepository; final FirebaseApi firebaseApi; + final SessionLifecycleService sessionLifecycleService; RegistrationCubitV2({ required this.registrationRepository, required this.firebaseApi, + required this.sessionLifecycleService, }) : super(const RegistrationStateV2()); void setFirstName(String firstName) { @@ -204,7 +207,7 @@ class RegistrationCubitV2 extends Cubit { ); if (token != null) { await TokenStorage.saveTokenInStorage(token); - await firebaseApi.registerPushToken(); + await sessionLifecycleService.onAuthenticated(); emit(state.copyWith(registrationError: null)); return (true, false); } 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 f468a1ce..a8479ffc 100644 --- a/lib/features/settings/presentation/bloc/settings_cubit/settings_cubit.dart +++ b/lib/features/settings/presentation/bloc/settings_cubit/settings_cubit.dart @@ -1,3 +1,5 @@ +import 'package:dio/dio.dart'; +import 'package:eatvio_app/core/session/session_lifecycle_service.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; import 'package:eatvio_app/features/consultations/repositories/consultations_repository.dart'; @@ -17,12 +19,14 @@ class SettingsCubit extends Cubit { final SettingsRepository settingsRepository; final ConsultantRepository consultantRepository; final ConsultationsRepository consultationsRepository; + final SessionLifecycleService sessionLifecycleService; SettingsCubit({ - required this.profileRepository, - required this.settingsRepository, + required this.profileRepository, + required this.settingsRepository, required this.consultantRepository, required this.consultationsRepository, + required this.sessionLifecycleService, }) : super(const SettingsState.loading()); Future init() async { @@ -30,10 +34,12 @@ class SettingsCubit extends Cubit { late final ConsultationRequestModel? consultantInfo; late final bool hasActiveConsultation; late final OAuthAccountsDataModel? oauthAccounts; - + try { if (profile.phoneConfirmationState.contains('confirmed')) { - consultantInfo = await consultantRepository.getMyRequestStatus(profile.slug); + consultantInfo = await consultantRepository.getMyRequestStatus( + profile.slug, + ); } else { consultantInfo = null; } @@ -41,14 +47,14 @@ class SettingsCubit extends Cubit { await reportFrom(e, st, this); consultantInfo = null; } - + try { hasActiveConsultation = await _checkActiveConsultation(); } catch (e, st) { await reportFrom(e, st, this); hasActiveConsultation = false; } - + try { final oauthResponse = await settingsRepository.getOAuthAccounts(); oauthAccounts = oauthResponse.data; @@ -56,7 +62,7 @@ class SettingsCubit extends Cubit { await reportFrom(e, st, this); oauthAccounts = null; } - + try { emit( SettingsState.loaded( @@ -74,9 +80,13 @@ class SettingsCubit extends Cubit { } } - Future updateProfileDetails({required ProfileDetailsModel profileDetailsModel}) async { + Future updateProfileDetails({ + required ProfileDetailsModel profileDetailsModel, + }) async { try { - await settingsRepository.updateProfileDetails(profileDetailsModel: profileDetailsModel); + await settingsRepository.updateProfileDetails( + profileDetailsModel: profileDetailsModel, + ); } catch (e, st) { await reportFrom(e, st, this); } finally { @@ -84,9 +94,13 @@ class SettingsCubit extends Cubit { } } - Future updateProfileSettings({required ProfileSettingsModel profileSettingsModel}) async { + Future updateProfileSettings({ + required ProfileSettingsModel profileSettingsModel, + }) async { try { - await settingsRepository.updateProfileSettings(profileSettingsModel: profileSettingsModel); + await settingsRepository.updateProfileSettings( + profileSettingsModel: profileSettingsModel, + ); } catch (e, st) { await reportFrom(e, st, this); } finally { @@ -97,9 +111,15 @@ class SettingsCubit extends Cubit { Future deleteAccount() async { try { await profileRepository.deleteAccount(); + await sessionLifecycleService.endSession( + reason: SessionEndReason.accountDeleted, + ); return true; } catch (e, st) { await reportFrom(e, st, this); + if (e is DioException && e.response?.statusCode == 401) { + return true; + } return false; } } @@ -135,11 +155,14 @@ class SettingsCubit extends Cubit { Future _checkActiveConsultation() async { try { - final userPurchases = await consultationsRepository.getUserPurchases(null, null); + final userPurchases = await consultationsRepository.getUserPurchases( + null, + null, + ); final activeStates = ChosenPlanStateEnum.getActiveStates(); - - return userPurchases.any((purchase) => - activeStates.contains(purchase.chosenPlanState) + + return userPurchases.any( + (purchase) => activeStates.contains(purchase.chosenPlanState), ); } catch (e, st) { await reportFrom(e, st, this); diff --git a/lib/features/settings/presentation/pages/settings_screen.dart b/lib/features/settings/presentation/pages/settings_screen.dart index a1b0acab..1f110d2b 100644 --- a/lib/features/settings/presentation/pages/settings_screen.dart +++ b/lib/features/settings/presentation/pages/settings_screen.dart @@ -698,7 +698,6 @@ class _DeleteAccountBottomSheet extends State { onPressed: () async { final result = await widget.cubit.deleteAccount(); if (result == true) { - AppRouter.navigatorKey.currentContext?.go('/auth-screen'); return; } AppRouter.navigatorKey.currentContext?.pop(); From 51aff6c175ec2d5dd3191e8b12f9f0201a8a6c8f Mon Sep 17 00:00:00 2001 From: alex-chevski Date: Fri, 30 Oct 2026 15:15:36 +0300 Subject: [PATCH 09/10] test(session): cover avatar refresh and session teardown Add regression tests for avatar reset and token-change refresh, stale 401 handling, idempotent session teardown and account-scoped preference cleanup. Update existing cubit tests for the new session dependency. Refs: #1113 --- .../token_interceptor_session_test.dart | 118 +++++++++ .../interceptors/token_interceptor_test.dart | 53 +++-- .../session_lifecycle_service_test.dart | 223 ++++++++++++++++++ .../bloc/auth_cubit/auth_cubit_test.dart | 8 + .../presentation/bloc/auth_cubit_test.dart | 25 +- .../bloc/oauth_cubit/oauth_cubit_test.dart | 31 +-- .../widgets/basic_widget_test.dart | 5 +- .../profile_avatar_cubit_session_test.dart | 204 ++++++++++++++++ .../profile_avatar_cubit_test.dart | 4 + .../presentation/bloc/menu_cubit_test.dart | 8 +- .../pages/menu_screen_smoke_test.dart | 5 +- .../registration/basic_widget_test.dart | 5 +- .../registration/integration_widget_test.dart | 3 +- .../registration_cubit_test.dart | 4 +- .../pages/registration_screen_smoke_test.dart | 5 +- .../cubit/registration_cubit_v2_test.dart | 7 +- .../bloc/settings_cubit_test.dart | 15 ++ .../pages/change_password_screen_test.dart | 2 + .../pages/settings_screen_smoke_test.dart | 2 + test/fixtures/doubles/session_doubles.dart | 24 ++ 20 files changed, 684 insertions(+), 67 deletions(-) create mode 100644 test/core/interceptors/token_interceptor_session_test.dart create mode 100644 test/core/session/session_lifecycle_service_test.dart create mode 100644 test/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit_session_test.dart create mode 100644 test/fixtures/doubles/session_doubles.dart diff --git a/test/core/interceptors/token_interceptor_session_test.dart b/test/core/interceptors/token_interceptor_session_test.dart new file mode 100644 index 00000000..4c82bd40 --- /dev/null +++ b/test/core/interceptors/token_interceptor_session_test.dart @@ -0,0 +1,118 @@ +import 'package:dio/dio.dart'; +import 'package:eatvio_app/core/interceptors/token_interceptor.dart'; +import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/session/session_lifecycle_service.dart'; +import 'package:eatvio_app/core/token_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../fixtures/doubles/session_doubles.dart'; + +class _RecordingErrorInterceptorHandler extends ErrorInterceptorHandler { + int nextCalls = 0; + DioException? nextError; + + @override + void next(DioException error) { + nextCalls++; + nextError = error; + } + + @override + void resolve(Response response) { + throw UnimplementedError('resolve should not be called'); + } + + @override + void reject( + DioException error, [ + bool callFollowingErrorInterceptor = false, + ]) { + throw UnimplementedError('reject should not be called'); + } +} + +DioException _unauthorized(RequestOptions options) { + return DioException( + requestOptions: options, + response: Response(requestOptions: options, statusCode: 401), + type: DioExceptionType.badResponse, + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late FakeSessionLifecycleService sessionLifecycleService; + late TokenInterceptor interceptor; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + TokenStorage.clearCache(); + await sl.reset(); + + sessionLifecycleService = FakeSessionLifecycleService(); + sl.registerSingleton(sessionLifecycleService); + interceptor = TokenInterceptor(); + }); + + tearDown(() async { + await sl.reset(); + TokenStorage.clearCache(); + }); + + group('TokenInterceptor.onError 401', () { + test('request without Authorization does not end the session', () async { + await TokenStorage.saveTokenInStorage('current-token'); + + final options = RequestOptions(path: '/api/profile/index'); + final error = _unauthorized(options); + final handler = _RecordingErrorInterceptorHandler(); + + await interceptor.onError(error, handler); + + expect(handler.nextCalls, 1); + expect(handler.nextError, same(error)); + expect(sessionLifecycleService.endSessionCalls, 0); + }); + + test('stale request token does not end the new session', () async { + await TokenStorage.saveTokenInStorage('new-token'); + + final options = RequestOptions(path: '/api/profile/index'); + options.headers['Authorization'] = 'Bearer old-token'; + options.extra['authToken'] = 'old-token'; + + final error = _unauthorized(options); + final handler = _RecordingErrorInterceptorHandler(); + + await interceptor.onError(error, handler); + + expect(handler.nextCalls, 1); + expect(sessionLifecycleService.endSessionCalls, 0); + }); + + test( + 'current request token ends the session once as unauthorized', + () async { + await TokenStorage.saveTokenInStorage('current-token'); + + final options = RequestOptions(path: '/api/profile/index'); + options.headers['Authorization'] = 'Bearer current-token'; + options.extra['authToken'] = 'current-token'; + + final error = _unauthorized(options); + final handler = _RecordingErrorInterceptorHandler(); + + await interceptor.onError(error, handler); + + expect(handler.nextCalls, 1); + expect(sessionLifecycleService.endSessionCalls, 1); + expect( + sessionLifecycleService.lastEndReason, + SessionEndReason.unauthorized, + ); + }, + ); + }); +} diff --git a/test/core/interceptors/token_interceptor_test.dart b/test/core/interceptors/token_interceptor_test.dart index 5995d4a5..d997a13a 100644 --- a/test/core/interceptors/token_interceptor_test.dart +++ b/test/core/interceptors/token_interceptor_test.dart @@ -1,22 +1,24 @@ import 'package:dio/dio.dart'; import 'package:eatvio_app/core/interceptors/token_interceptor.dart'; -import 'package:eatvio_app/core/token_storage.dart'; -import 'package:eatvio_app/core/websockets/ws_manager.dart'; import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/session/session_lifecycle_service.dart'; +import 'package:eatvio_app/core/token_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../../fixtures/doubles/session_doubles.dart'; + class MockRequestOptions extends Mock implements RequestOptions {} -class MockRequestInterceptorHandler extends Mock implements RequestInterceptorHandler {} +class MockRequestInterceptorHandler extends Mock + implements RequestInterceptorHandler {} -class MockErrorInterceptorHandler extends Mock implements ErrorInterceptorHandler {} +class MockErrorInterceptorHandler extends Mock + implements ErrorInterceptorHandler {} class MockResponse extends Mock implements Response {} -class MockWsManager extends Mock implements WsManager {} - class MockDioException extends Mock implements DioException {} class FakeRequestOptions extends Fake implements RequestOptions {} @@ -32,23 +34,18 @@ void main() { }); group('TokenInterceptor', () { late TokenInterceptor interceptor; + late FakeSessionLifecycleService fakeSession; setUp(() async { SharedPreferences.setMockInitialValues({}); - - final mockWsManager = MockWsManager(); - when(() => mockWsManager.clear()).thenAnswer((_) async {}); - - await sl.reset(); // Сбрасываем sl перед каждым тестом - sl.registerSingleton(mockWsManager); - + TokenStorage.clearCache(); + + await sl.reset(); + fakeSession = FakeSessionLifecycleService(); + sl.registerSingleton(fakeSession); + interceptor = TokenInterceptor(); - // Сбрасываем токен перед каждым тестом - try { - await TokenStorage.removeTokenInStorage(); - } catch (e) { - // Игнорируем ошибки при очистке - } + await TokenStorage.removeTokenInStorage(); }); group('onRequest', () { @@ -59,6 +56,7 @@ void main() { final headers = {}; final options = MockRequestOptions(); when(() => options.headers).thenReturn(headers); + when(() => options.extra).thenReturn({}); final handler = MockRequestInterceptorHandler(); when(() => handler.next(any())).thenReturn(null); @@ -73,6 +71,7 @@ void main() { final headers = {}; final options = MockRequestOptions(); when(() => options.headers).thenReturn(headers); + when(() => options.extra).thenReturn({}); final handler = MockRequestInterceptorHandler(); when(() => handler.next(any())).thenReturn(null); @@ -89,6 +88,7 @@ void main() { final headers = {}; final options = MockRequestOptions(); when(() => options.headers).thenReturn(headers); + when(() => options.extra).thenReturn({}); final handler = MockRequestInterceptorHandler(); when(() => handler.next(any())).thenReturn(null); @@ -101,11 +101,16 @@ void main() { }); group('onError', () { - test('удаляет токен и перенаправляет на auth при 401', () async { + test('завершает сессию при 401 для текущего токена', () async { const token = 'test-token'; await TokenStorage.saveTokenInStorage(token); final options = MockRequestOptions(); + when( + () => options.headers, + ).thenReturn({'Authorization': 'Bearer $token'}); + when(() => options.extra).thenReturn({'authToken': token}); + final response = MockResponse(); when(() => response.statusCode).thenReturn(401); @@ -116,14 +121,10 @@ void main() { final handler = MockErrorInterceptorHandler(); when(() => handler.next(any())).thenReturn(null); - // Проверяем, что токен был удален - final tokenBefore = await TokenStorage.getTokenFromStorage(); - expect(tokenBefore, token); - await interceptor.onError(error, handler); - final tokenAfter = await TokenStorage.getTokenFromStorage(); - expect(tokenAfter, null); + expect(fakeSession.endSessionCalls, 1); + expect(fakeSession.lastEndReason, SessionEndReason.unauthorized); verify(() => handler.next(error)).called(1); }); diff --git a/test/core/session/session_lifecycle_service_test.dart b/test/core/session/session_lifecycle_service_test.dart new file mode 100644 index 00000000..ce991a17 --- /dev/null +++ b/test/core/session/session_lifecycle_service_test.dart @@ -0,0 +1,223 @@ +import 'package:eatvio_app/core/error_reporting/error_reporter.dart'; +import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/prefs.dart'; +import 'package:eatvio_app/core/prefs_keys.dart'; +import 'package:eatvio_app/core/session/session_lifecycle_service.dart'; +import 'package:eatvio_app/core/token_storage.dart'; +import 'package:eatvio_app/core/websockets/ws_manager.dart'; +import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/new_messages_cubit/new_messages_cubit.dart'; +import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart'; +import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart'; +import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; +import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart'; +import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _FakeWsManager implements WsManager { + int clearCalls = 0; + + @override + Future clear() async { + clearCalls++; + } + + @override + dynamic noSuchMethod(Invocation invocation) { + throw UnimplementedError('WsManager.${invocation.memberName}'); + } +} + +class _FakeErrorReporter implements ErrorReporter { + int reportCalls = 0; + + @override + Future report( + Object error, + StackTrace? stackTrace, { + String? origin, + Map? extras, + }) async { + reportCalls++; + } +} + +class _FakeProfileAvatarCubit implements ProfileAvatarCubit { + int resetCalls = 0; + + @override + void reset() { + resetCalls++; + } + + @override + dynamic noSuchMethod(Invocation invocation) { + throw UnimplementedError('ProfileAvatarCubit.${invocation.memberName}'); + } +} + +class _FakeChatsCubit implements ChatsCubit { + int resetCalls = 0; + + @override + void reset() { + resetCalls++; + } + + @override + dynamic noSuchMethod(Invocation invocation) { + throw UnimplementedError('ChatsCubit.${invocation.memberName}'); + } +} + +class _FakeNotificationsCubit implements NotificationsCubit { + int resetCalls = 0; + + @override + void reset() { + resetCalls++; + } + + @override + dynamic noSuchMethod(Invocation invocation) { + throw UnimplementedError('NotificationsCubit.${invocation.memberName}'); + } +} + +class _FakeNewMessagesCubit implements NewMessagesCubit { + int resetCalls = 0; + + @override + void reset() { + resetCalls++; + } + + @override + dynamic noSuchMethod(Invocation invocation) { + throw UnimplementedError('NewMessagesCubit.${invocation.memberName}'); + } +} + +class _FakeFeedCubit implements FeedCubit { + int resetCalls = 0; + + @override + void reset() { + resetCalls++; + } + + @override + dynamic noSuchMethod(Invocation invocation) { + throw UnimplementedError('FeedCubit.${invocation.memberName}'); + } +} + +class _FakeVideoCallSessionCubit implements VideoCallSessionCubit { + int endCallCalls = 0; + + @override + Future endCall() async { + endCallCalls++; + } + + @override + dynamic noSuchMethod(Invocation invocation) { + throw UnimplementedError('VideoCallSessionCubit.${invocation.memberName}'); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late _FakeWsManager wsManager; + late _FakeProfileAvatarCubit profileAvatarCubit; + late _FakeChatsCubit chatsCubit; + late _FakeNotificationsCubit notificationsCubit; + late _FakeNewMessagesCubit newMessagesCubit; + late _FakeFeedCubit feedCubit; + late _FakeVideoCallSessionCubit videoCallSessionCubit; + late SessionLifecycleService service; + + setUp(() async { + SharedPreferences.setMockInitialValues({ + 'token': 'token-a', + 'socketId': 'socket-a', + PrefsKeys.customBaseUrl.name: 'https://old.example', + PrefsKeys.isEmailVerificationSkipped.name: true, + PrefsKeys.registrationDate.name: '2024-01-01', + PrefsKeys.welcomeScreenWasShowed.name: true, + }); + TokenStorage.clearCache(); + await sl.reset(); + + wsManager = _FakeWsManager(); + profileAvatarCubit = _FakeProfileAvatarCubit(); + chatsCubit = _FakeChatsCubit(); + notificationsCubit = _FakeNotificationsCubit(); + newMessagesCubit = _FakeNewMessagesCubit(); + feedCubit = _FakeFeedCubit(); + videoCallSessionCubit = _FakeVideoCallSessionCubit(); + + sl.registerSingleton(profileAvatarCubit); + sl.registerSingleton(chatsCubit); + sl.registerSingleton(notificationsCubit); + sl.registerSingleton(newMessagesCubit); + sl.registerSingleton(feedCubit); + sl.registerSingleton(videoCallSessionCubit); + sl.registerSingleton(Prefs(await SharedPreferences.getInstance())); + + service = SessionLifecycleService( + wsManager: wsManager, + errorReporter: _FakeErrorReporter(), + ); + }); + + tearDown(() async { + await sl.reset(); + TokenStorage.clearCache(); + }); + + group('SessionLifecycleService.endSession', () { + test( + 'resets user-scoped state and clears only session-scoped keys', + () async { + await service.endSession(reason: SessionEndReason.logout); + + expect(wsManager.clearCalls, 1); + expect(profileAvatarCubit.resetCalls, 1); + expect(chatsCubit.resetCalls, 1); + expect(notificationsCubit.resetCalls, 1); + expect(newMessagesCubit.resetCalls, 1); + expect(feedCubit.resetCalls, 1); + expect(videoCallSessionCubit.endCallCalls, 1); + + expect(await TokenStorage.getTokenFromStorage(), isNull); + expect(await SocketIdStorage.getSocketIdFromStorage(), isNull); + + final prefs = sl(); + expect(prefs.getString(PrefsKeys.customBaseUrl), 'https://old.example'); + expect(prefs.getBool(PrefsKeys.isEmailVerificationSkipped), isNull); + expect(prefs.getString(PrefsKeys.registrationDate), isNull); + expect(prefs.getBool(PrefsKeys.welcomeScreenWasShowed), isTrue); + }, + ); + + test('is idempotent for concurrent calls', () async { + final first = service.endSession(reason: SessionEndReason.logout); + final second = service.endSession(reason: SessionEndReason.unauthorized); + + await Future.wait([first, second]); + + expect(wsManager.clearCalls, 1); + expect(profileAvatarCubit.resetCalls, 1); + expect(chatsCubit.resetCalls, 1); + expect(notificationsCubit.resetCalls, 1); + expect(newMessagesCubit.resetCalls, 1); + expect(feedCubit.resetCalls, 1); + expect(videoCallSessionCubit.endCallCalls, 1); + + expect(await TokenStorage.getTokenFromStorage(), isNull); + expect(await SocketIdStorage.getSocketIdFromStorage(), isNull); + }); + }); +} diff --git a/test/features/auth/presentation/bloc/auth_cubit/auth_cubit_test.dart b/test/features/auth/presentation/bloc/auth_cubit/auth_cubit_test.dart index 65bb710e..27f3c737 100644 --- a/test/features/auth/presentation/bloc/auth_cubit/auth_cubit_test.dart +++ b/test/features/auth/presentation/bloc/auth_cubit/auth_cubit_test.dart @@ -12,6 +12,7 @@ import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../../../../../fixtures/doubles/session_doubles.dart'; import 'auth_cubit_test.mocks.dart'; @GenerateNiceMocks([ @@ -75,6 +76,7 @@ void main() { profileRepository: mockProfileRepository, firebaseApi: mockFirebaseApi, prefs: mockPrefs, + sessionLifecycleService: FakeSessionLifecycleService(), ); expect(cubit!.state, isA()); @@ -86,6 +88,7 @@ void main() { profileRepository: mockProfileRepository, firebaseApi: mockFirebaseApi, prefs: mockPrefs, + sessionLifecycleService: FakeSessionLifecycleService(), ); const email = 'test@example.com'; @@ -116,6 +119,7 @@ void main() { profileRepository: mockProfileRepository, firebaseApi: mockFirebaseApi, prefs: mockPrefs, + sessionLifecycleService: FakeSessionLifecycleService(), ); const email = 'test@example.com'; @@ -140,6 +144,7 @@ void main() { profileRepository: mockProfileRepository, firebaseApi: mockFirebaseApi, prefs: mockPrefs, + sessionLifecycleService: FakeSessionLifecycleService(), ); when(mockPrefs.getBool(any)).thenReturn(false); @@ -159,6 +164,7 @@ void main() { profileRepository: mockProfileRepository, firebaseApi: mockFirebaseApi, prefs: mockPrefs, + sessionLifecycleService: FakeSessionLifecycleService(), ); when(mockPrefs.getBool(any)).thenReturn(true); @@ -174,6 +180,7 @@ void main() { profileRepository: mockProfileRepository, firebaseApi: mockFirebaseApi, prefs: mockPrefs, + sessionLifecycleService: FakeSessionLifecycleService(), ); when(mockFirebaseApi.registerPushToken()) @@ -190,6 +197,7 @@ void main() { profileRepository: mockProfileRepository, firebaseApi: mockFirebaseApi, prefs: mockPrefs, + sessionLifecycleService: FakeSessionLifecycleService(), ); // TokenStorage.haveTokenInStorage() вернет false diff --git a/test/features/auth/presentation/bloc/auth_cubit_test.dart b/test/features/auth/presentation/bloc/auth_cubit_test.dart index 790775b0..cc57e231 100644 --- a/test/features/auth/presentation/bloc/auth_cubit_test.dart +++ b/test/features/auth/presentation/bloc/auth_cubit_test.dart @@ -13,6 +13,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../../../../fixtures/doubles/auth_doubles.dart'; import '../../../../fixtures/doubles/profile_doubles.dart'; +import '../../../../fixtures/doubles/session_doubles.dart'; import '../../../../fixtures/fixture_reader.dart'; import 'auth_cubit_test.mocks.dart'; @@ -26,6 +27,7 @@ void main() { late FixtureProfileRepository profileRepository; late MockPrefs mockPrefs; late MockFirebaseApi mockFirebaseApi; + late FakeSessionLifecycleService fakeSession; late Map credentialsFixture; late TokenModel tokenFixture; @@ -37,6 +39,7 @@ void main() { profileRepository = FixtureProfileRepository(); mockPrefs = MockPrefs(); mockFirebaseApi = MockFirebaseApi(); + fakeSession = FakeSessionLifecycleService(); SharedPreferences.setMockInitialValues({}); // Mock Fluttertoast plugin @@ -59,12 +62,12 @@ void main() { }); test('начальное состояние - AuthState', () { - authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); + authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession); expect(authCubit!.state, const AuthState()); }); test('getTokenByCredentials - успешная авторизация', () async { - authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); + authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession); final result = await authCubit!.getTokenByCredentials(credentialsFixture['email'] as String, credentialsFixture['password'] as String); @@ -77,7 +80,7 @@ void main() { }); test('getTokenByCredentials - переход в состояние loading', () async { - authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); + authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession); authRepository.responseDelay = const Duration(milliseconds: 50); final future = authCubit!.getTokenByCredentials(credentialsFixture['email'] as String, credentialsFixture['password'] as String); @@ -88,7 +91,7 @@ void main() { }); test('getTokenByCredentials - обработка ошибки авторизации', () async { - authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); + authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession); authRepository.error = Exception('Неверный адрес электронной почты или пароль'); final result = await authCubit!.getTokenByCredentials('wrong@example.com', 'wrongpassword'); @@ -102,7 +105,7 @@ void main() { }); test('getTokenByCredentials - обработка различных типов ошибок', () async { - authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); + authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession); authRepository.error = const FormatException('Invalid credentials format'); final result = await authCubit!.getTokenByCredentials('invalid', 'pass'); @@ -115,7 +118,7 @@ void main() { }); test('getTokenByCredentials - проверка вызова с правильными параметрами', () async { - authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); + authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession); await authCubit!.getTokenByCredentials('user@test.com', 'securePassword123'); @@ -124,7 +127,7 @@ void main() { }); test('getTokenByCredentials - множественные вызовы', () async { - authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); + authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession); await authCubit!.getTokenByCredentials('first@test.com', 'password1'); await Future.delayed(const Duration(milliseconds: 100)); @@ -138,7 +141,7 @@ void main() { }); test('registerAnonymousPushToken - регистрирует токен если не был отправлен', () async { - authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); + authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession); when(mockPrefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent)).thenReturn(false); when(mockFirebaseApi.registerAnonymousPushToken()).thenAnswer((_) async => {}); when(mockPrefs.setBool(PrefsKeys.hasAnonymousTokenBeenSent, true)).thenAnswer((_) async => {}); @@ -150,7 +153,7 @@ void main() { }); test('registerAnonymousPushToken - не регистрирует токен если уже был отправлен', () async { - authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); + authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession); when(mockPrefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent)).thenReturn(true); await authCubit!.registerAnonymousPushToken(); @@ -159,7 +162,7 @@ void main() { }); test('registerPushToken - регистрирует push токен', () async { - authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); + authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession); when(mockFirebaseApi.registerPushToken()).thenAnswer((_) async => {}); await authCubit!.registerPushToken(); @@ -168,7 +171,7 @@ void main() { }); test('registerAnonymousPushToken - обрабатывает null значение hasAnonymousTokenBeenSent', () async { - authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); + authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession); when(mockPrefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent)).thenReturn(null); when(mockFirebaseApi.registerAnonymousPushToken()).thenAnswer((_) async => {}); when(mockPrefs.setBool(PrefsKeys.hasAnonymousTokenBeenSent, true)).thenAnswer((_) async => {}); diff --git a/test/features/auth/presentation/bloc/oauth_cubit/oauth_cubit_test.dart b/test/features/auth/presentation/bloc/oauth_cubit/oauth_cubit_test.dart index 3f757bb1..443ee7e6 100644 --- a/test/features/auth/presentation/bloc/oauth_cubit/oauth_cubit_test.dart +++ b/test/features/auth/presentation/bloc/oauth_cubit/oauth_cubit_test.dart @@ -14,6 +14,7 @@ import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../../../../../fixtures/doubles/session_doubles.dart'; import '../../../../../fixtures/fixture_reader.dart'; import 'oauth_cubit_test.mocks.dart'; @@ -37,6 +38,7 @@ void main() { late MockAuthRepository mockAuthRepository; late MockPrefs mockPrefs; late _RecordingFirebaseApi firebaseApi; + late FakeSessionLifecycleService fakeSession; late TokenModel tokenFixture; late Map signInRequestFixture; late Map signUpRequestFixture; @@ -55,6 +57,7 @@ void main() { mockAuthRepository = MockAuthRepository(); mockPrefs = MockPrefs(); firebaseApi = _RecordingFirebaseApi(); + fakeSession = FakeSessionLifecycleService(); SharedPreferences.setMockInitialValues({}); @@ -87,7 +90,7 @@ void main() { test('начальное состояние - OAuthState', () { oauthCubit = OAuthCubit( authRepository: mockAuthRepository, - firebaseApi: firebaseApi, + firebaseApi: firebaseApi, sessionLifecycleService: fakeSession, prefs: mockPrefs, ); expect(oauthCubit!.state, const OAuthState()); @@ -96,7 +99,7 @@ void main() { test('signInViaSocialMedia - успешный вход через соцсеть', () async { oauthCubit = OAuthCubit( authRepository: mockAuthRepository, - firebaseApi: firebaseApi, + firebaseApi: firebaseApi, sessionLifecycleService: fakeSession, prefs: mockPrefs, ); @@ -129,7 +132,7 @@ void main() { test('signInViaSocialMedia - переход в состояние loading', () async { oauthCubit = OAuthCubit( authRepository: mockAuthRepository, - firebaseApi: firebaseApi, + firebaseApi: firebaseApi, sessionLifecycleService: fakeSession, prefs: mockPrefs, ); @@ -160,7 +163,7 @@ void main() { test('signInViaSocialMedia - обработка ошибки', () async { oauthCubit = OAuthCubit( authRepository: mockAuthRepository, - firebaseApi: firebaseApi, + firebaseApi: firebaseApi, sessionLifecycleService: fakeSession, prefs: mockPrefs, ); @@ -189,7 +192,7 @@ void main() { test('signUpViaSocialMedia - успешная регистрация через соцсеть', () async { oauthCubit = OAuthCubit( authRepository: mockAuthRepository, - firebaseApi: firebaseApi, + firebaseApi: firebaseApi, sessionLifecycleService: fakeSession, prefs: mockPrefs, ); @@ -237,8 +240,7 @@ void main() { final loadedState = oauthCubit!.state as OAuthStateLoaded; expect(loadedState.token, tokenFixture.token); expect(await TokenStorage.getTokenFromStorage(), tokenFixture.token); - expect(firebaseApi.registerPushTokenCalls, 1); - expect(firebaseApi.hadAuthToken, isTrue); + expect(fakeSession.onAuthenticatedCalls, 1); }); test( @@ -246,7 +248,7 @@ void main() { () async { oauthCubit = OAuthCubit( authRepository: mockAuthRepository, - firebaseApi: firebaseApi, + firebaseApi: firebaseApi, sessionLifecycleService: fakeSession, prefs: mockPrefs, ); @@ -289,7 +291,7 @@ void main() { test('signUpViaSocialMedia - обработка ошибки валидации email', () async { oauthCubit = OAuthCubit( authRepository: mockAuthRepository, - firebaseApi: firebaseApi, + firebaseApi: firebaseApi, sessionLifecycleService: fakeSession, prefs: mockPrefs, ); @@ -350,7 +352,7 @@ void main() { () async { oauthCubit = OAuthCubit( authRepository: mockAuthRepository, - firebaseApi: firebaseApi, + firebaseApi: firebaseApi, sessionLifecycleService: fakeSession, prefs: mockPrefs, ); @@ -379,7 +381,7 @@ void main() { test('requestOAuthVerification - обработка ошибки', () async { oauthCubit = OAuthCubit( authRepository: mockAuthRepository, - firebaseApi: firebaseApi, + firebaseApi: firebaseApi, sessionLifecycleService: fakeSession, prefs: mockPrefs, ); @@ -403,7 +405,7 @@ void main() { () async { oauthCubit = OAuthCubit( authRepository: mockAuthRepository, - firebaseApi: firebaseApi, + firebaseApi: firebaseApi, sessionLifecycleService: fakeSession, prefs: mockPrefs, ); @@ -458,15 +460,14 @@ void main() { final loadedState = oauthCubit!.state as OAuthStateLoaded; expect(loadedState.token, tokenFixture.token); expect(await TokenStorage.getTokenFromStorage(), tokenFixture.token); - expect(firebaseApi.registerPushTokenCalls, 1); - expect(firebaseApi.hadAuthToken, isTrue); + expect(fakeSession.onAuthenticatedCalls, 1); }, ); test('confirmOAuthVerification - обработка ошибки', () async { oauthCubit = OAuthCubit( authRepository: mockAuthRepository, - firebaseApi: firebaseApi, + firebaseApi: firebaseApi, sessionLifecycleService: fakeSession, prefs: mockPrefs, ); diff --git a/test/features/auth/presentation/widgets/basic_widget_test.dart b/test/features/auth/presentation/widgets/basic_widget_test.dart index 05c2e2e3..0fac7d35 100644 --- a/test/features/auth/presentation/widgets/basic_widget_test.dart +++ b/test/features/auth/presentation/widgets/basic_widget_test.dart @@ -15,6 +15,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../../../../fixtures/doubles/auth_doubles.dart'; import '../../../../fixtures/doubles/profile_doubles.dart'; +import '../../../../fixtures/doubles/session_doubles.dart'; import '../../../../fixtures/fixture_reader.dart'; import 'basic_widget_test.mocks.dart'; @@ -36,7 +37,7 @@ void main() { mockFirebaseApi = MockFirebaseApi(); SharedPreferences.setMockInitialValues({}); - authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); + authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: FakeSessionLifecycleService()); }); tearDown(() { @@ -55,7 +56,7 @@ void main() { testWidgets('should create AuthCubit successfully', (WidgetTester tester) async { // Arrange & Act - final cubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); + final cubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: FakeSessionLifecycleService()); // Assert expect(cubit, isA()); diff --git a/test/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit_session_test.dart b/test/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit_session_test.dart new file mode 100644 index 00000000..0535e564 --- /dev/null +++ b/test/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit_session_test.dart @@ -0,0 +1,204 @@ +import 'dart:async'; + +import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/token_storage.dart'; +import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart'; +import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart'; +import 'package:eatvio_app/features/profile/models/profile_model.dart'; +import 'package:eatvio_app/features/profile/repositories/profile_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class MockProfileRepository extends Mock implements ProfileRepository {} + +const _profileA = ProfileModel( + id: 1, + slug: 'user-a', + name: 'User A', + email: 'a@example.com', + phoneConfirmationState: 'confirmed', + isPublicProfile: true, + isBlockedByMe: false, + isBlockedByAuthor: false, + isBlockedByUser: false, + isEntitySubscribedByMe: false, + profilePhotoUrl: 'avatars/a.png', + roles: [], + badges: [], + subscriptions: [], + subscribers: [], +); + +const _profileB = ProfileModel( + id: 2, + slug: 'user-b', + name: 'User B', + email: 'b@example.com', + phoneConfirmationState: 'confirmed', + isPublicProfile: true, + isBlockedByMe: false, + isBlockedByAuthor: false, + isBlockedByUser: false, + isEntitySubscribedByMe: false, + profilePhotoUrl: 'avatars/b.png', + roles: [], + badges: [], + subscriptions: [], + subscribers: [], +); + +const _details = ProfileDetailsModel( + firstName: 'User', + lastName: 'Test', + birthdate: '1990-01-01', + gender: 'male', + country: 'Russia', + city: 'Moscow', + lang: 'ru', + weight: 70.0, + height: 175, + chestCircumference: 100, + waistCircumference: 80, + hipCircumference: 90, + bloodPressureUp: 120, + bloodPressureDown: 80, + hasHypo: false, + hasHyper: false, + aboutAlergy: '-', + aboutTakingPils: '-', + aboutSportHobbies: '-', + favoriteFoods: '-', + unfavoriteFoods: '-', + additionalInfo: '-', + isMoodFillable: true, + isBedtimeFillable: true, + activityCoefficient: 1.5, +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late MockProfileRepository repository; + ProfileAvatarCubit? cubit; + + setUp(() async { + await sl.reset(); + SharedPreferences.setMockInitialValues({'token': 'token-a'}); + TokenStorage.clearCache(); + repository = MockProfileRepository(); + }); + + tearDown(() async { + if (cubit != null && !cubit!.isClosed) { + await cubit!.close(); + } + cubit = null; + await sl.reset(); + TokenStorage.clearCache(); + }); + + void stubSuccessfulProfile(ProfileModel profile) { + when(() => repository.getMyProfile()).thenAnswer((_) async => profile); + when( + () => repository.getMyProfileDetails(), + ).thenAnswer((_) async => _details); + } + + test('reset emits loading and clears the cached profile', () async { + stubSuccessfulProfile(_profileA); + cubit = ProfileAvatarCubit(profileRepository: repository); + + await cubit!.ensureFreshForCurrentSession(); + expect(cubit!.state, isA()); + + cubit!.reset(); + expect(cubit!.state, isA()); + + await cubit!.ensureFreshForCurrentSession(); + + verify(() => repository.getMyProfile()).called(2); + expect(cubit!.state, isA()); + }); + + test('late getMyProfile completion after reset is ignored', () async { + final profileCompleter = Completer(); + when( + () => repository.getMyProfile(), + ).thenAnswer((_) => profileCompleter.future); + when( + () => repository.getMyProfileDetails(), + ).thenAnswer((_) async => _details); + + cubit = ProfileAvatarCubit(profileRepository: repository); + + final pendingRequest = cubit!.getMyProfile(); + await pumpEventQueue(); + expect(cubit!.state, isA()); + + cubit!.reset(); + profileCompleter.complete(_profileA); + await pendingRequest; + await pumpEventQueue(); + + expect(cubit!.state, isA()); + verifyNever(() => repository.getMyProfileDetails()); + }); + + test( + 'ensureFreshForCurrentSession does not refetch for the same token', + () async { + stubSuccessfulProfile(_profileA); + cubit = ProfileAvatarCubit(profileRepository: repository); + + await cubit!.ensureFreshForCurrentSession(); + await cubit!.ensureFreshForCurrentSession(); + + verify(() => repository.getMyProfile()).called(1); + expect(cubit!.state, isA()); + }, + ); + + test( + 'ensureFreshForCurrentSession refetches after the token changes', + () async { + final profiles = [_profileA, _profileB]; + when( + () => repository.getMyProfile(), + ).thenAnswer((_) async => profiles.removeAt(0)); + when( + () => repository.getMyProfileDetails(), + ).thenAnswer((_) async => _details); + + cubit = ProfileAvatarCubit(profileRepository: repository); + + await cubit!.ensureFreshForCurrentSession(); + final firstState = cubit!.state as ProfileAvatarStateLoaded; + expect(firstState.profile.id, _profileA.id); + + await TokenStorage.saveTokenInStorage('token-b'); + await cubit!.ensureFreshForCurrentSession(); + + final secondState = cubit!.state as ProfileAvatarStateLoaded; + expect(secondState.profile.id, _profileB.id); + expect(secondState.profile.profilePhotoUrl, _profileB.profilePhotoUrl); + verify(() => repository.getMyProfile()).called(2); + }, + ); + + test('errors emit ProfileAvatarStateError and do not navigate', () async { + when( + () => repository.getMyProfile(), + ).thenAnswer((_) async => throw Exception('profile request failed')); + + cubit = ProfileAvatarCubit(profileRepository: repository); + + await cubit!.getMyProfile(); + + expect(cubit!.state, isA()); + expect( + (cubit!.state as ProfileAvatarStateError).errorMessage, + contains('profile request failed'), + ); + }); +} diff --git a/test/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit_test.dart b/test/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit_test.dart index 61d515c1..981bfff4 100644 --- a/test/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit_test.dart +++ b/test/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit_test.dart @@ -1,3 +1,4 @@ +import 'package:eatvio_app/core/token_storage.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart'; import 'package:eatvio_app/features/profile/models/profile_model.dart'; @@ -5,6 +6,7 @@ import 'package:eatvio_app/features/profile/repositories/profile_repository.dart import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'profile_avatar_cubit_test.mocks.dart'; @@ -16,6 +18,8 @@ void main() { late MockProfileRepository mockRepository; setUp(() { + SharedPreferences.setMockInitialValues({'token': 'test-token'}); + TokenStorage.clearCache(); mockRepository = MockProfileRepository(); }); diff --git a/test/features/menu/presentation/bloc/menu_cubit_test.dart b/test/features/menu/presentation/bloc/menu_cubit_test.dart index 9cdf18d3..e7dd9a9c 100644 --- a/test/features/menu/presentation/bloc/menu_cubit_test.dart +++ b/test/features/menu/presentation/bloc/menu_cubit_test.dart @@ -4,6 +4,8 @@ import 'package:eatvio_app/features/profile/repositories/profile_repository.dart import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; + +import '../../../../fixtures/doubles/session_doubles.dart'; import 'menu_cubit_test.mocks.dart'; @GenerateNiceMocks([MockSpec()]) @@ -25,12 +27,12 @@ void main() { }); test('начальное состояние - MenuState', () { - menuCubit = MenuCubit(mockProfileRepository); + menuCubit = MenuCubit(mockProfileRepository, FakeSessionLifecycleService()); expect(menuCubit!.state, isA()); }); test('makePassKey - успешно создает passKey', () async { - menuCubit = MenuCubit(mockProfileRepository); + menuCubit = MenuCubit(mockProfileRepository, FakeSessionLifecycleService()); const expectedPassKey = 'test-pass-key'; when(mockProfileRepository.getPassKey( @@ -48,7 +50,7 @@ void main() { }); test('makePassKey - возвращает null при ошибке', () async { - menuCubit = MenuCubit(mockProfileRepository); + menuCubit = MenuCubit(mockProfileRepository, FakeSessionLifecycleService()); when(mockProfileRepository.getPassKey( routeName: 'product_nutritional_value.index', diff --git a/test/features/menu/presentation/pages/menu_screen_smoke_test.dart b/test/features/menu/presentation/pages/menu_screen_smoke_test.dart index 1facf0bb..cfc1ddf8 100644 --- a/test/features/menu/presentation/pages/menu_screen_smoke_test.dart +++ b/test/features/menu/presentation/pages/menu_screen_smoke_test.dart @@ -15,6 +15,7 @@ import 'package:go_router/go_router.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; +import '../../../../fixtures/doubles/session_doubles.dart'; import 'menu_screen_smoke_test.mocks.dart'; @GenerateNiceMocks([MockSpec()]) @@ -138,7 +139,7 @@ void main() { testWidgets('renders menu screen for user', (tester) async { final repository = MockProfileRepository(); - final cubit = MenuCubit(repository); + final cubit = MenuCubit(repository, FakeSessionLifecycleService()); when(repository.getPassKey(routeName: anyNamed('routeName'), routeParams: anyNamed('routeParams'))).thenAnswer((_) async => 'test-passkey'); when(repository.getMyProfile()).thenAnswer((_) async => mockUserProfile); @@ -155,7 +156,7 @@ void main() { testWidgets('renders menu screen for consultant', (tester) async { final repository = MockProfileRepository(); - final cubit = MenuCubit(repository); + final cubit = MenuCubit(repository, FakeSessionLifecycleService()); when(repository.getPassKey(routeName: anyNamed('routeName'), routeParams: anyNamed('routeParams'))).thenAnswer((_) async => 'test-passkey'); when(repository.getMyProfile()).thenAnswer((_) async => mockConsultantProfile); diff --git a/test/features/registration/basic_widget_test.dart b/test/features/registration/basic_widget_test.dart index 16995a82..2b0a6e29 100644 --- a/test/features/registration/basic_widget_test.dart +++ b/test/features/registration/basic_widget_test.dart @@ -9,6 +9,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; +import '../../fixtures/doubles/session_doubles.dart'; import 'basic_widget_test.mocks.dart'; @GenerateMocks([RegistrationRepository, Prefs]) @@ -19,7 +20,7 @@ void main() { setUp(() { mockRegistrationRepository = MockRegistrationRepository(); - registrationCubit = RegistrationCubit(registrationRepository: mockRegistrationRepository, prefs: MockPrefs()); + registrationCubit = RegistrationCubit(registrationRepository: mockRegistrationRepository, prefs: MockPrefs(), sessionLifecycleService: FakeSessionLifecycleService()); }); tearDown(() { @@ -38,7 +39,7 @@ void main() { testWidgets('should create RegistrationCubit successfully', (WidgetTester tester) async { // Arrange & Act - final cubit = RegistrationCubit(registrationRepository: mockRegistrationRepository, prefs: MockPrefs()); + final cubit = RegistrationCubit(registrationRepository: mockRegistrationRepository, prefs: MockPrefs(), sessionLifecycleService: FakeSessionLifecycleService()); // Assert expect(cubit, isA()); diff --git a/test/features/registration/integration_widget_test.dart b/test/features/registration/integration_widget_test.dart index e5559a80..0e35d009 100644 --- a/test/features/registration/integration_widget_test.dart +++ b/test/features/registration/integration_widget_test.dart @@ -7,6 +7,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; +import '../../fixtures/doubles/session_doubles.dart'; import 'integration_widget_test.mocks.dart'; @GenerateMocks([RegistrationRepository, Prefs]) @@ -17,7 +18,7 @@ void main() { setUp(() { mockRegistrationRepository = MockRegistrationRepository(); - registrationCubit = RegistrationCubit(registrationRepository: mockRegistrationRepository, prefs: MockPrefs()); + registrationCubit = RegistrationCubit(registrationRepository: mockRegistrationRepository, prefs: MockPrefs(), sessionLifecycleService: FakeSessionLifecycleService()); }); tearDown(() { diff --git a/test/features/registration/presentation/bloc/registration_cubit/registration_cubit_test.dart b/test/features/registration/presentation/bloc/registration_cubit/registration_cubit_test.dart index 11bfe5d6..3f108a79 100644 --- a/test/features/registration/presentation/bloc/registration_cubit/registration_cubit_test.dart +++ b/test/features/registration/presentation/bloc/registration_cubit/registration_cubit_test.dart @@ -7,6 +7,8 @@ import 'package:eatvio_app/features/registration/repositories/registration_repos import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import '../../../../../fixtures/doubles/session_doubles.dart'; + class MockRegistrationRepository extends Mock implements RegistrationRepository {} class MockPrefs extends Mock implements Prefs {} @@ -26,7 +28,7 @@ void main() { setUp(() { mockRepository = MockRegistrationRepository(); mockPrefs = MockPrefs(); - cubit = RegistrationCubit(registrationRepository: mockRepository, prefs: mockPrefs); + cubit = RegistrationCubit(registrationRepository: mockRepository, prefs: mockPrefs, sessionLifecycleService: FakeSessionLifecycleService()); }); tearDown(() { diff --git a/test/features/registration/presentation/pages/registration_screen_smoke_test.dart b/test/features/registration/presentation/pages/registration_screen_smoke_test.dart index d3b60f15..4fc28966 100644 --- a/test/features/registration/presentation/pages/registration_screen_smoke_test.dart +++ b/test/features/registration/presentation/pages/registration_screen_smoke_test.dart @@ -13,6 +13,7 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; +import '../../../../fixtures/doubles/session_doubles.dart'; import 'registration_screen_smoke_test.mocks.dart'; @GenerateNiceMocks([MockSpec(), MockSpec()]) @@ -44,7 +45,7 @@ void main() { testWidgets('renders registration screen with initial step', (tester) async { final repository = MockRegistrationRepository(); final prefs = MockPrefs(); - final cubit = RegistrationCubit(registrationRepository: repository, prefs: prefs); + final cubit = RegistrationCubit(registrationRepository: repository, prefs: prefs, sessionLifecycleService: FakeSessionLifecycleService()); sl.registerLazySingleton(() => repository); sl.registerLazySingleton(() => prefs); @@ -59,7 +60,7 @@ void main() { testWidgets('renders phone screen as first step', (tester) async { final repository = MockRegistrationRepository(); final prefs = MockPrefs(); - final cubit = RegistrationCubit(registrationRepository: repository, prefs: prefs); + final cubit = RegistrationCubit(registrationRepository: repository, prefs: prefs, sessionLifecycleService: FakeSessionLifecycleService()); sl.registerLazySingleton(() => repository); sl.registerLazySingleton(() => prefs); diff --git a/test/features/registration_v2/cubit/registration_cubit_v2_test.dart b/test/features/registration_v2/cubit/registration_cubit_v2_test.dart index e2d3d8ab..98f4ac25 100644 --- a/test/features/registration_v2/cubit/registration_cubit_v2_test.dart +++ b/test/features/registration_v2/cubit/registration_cubit_v2_test.dart @@ -9,6 +9,7 @@ import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../../../fixtures/doubles/session_doubles.dart'; import 'registration_cubit_v2_test.mocks.dart'; class _RecordingFirebaseApi extends Fake implements FirebaseApi { @@ -30,6 +31,7 @@ void main() { late RegistrationCubitV2 cubit; late MockRegistrationRepositoryV2 mockRepository; late _RecordingFirebaseApi firebaseApi; + late FakeSessionLifecycleService fakeSession; const MethodChannel passwordGeneratorChannel = MethodChannel( 'flutter_password_generator', ); @@ -37,9 +39,11 @@ void main() { setUp(() { mockRepository = MockRegistrationRepositoryV2(); firebaseApi = _RecordingFirebaseApi(); + fakeSession = FakeSessionLifecycleService(); cubit = RegistrationCubitV2( registrationRepository: mockRepository, firebaseApi: firebaseApi, + sessionLifecycleService: fakeSession, ); // Инициализируем SharedPreferences для тестов @@ -212,8 +216,7 @@ void main() { expect(result.$1, isTrue); expect(result.$2, isFalse); expect(await TokenStorage.getTokenFromStorage(), token); - expect(firebaseApi.registerPushTokenCalls, 1); - expect(firebaseApi.hadAuthToken, isTrue); + expect(fakeSession.onAuthenticatedCalls, 1); verify( mockRepository.register( registrationModel: anyNamed('registrationModel'), diff --git a/test/features/settings/presentation/bloc/settings_cubit_test.dart b/test/features/settings/presentation/bloc/settings_cubit_test.dart index 2733bd07..674492c4 100644 --- a/test/features/settings/presentation/bloc/settings_cubit_test.dart +++ b/test/features/settings/presentation/bloc/settings_cubit_test.dart @@ -1,3 +1,4 @@ +import 'package:eatvio_app/core/session/session_lifecycle_service.dart'; import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; import 'package:eatvio_app/features/consultations/repositories/consultations_repository.dart'; import 'package:eatvio_app/features/profile/repositories/profile_repository.dart'; @@ -7,6 +8,7 @@ import 'package:eatvio_app/features/settings/repositories/service_repository.dar import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; +import '../../../../fixtures/doubles/session_doubles.dart'; import '../../../../fixtures/settings_fixtures.dart'; import 'settings_cubit_test.mocks.dart'; @@ -23,6 +25,7 @@ void main() { late MockSettingsRepository mockSettingsRepository; late MockConsultantRepository mockConsultantRepository; late MockConsultationsRepository mockConsultationsRepository; + late FakeSessionLifecycleService fakeSession; setUp(() { @@ -30,6 +33,7 @@ void main() { mockSettingsRepository = MockSettingsRepository(); mockConsultantRepository = MockConsultantRepository(); mockConsultationsRepository = MockConsultationsRepository(); + fakeSession = FakeSessionLifecycleService(); }); tearDown(() async { @@ -46,6 +50,7 @@ void main() { settingsRepository: mockSettingsRepository, consultantRepository: mockConsultantRepository, consultationsRepository: mockConsultationsRepository, + sessionLifecycleService: fakeSession, ); expect(settingsCubit!.state, const SettingsState.loading()); }); @@ -56,6 +61,7 @@ void main() { settingsRepository: mockSettingsRepository, consultantRepository: mockConsultantRepository, consultationsRepository: mockConsultationsRepository, + sessionLifecycleService: fakeSession, ); when(mockProfileRepository.getMyProfile()) @@ -86,6 +92,7 @@ void main() { settingsRepository: mockSettingsRepository, consultantRepository: mockConsultantRepository, consultationsRepository: mockConsultationsRepository, + sessionLifecycleService: fakeSession, ); // Ошибка происходит при загрузке настроек профиля (внутри try-catch блока) @@ -114,6 +121,7 @@ void main() { settingsRepository: mockSettingsRepository, consultantRepository: mockConsultantRepository, consultationsRepository: mockConsultationsRepository, + sessionLifecycleService: fakeSession, ); when(mockProfileRepository.getMyProfile()) @@ -145,6 +153,7 @@ void main() { settingsRepository: mockSettingsRepository, consultantRepository: mockConsultantRepository, consultationsRepository: mockConsultationsRepository, + sessionLifecycleService: fakeSession, ); when(mockProfileRepository.getMyProfile()) @@ -176,6 +185,7 @@ void main() { settingsRepository: mockSettingsRepository, consultantRepository: mockConsultantRepository, consultationsRepository: mockConsultationsRepository, + sessionLifecycleService: fakeSession, ); when(mockProfileRepository.deleteAccount()) @@ -184,6 +194,8 @@ void main() { final result = await settingsCubit!.deleteAccount(); expect(result, true); + expect(fakeSession.endSessionCalls, 1); + expect(fakeSession.lastEndReason, SessionEndReason.accountDeleted); verify(mockProfileRepository.deleteAccount()).called(1); }); @@ -193,6 +205,7 @@ void main() { settingsRepository: mockSettingsRepository, consultantRepository: mockConsultantRepository, consultationsRepository: mockConsultationsRepository, + sessionLifecycleService: fakeSession, ); when(mockProfileRepository.deleteAccount()) @@ -209,6 +222,7 @@ void main() { settingsRepository: mockSettingsRepository, consultantRepository: mockConsultantRepository, consultationsRepository: mockConsultationsRepository, + sessionLifecycleService: fakeSession, ); when(mockProfileRepository.getMyProfile()) @@ -244,6 +258,7 @@ void main() { settingsRepository: mockSettingsRepository, consultantRepository: mockConsultantRepository, consultationsRepository: mockConsultationsRepository, + sessionLifecycleService: fakeSession, ); when(mockProfileRepository.getMyProfile()) diff --git a/test/features/settings/presentation/pages/change_password_screen_test.dart b/test/features/settings/presentation/pages/change_password_screen_test.dart index fe576e3b..c46d2c56 100644 --- a/test/features/settings/presentation/pages/change_password_screen_test.dart +++ b/test/features/settings/presentation/pages/change_password_screen_test.dart @@ -6,6 +6,7 @@ import 'package:eatvio_app/features/settings/presentation/bloc/settings_cubit/se import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../../../../fixtures/doubles/session_doubles.dart'; import '../../../../fixtures/settings_fixtures.dart'; import '../bloc/settings_cubit_test.mocks.dart'; @@ -27,6 +28,7 @@ void main() { settingsRepository: mockSettingsRepository, consultantRepository: mockConsultantRepository, consultationsRepository: mockConsultationsRepository, + sessionLifecycleService: FakeSessionLifecycleService(), ); }); diff --git a/test/features/settings/presentation/pages/settings_screen_smoke_test.dart b/test/features/settings/presentation/pages/settings_screen_smoke_test.dart index 92cb508a..4f39560f 100644 --- a/test/features/settings/presentation/pages/settings_screen_smoke_test.dart +++ b/test/features/settings/presentation/pages/settings_screen_smoke_test.dart @@ -18,6 +18,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; +import '../../../../fixtures/doubles/session_doubles.dart'; import 'settings_screen_smoke_test.mocks.dart'; @GenerateNiceMocks([ @@ -57,6 +58,7 @@ void main() { settingsRepository: sl(), consultantRepository: sl(), consultationsRepository: sl(), + sessionLifecycleService: FakeSessionLifecycleService(), ), ); }); diff --git a/test/fixtures/doubles/session_doubles.dart b/test/fixtures/doubles/session_doubles.dart new file mode 100644 index 00000000..09b07b83 --- /dev/null +++ b/test/fixtures/doubles/session_doubles.dart @@ -0,0 +1,24 @@ +import 'package:eatvio_app/core/session/session_lifecycle_service.dart'; + +class FakeSessionLifecycleService implements SessionLifecycleService { + int onAuthenticatedCalls = 0; + int endSessionCalls = 0; + int logoutCalls = 0; + SessionEndReason? lastEndReason; + + @override + Future onAuthenticated() async { + onAuthenticatedCalls++; + } + + @override + Future endSession({required SessionEndReason reason}) async { + endSessionCalls++; + lastEndReason = reason; + } + + @override + Future logout() async { + logoutCalls++; + } +} From 55e1633d1c2285d746394eac101a2c0752568aaf Mon Sep 17 00:00:00 2001 From: misha Date: Mon, 2 Nov 2026 11:42:14 +0300 Subject: [PATCH 10/10] fix: android ci runner changed --- .gitea/workflows/test.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 516f806a..98882dbe 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -14,7 +14,7 @@ env: jobs: build: - runs-on: [ubuntu-latest] + runs-on: [debian-12] steps: - uses: https://github.com/actions/checkout@v3 @@ -155,10 +155,10 @@ jobs: - name: Get dependencies run: | - flutter clean - flutter pub cache repair - flutter pub get - dart run build_runner build --delete-conflicting-outputs + flutter clean + flutter pub cache repair + flutter pub get + dart run build_runner build --delete-conflicting-outputs - name: Native Analyze run: flutter analyze