From 1acb158f3658ae6c1be200db955a2fe64b03b529 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 12 May 2026 07:47:41 +0300 Subject: [PATCH 01/88] feat(vidoe_calls): global cideo call state, connection, loading and error screens for video calls --- assets/icons/video_call/vc_control_enter.svg | 3 + lib/core/app_config.dart | 2 + lib/core/di/cubit_registrations.dart | 2 + lib/core/routing/other_routes.dart | 1 + lib/core/theme/custom_text_style.dart | 42 + .../chat/presentation/pages/chat_screen.dart | 10 + .../menu/presentation/pages/menu_screen.dart | 2 + .../video_call_session_cubit.dart | 726 ++++++++++++++++++ .../video_call_session_state.dart | 32 + .../presentation/pages/video_call_screen.dart | 655 +++++----------- ...video_call_connecting_entrance_screen.dart | 340 ++++++++ .../video_call_flow_placeholder_screens.dart | 305 ++++++++ .../widgets/video_call_global_pip_layer.dart | 167 ++++ lib/generated/intl/messages_en.dart | 22 + lib/generated/intl/messages_ru.dart | 22 + lib/generated/l10n.dart | 89 +++ lib/l10n/intl_en.arb | 13 +- lib/l10n/intl_ru.arb | 13 +- lib/main.dart | 10 + 19 files changed, 1998 insertions(+), 458 deletions(-) create mode 100644 assets/icons/video_call/vc_control_enter.svg create mode 100644 lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart create mode 100644 lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart create mode 100644 lib/features/video_calls/presentation/widgets/video_call_connecting_entrance_screen.dart create mode 100644 lib/features/video_calls/presentation/widgets/video_call_flow_placeholder_screens.dart create mode 100644 lib/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart diff --git a/assets/icons/video_call/vc_control_enter.svg b/assets/icons/video_call/vc_control_enter.svg new file mode 100644 index 00000000..e396beb2 --- /dev/null +++ b/assets/icons/video_call/vc_control_enter.svg @@ -0,0 +1,3 @@ + + + diff --git a/lib/core/app_config.dart b/lib/core/app_config.dart index df87d259..d410565f 100644 --- a/lib/core/app_config.dart +++ b/lib/core/app_config.dart @@ -8,6 +8,7 @@ import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/new_ import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_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/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; @@ -35,6 +36,7 @@ class AppConfig { /// Список глобальных BlocProvider'ов /// Используются lazy: true для отложенной инициализации, чтобы не блокировать старт приложения List get globalBlocProviders => [ + BlocProvider.value(value: sl()), BlocProvider(lazy: true, create: (context) => sl()), BlocProvider(lazy: true, create: (context) => sl()), BlocProvider(lazy: true, create: (context) => sl()), diff --git a/lib/core/di/cubit_registrations.dart b/lib/core/di/cubit_registrations.dart index 6f26ab95..e9f932b4 100644 --- a/lib/core/di/cubit_registrations.dart +++ b/lib/core/di/cubit_registrations.dart @@ -51,6 +51,7 @@ import 'package:eatvio_app/features/registration/presentation/bloc/verify_email_ import 'package:eatvio_app/features/registration_v2/cubit/registration_cubit.dart'; import 'package:eatvio_app/features/settings/presentation/bloc/settings_cubit/settings_cubit.dart'; import 'package:eatvio_app/features/user_details/presentation/cubit/user_details_cubit.dart'; +import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart'; import 'package:get_it/get_it.dart'; @@ -71,6 +72,7 @@ class CubitRegistrations { sl.registerLazySingleton(() => ProfileAvatarCubit(profileRepository: sl())); sl.registerLazySingleton(() => ChatsCubit(chatRepository: sl(), wsManager: sl(), profileRepository: sl())); sl.registerLazySingleton(() => InternetConnectionCubit()); + sl.registerLazySingleton(() => VideoCallSessionCubit(videoCallRepository: sl())); } /// Регистрация Cubit'ов как фабрик diff --git a/lib/core/routing/other_routes.dart b/lib/core/routing/other_routes.dart index b321a15c..42e29f10 100644 --- a/lib/core/routing/other_routes.dart +++ b/lib/core/routing/other_routes.dart @@ -234,6 +234,7 @@ class OtherRoutes { return VideoCallScreen( conversationUuid: extra['conversationUuid'] as String, counterpartName: extra['counterpartName'] as String?, + counterpartAvatarUrl: extra['counterpartAvatarUrl'] as String?, localParticipantName: extra['localParticipantName'] as String?, onBackToChat: extra['onBackToChat'] as void Function()?, onHangUp: extra['onHangUp'] as void Function()?, diff --git a/lib/core/theme/custom_text_style.dart b/lib/core/theme/custom_text_style.dart index f0d0661b..1e09ba93 100644 --- a/lib/core/theme/custom_text_style.dart +++ b/lib/core/theme/custom_text_style.dart @@ -80,6 +80,48 @@ class CustomTextStyles { static TextStyle get titleSmallSecondaryContainer_1 => theme.textTheme.titleSmall!.copyWith(color: theme.colorScheme.secondaryContainer); static TextStyle get titleSmallSegoeUIOnPrimary => theme.textTheme.titleSmall!.segoeUI.copyWith(color: theme.colorScheme.onPrimary); + + static TextStyle get videoCallFlowPlaceholderTitle => theme.textTheme.headlineSmall!.copyWith( + fontWeight: FontWeight.w600, + height: 32 / 24, + color: appTheme.black900, + ); + + static TextStyle get videoCallFlowPlaceholderConnectionLostBody => theme.textTheme.bodyLarge!.copyWith( + fontWeight: FontWeight.w400, + fontSize: 16, + height: 24 / 16, + letterSpacing: 0.5, + color: appTheme.black900, + ); + + static TextStyle get videoCallFlowPlaceholderConnectingSubtitle => theme.textTheme.bodyMedium!.copyWith( + fontSize: 13, + fontWeight: FontWeight.w400, + height: 24 / 13, + letterSpacing: 0.5, + color: appTheme.black900, + ); + + static TextStyle get videoCallFlowPlaceholderReconnectingSubtitle => theme.textTheme.bodyMedium!.copyWith( + fontSize: 14, + fontWeight: FontWeight.w500, + height: 24 / 14, + color: appTheme.gray600, + ); + + static TextStyle get videoCallFlowPlaceholderConnectionLostDetail => theme.textTheme.bodyMedium!.copyWith( + fontSize: 13, + fontWeight: FontWeight.w400, + height: 1.35, + color: appTheme.red, + ); + + static TextStyle get videoCallFlowPlaceholderFilledButtonLabel => theme.textTheme.titleMedium!.copyWith( + fontSize: 16, + fontWeight: FontWeight.w600, + color: appTheme.whiteA700, + ); } extension on TextStyle { diff --git a/lib/features/chat/presentation/pages/chat_screen.dart b/lib/features/chat/presentation/pages/chat_screen.dart index 4c7b96e6..6ab605f8 100644 --- a/lib/features/chat/presentation/pages/chat_screen.dart +++ b/lib/features/chat/presentation/pages/chat_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'dart:math'; @@ -24,6 +25,7 @@ import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'; import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.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/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:file_picker/file_picker.dart'; @@ -239,11 +241,19 @@ class _ChatScreenState extends State { padding: const EdgeInsetsDirectional.only(end: 8), child: _ChatHeaderVideoCallButton( onPressed: () { + unawaited( + sl().startCall( + conversationUuid: widget.uuid, + counterpartName: state.chatRoom.users.receiver.name, + localParticipantName: state.chatRoom.users.sender.name, + ), + ); GoRouter.of(context).pushNamed( VideoCallScreen.name, extra: { 'conversationUuid': widget.uuid, 'counterpartName': state.chatRoom.users.receiver.name, + 'counterpartAvatarUrl': state.chatRoom.users.receiver.profilePhotoUrl, 'localParticipantName': state.chatRoom.users.sender.name, }, ); diff --git a/lib/features/menu/presentation/pages/menu_screen.dart b/lib/features/menu/presentation/pages/menu_screen.dart index 05cabce2..41f405fd 100644 --- a/lib/features/menu/presentation/pages/menu_screen.dart +++ b/lib/features/menu/presentation/pages/menu_screen.dart @@ -15,6 +15,7 @@ import 'package:eatvio_app/features/consultant_rating/presentation/consultant_ra 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'; @@ -165,6 +166,7 @@ class MenuScreen extends StatelessWidget { } catch (e, s) { await sl().report(e, s, origin: 'MenuScreen'); } finally { + await sl().endCall(); await sl().clear(); await TokenStorage.removeTokenInStorage(); if (context.mounted) { diff --git a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart new file mode 100644 index 00000000..03136e76 --- /dev/null +++ b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart @@ -0,0 +1,726 @@ +import 'dart:async'; + +import 'package:eatvio_app/core/app_router.dart'; +import 'package:eatvio_app/core/cubit/base_cubit.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/widgets/show_error_dialog.dart'; +import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_top_overlay.dart'; +import 'package:eatvio_app/features/video_calls/repositories/video_call_repository.dart'; +import 'package:eatvio_app/generated/l10n.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:livekit_client/livekit_client.dart' as lk; +import 'package:permission_handler/permission_handler.dart'; + +// ignore_for_file: use_build_context_synchronously + +class VideoCallSessionCubit extends BaseCubit { + VideoCallSessionCubit({required VideoCallRepository videoCallRepository}) + : _videoCallRepository = videoCallRepository, + super(const VideoCallSessionState()); + + final VideoCallRepository _videoCallRepository; + + static const lk.AudioCaptureOptions _micCaptureOptions = + lk.AudioCaptureOptions(stopAudioCaptureOnMute: false); + static const Duration _mutedMicPollInterval = Duration(milliseconds: 320); + static const Duration _mutedMicOverlayMinInterval = Duration( + milliseconds: 2600, + ); + static const double _mutedMicAudioLevelThreshold = 0.008; + static const double _mutedMicEnergyDelta = 1e-6; + + lk.Room? _room; + lk.EventsListener? _roomEventsListener; + OverlayEntry? _poorConnectionEntry; + Timer? _mutedMicPollTimer; + DateTime? _lastMutedMicOverlayAt; + bool _recordingOverlayScheduled = false; + bool _mutedMicStatsInFlight = false; + num? _lastMutedMicTotalEnergy; + bool _liveKitDisposed = true; + bool _callEndedNotified = true; + + lk.Room? get liveKitRoom => _room; + + lk.CameraCaptureOptions _cameraCaptureOptions({ + lk.CameraPosition? cameraPosition, + }) { + return lk.CameraCaptureOptions( + cameraPosition: + cameraPosition ?? + (state.useFrontCamera + ? lk.CameraPosition.front + : lk.CameraPosition.back), + params: lk.VideoParametersPresets.h1080_169, + ); + } + + BuildContext? get _navigatorContext => AppRouter.navigatorKey.currentContext; + + Future startCall({ + required String conversationUuid, + String? counterpartName, + String? localParticipantName, + }) async { + if (_room != null && + !_liveKitDisposed && + state.conversationUuid == conversationUuid && + _room!.connectionState == lk.ConnectionState.connected) { + emit( + state.copyWith( + counterpartName: counterpartName ?? state.counterpartName, + localParticipantName: + localParticipantName ?? state.localParticipantName, + ), + ); + return; + } + + await endCall(); + + _resetSessionFlagsForNewRoom(); + _callEndedNotified = false; + _liveKitDisposed = false; + _room = lk.Room( + roomOptions: const lk.RoomOptions( + adaptiveStream: true, + defaultCameraCaptureOptions: lk.CameraCaptureOptions( + params: lk.VideoParametersPresets.h1080_169, + ), + defaultAudioCaptureOptions: _micCaptureOptions, + ), + ); + _attachRoomEventListeners(); + _startMutedMicPollTimer(); + + emit( + state.copyWith( + conversationUuid: conversationUuid, + counterpartName: counterpartName, + localParticipantName: localParticipantName, + connectError: null, + connectPhase: VideoCallConnectPhase.connecting, + uiMode: VideoCallUiMode.hidden, + linkIssue: VideoCallLinkIssue.none, + callStageEntered: false, + ), + ); + + await _connectLiveKit(); + } + + void _resetSessionFlagsForNewRoom() { + _lastMutedMicOverlayAt = null; + _lastMutedMicTotalEnergy = null; + _recordingOverlayScheduled = false; + } + + Future onCallScreenVisible({ + required String conversationUuid, + String? counterpartName, + String? localParticipantName, + }) async { + if (state.conversationUuid != conversationUuid || + _room == null || + _liveKitDisposed) { + await startCall( + conversationUuid: conversationUuid, + counterpartName: counterpartName, + localParticipantName: localParticipantName, + ); + } else { + emit( + state.copyWith( + counterpartName: counterpartName ?? state.counterpartName, + localParticipantName: + localParticipantName ?? state.localParticipantName, + ), + ); + } + + emit(state.copyWith(uiMode: VideoCallUiMode.fullscreen)); + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_applyVideoCallSystemUi()); + }); + } + + void onCallScreenDisposed() { + if (state.uiMode != VideoCallUiMode.fullscreen) return; + if (state.connectPhase == VideoCallConnectPhase.connected && + state.linkIssue != VideoCallLinkIssue.lost) { + if (state.callStageEntered) { + unawaited(minimizeToPip()); + } else { + unawaited(endCall()); + } + } else if (state.connectPhase == VideoCallConnectPhase.connecting) { + unawaited(endCall()); + } else if (state.linkIssue == VideoCallLinkIssue.lost) { + unawaited(endCall()); + } + } + + Future minimizeToPip() async { + if (state.connectPhase != VideoCallConnectPhase.connected) return; + if (state.linkIssue == VideoCallLinkIssue.lost) return; + if (!state.callStageEntered) return; + emit(state.copyWith(uiMode: VideoCallUiMode.pip)); + await _restoreSystemUiAfterCall(); + } + + Future leaveCallRoute() async { + if (state.connectPhase == VideoCallConnectPhase.connected && + state.linkIssue != VideoCallLinkIssue.lost) { + if (state.callStageEntered) { + await minimizeToPip(); + } else { + await endCall(); + } + } else { + await endCall(); + } + } + + void enterCallStage() { + if (isClosed) return; + if (state.connectPhase != VideoCallConnectPhase.connected) return; + if (state.callStageEntered) return; + emit(state.copyWith(callStageEntered: true)); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (isClosed) return; + _scheduleRecordingNoticeOnce(); + }); + } + + Future retryAfterConnectionLoss() async { + if (state.conversationUuid.isEmpty) return; + _disposeRoomEphemerals(); + await _disconnectLiveKitOnly(); + _resetSessionFlagsForNewRoom(); + _liveKitDisposed = false; + _room = lk.Room( + roomOptions: const lk.RoomOptions( + adaptiveStream: true, + defaultCameraCaptureOptions: lk.CameraCaptureOptions( + params: lk.VideoParametersPresets.h1080_169, + ), + defaultAudioCaptureOptions: _micCaptureOptions, + ), + ); + _attachRoomEventListeners(); + _startMutedMicPollTimer(); + emit( + state.copyWith( + connectPhase: VideoCallConnectPhase.connecting, + linkIssue: VideoCallLinkIssue.none, + connectError: null, + callStageEntered: false, + ), + ); + await _connectLiveKit(); + } + + void _disposeRoomEphemerals() { + _mutedMicPollTimer?.cancel(); + _mutedMicPollTimer = null; + final listener = _roomEventsListener; + _roomEventsListener = null; + if (listener != null) { + unawaited(listener.dispose()); + } + final poor = _poorConnectionEntry; + _poorConnectionEntry = null; + if (poor != null && poor.mounted) { + poor.remove(); + } + } + + Future endCall() async { + final uuid = state.conversationUuid; + _disposeRoomEphemerals(); + await _disconnectLiveKitOnly(); + if (!_callEndedNotified && uuid.isNotEmpty) { + _callEndedNotified = true; + try { + await _videoCallRepository.notifyCallEnded(uuid); + } catch (_) {} + } + await _restoreSystemUiAfterCall(); + _liveKitDisposed = true; + emit(const VideoCallSessionState()); + } + + void expandLocal() { + emit( + state.copyWith( + splitParticipantView: false, + mainStageShowsLocalVideo: true, + ), + ); + } + + void expandRemote() { + emit( + state.copyWith( + splitParticipantView: false, + mainStageShowsLocalVideo: false, + ), + ); + } + + void shrinkToSplit() { + emit(state.copyWith(splitParticipantView: true)); + } + + void toggleStageSwap() { + emit( + state.copyWith(mainStageShowsLocalVideo: !state.mainStageShowsLocalVideo), + ); + } + + Future toggleMic() async { + if (_liveKitDisposed || + _room == null || + _room!.connectionState != lk.ConnectionState.connected) { + emit(state.copyWith(micEnabled: !state.micEnabled)); + _lastMutedMicTotalEnergy = null; + return; + } + final next = !state.micEnabled; + if (next) { + final granted = await _ensureMicrophonePermission(); + if (!granted) return; + } + emit(state.copyWith(micEnabled: next)); + _lastMutedMicTotalEnergy = null; + await _room!.localParticipant?.setMicrophoneEnabled( + next, + audioCaptureOptions: _micCaptureOptions, + ); + } + + Future toggleVideo() async { + if (_liveKitDisposed || + _room == null || + _room!.connectionState != lk.ConnectionState.connected) { + emit(state.copyWith(videoEnabled: !state.videoEnabled)); + return; + } + final next = !state.videoEnabled; + if (next) { + final granted = await _ensureCameraPermission(); + if (!granted) return; + } + emit(state.copyWith(videoEnabled: next)); + final lp = _room!.localParticipant; + if (lp == null) return; + if (next) { + await lp.setCameraEnabled( + true, + cameraCaptureOptions: _cameraCaptureOptions(), + ); + } else { + await lp.setCameraEnabled(false); + } + } + + Future switchCameraFacing() async { + if (_liveKitDisposed || + _room == null || + _room!.connectionState != lk.ConnectionState.connected) { + emit(state.copyWith(useFrontCamera: !state.useFrontCamera)); + return; + } + if (!state.videoEnabled) return; + + final lp = _room!.localParticipant; + if (lp == null) return; + + final currentPosition = state.useFrontCamera + ? lk.CameraPosition.front + : lk.CameraPosition.back; + final newPosition = currentPosition.switched(); + + final pub = lp.getTrackPublicationBySource(lk.TrackSource.camera); + final track = pub?.track; + if (track is lk.LocalVideoTrack) { + try { + await track.setCameraPosition(newPosition); + emit( + state.copyWith( + useFrontCamera: newPosition == lk.CameraPosition.front, + ), + ); + } catch (_) {} + return; + } + + try { + await lp.setCameraEnabled( + true, + cameraCaptureOptions: _cameraCaptureOptions( + cameraPosition: newPosition, + ), + ); + emit( + state.copyWith(useFrontCamera: newPosition == lk.CameraPosition.front), + ); + } catch (_) {} + } + + void _startMutedMicPollTimer() { + _mutedMicPollTimer?.cancel(); + _mutedMicPollTimer = Timer.periodic(_mutedMicPollInterval, (_) { + if (isClosed || + _liveKitDisposed || + state.micEnabled || + _mutedMicStatsInFlight) { + return; + } + if (_room == null || + _room!.connectionState != lk.ConnectionState.connected) { + return; + } + _mutedMicStatsInFlight = true; + unawaited( + _pollMutedMicFromWebRtcStats().whenComplete(() { + _mutedMicStatsInFlight = false; + }), + ); + }); + } + + void _attachRoomEventListeners() { + final room = _room; + if (room == null) return; + _roomEventsListener = room.createListener() + ..on((_) { + if (isClosed) return; + if (state.connectPhase != VideoCallConnectPhase.connected) return; + emit(state.copyWith(linkIssue: VideoCallLinkIssue.reconnecting)); + }) + ..on((_) { + if (isClosed) return; + emit(state.copyWith(linkIssue: VideoCallLinkIssue.none)); + }) + ..on((_) { + if (isClosed) return; + if (_liveKitDisposed) return; + if (state.connectPhase != VideoCallConnectPhase.connected) return; + emit(state.copyWith(linkIssue: VideoCallLinkIssue.lost)); + }) + ..on((e) { + if (e.participant != room.localParticipant) return; + if (isClosed) return; + _syncPoorConnectionOverlay(e.connectionQuality); + }) + ..on((e) { + if (isClosed || state.micEnabled) return; + final lp = room.localParticipant; + if (lp == null) return; + if (e.speakers.any((p) => p.sid == lp.sid)) { + _maybeShowMutedMicOverlay(); + } + }); + } + + void _scheduleRecordingNoticeOnce() { + if (_recordingOverlayScheduled) return; + final ctx = _navigatorContext; + if (ctx == null) return; + _recordingOverlayScheduled = true; + VideoCallTopOverlay.insertBanner( + ctx, + message: S.of(ctx).video_call_recording_notice, + backgroundColor: appTheme.videoCallNoticeBannerBackground, + svgAsset: VideoCallTopOverlay.warningSvg, + autoRemoveAfter: const Duration(seconds: 3), + ); + } + + void _syncPoorConnectionOverlay(lk.ConnectionQuality q) { + final ctx = _navigatorContext; + if (ctx == null) return; + final bad = + q == lk.ConnectionQuality.poor || q == lk.ConnectionQuality.lost; + if (bad) { + if (_poorConnectionEntry != null) return; + _poorConnectionEntry = VideoCallTopOverlay.insertBanner( + ctx, + message: S.of(ctx).video_call_poor_connection, + backgroundColor: appTheme.strongRed, + svgAsset: VideoCallTopOverlay.poorConnectionSvg, + ); + } else { + final e = _poorConnectionEntry; + if (e != null && e.mounted) { + e.remove(); + } + _poorConnectionEntry = null; + } + } + + void _maybeShowMutedMicOverlay() { + if (isClosed || state.micEnabled) return; + final ctx = _navigatorContext; + if (ctx == null) return; + final now = DateTime.now(); + if (_lastMutedMicOverlayAt != null && + now.difference(_lastMutedMicOverlayAt!) < _mutedMicOverlayMinInterval) { + return; + } + _lastMutedMicOverlayAt = now; + VideoCallTopOverlay.insertBanner( + ctx, + message: S.of(ctx).video_call_muted_mic_warning, + backgroundColor: appTheme.videoCallNoticeBannerBackground, + svgAsset: VideoCallTopOverlay.warningSvg, + autoRemoveAfter: const Duration(seconds: 2), + ); + } + + Future _pollMutedMicFromWebRtcStats() async { + if (isClosed || _liveKitDisposed || state.micEnabled) return; + final room = _room; + if (room == null || room.connectionState != lk.ConnectionState.connected) { + return; + } + final lp = room.localParticipant; + if (lp == null) return; + final pub = lp.getTrackPublicationBySource(lk.TrackSource.microphone); + final track = pub?.track; + if (track is! lk.LocalAudioTrack) return; + if (!track.muted) return; + try { + final stats = await track.getSenderStats(); + if (isClosed || _liveKitDisposed || state.micEnabled) return; + final src = stats?.audioSourceStats; + if (src == null) return; + + final level = src.audioLevel; + if (level != null && level >= _mutedMicAudioLevelThreshold) { + _maybeShowMutedMicOverlay(); + _lastMutedMicTotalEnergy = src.totalAudioEnergy; + return; + } + + final energy = src.totalAudioEnergy; + if (energy != null) { + final prev = _lastMutedMicTotalEnergy; + if (prev != null && energy > prev + _mutedMicEnergyDelta) { + _maybeShowMutedMicOverlay(); + } + _lastMutedMicTotalEnergy = energy; + } + } catch (_) {} + } + + Future _applyVideoCallSystemUi() async { + try { + await SystemChrome.setPreferredOrientations(const [ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + } on PlatformException catch (_) { + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + } + } + + Future _restoreSystemUiAfterCall() async { + try { + await SystemChrome.setPreferredOrientations(const []); + await SystemChrome.setEnabledSystemUIMode( + SystemUiMode.manual, + overlays: SystemUiOverlay.values, + ); + } on PlatformException catch (_) {} + } + + Future _connectLiveKit() async { + final room = _room; + if (room == null) return; + emit(state.copyWith(connectError: null)); + try { + final tokenResponse = await _videoCallRepository.getParticipantToken( + state.conversationUuid, + ); + if (isClosed || _liveKitDisposed) return; + + var micOn = state.micEnabled; + var videoOn = state.videoEnabled; + if (micOn) { + final granted = await _ensureMicrophonePermission(); + if (isClosed || _liveKitDisposed) return; + if (!granted) micOn = false; + } + if (videoOn) { + final granted = await _ensureCameraPermission(); + if (isClosed || _liveKitDisposed) return; + if (!granted) videoOn = false; + } + if (micOn != state.micEnabled || videoOn != state.videoEnabled) { + emit(state.copyWith(micEnabled: micOn, videoEnabled: videoOn)); + } + + await room.connect(tokenResponse.wsUrl, tokenResponse.token); + if (isClosed || _liveKitDisposed) { + return; + } + + var effectiveMic = micOn; + var effectiveVideo = videoOn; + final lp = room.localParticipant; + + if (micOn) { + try { + await lp?.setMicrophoneEnabled( + true, + audioCaptureOptions: _micCaptureOptions, + ); + } catch (_) { + effectiveMic = false; + } + } + if (isClosed || _liveKitDisposed) return; + + if (videoOn) { + try { + await lp?.setCameraEnabled( + true, + cameraCaptureOptions: _cameraCaptureOptions(), + ); + } catch (_) { + effectiveVideo = false; + } + } + + if (effectiveMic != state.micEnabled || + effectiveVideo != state.videoEnabled) { + emit( + state.copyWith( + micEnabled: effectiveMic, + videoEnabled: effectiveVideo, + ), + ); + } + + if (!isClosed) { + emit( + state.copyWith( + connectPhase: VideoCallConnectPhase.connected, + linkIssue: VideoCallLinkIssue.none, + ), + ); + _syncPoorConnectionOverlay( + room.localParticipant?.connectionQuality ?? + lk.ConnectionQuality.unknown, + ); + } + } catch (e) { + if (!isClosed) { + emit( + state.copyWith( + connectError: e.toString(), + connectPhase: VideoCallConnectPhase.idle, + linkIssue: VideoCallLinkIssue.lost, + ), + ); + } + } + } + + Future _disconnectLiveKitOnly() async { + if (_room == null) return; + _liveKitDisposed = true; + final room = _room!; + _room = null; + try { + if (room.connectionState != lk.ConnectionState.disconnected) { + await room.disconnect(); + } + } catch (_) {} + try { + await room.dispose(); + } catch (_) {} + } + + bool _permissionNeedsSettings(PermissionStatus status) => + status.isPermanentlyDenied || status.isRestricted; + + Future _ensureCameraPermission() async { + final ctx = _navigatorContext; + if (ctx == null) return false; + var status = await Permission.camera.status; + if (status.isGranted) return true; + status = await Permission.camera.request(); + if (status.isGranted) return true; + final ctx2 = _navigatorContext; + if (ctx2 == null) return false; + final s = S.of(ctx2); + await showErrorDialog( + ctx2, + title: s.video_call_permission_camera_title, + description: s.video_call_permission_camera_description, + buttonTitle: s.allowAccess, + needToOpenSettings: _permissionNeedsSettings(status), + ); + return false; + } + + Future _ensureMicrophonePermission() async { + final ctx = _navigatorContext; + if (ctx == null) return false; + var status = await Permission.microphone.status; + if (status.isGranted) return true; + status = await Permission.microphone.request(); + if (status.isGranted) return true; + final ctx2 = _navigatorContext; + if (ctx2 == null) return false; + final s = S.of(ctx2); + await showErrorDialog( + ctx2, + title: s.video_call_permission_microphone_title, + description: s.video_call_permission_microphone_description, + buttonTitle: s.allowAccess, + needToOpenSettings: _permissionNeedsSettings(status), + ); + return false; + } + + String resolvedLocalDisplayName() { + final fromNav = state.localParticipantName?.trim(); + if (fromNav != null && fromNav.isNotEmpty) return fromNav; + final n = (_room?.localParticipant?.name ?? '').trim(); + if (n.isNotEmpty) return n; + final ctx = _navigatorContext; + if (ctx != null) { + return S.of(ctx).video_call_you; + } + return 'You'; + } + + String resolvedRemoteDisplayName(String fallback) { + final room = _room; + if (room != null) { + for (final p in room.remoteParticipants.values) { + final n = p.name.trim(); + if (n.isNotEmpty) return n; + } + } + final c = state.counterpartName?.trim(); + if (c != null && c.isNotEmpty) return c; + return fallback; + } + + @override + Future close() async { + _mutedMicPollTimer?.cancel(); + await endCall(); + return super.close(); + } +} diff --git a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart new file mode 100644 index 00000000..431d2e72 --- /dev/null +++ b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart @@ -0,0 +1,32 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'video_call_session_state.freezed.dart'; + +enum VideoCallUiMode { + hidden, + fullscreen, + pip, +} + +enum VideoCallConnectPhase { idle, connecting, connected } + +enum VideoCallLinkIssue { none, reconnecting, lost } + +@freezed +abstract class VideoCallSessionState with _$VideoCallSessionState { + const factory VideoCallSessionState({ + @Default('') String conversationUuid, + String? counterpartName, + String? localParticipantName, + @Default(true) bool micEnabled, + @Default(true) bool videoEnabled, + @Default(true) bool splitParticipantView, + @Default(false) bool mainStageShowsLocalVideo, + @Default(true) bool useFrontCamera, + String? connectError, + @Default(VideoCallUiMode.hidden) VideoCallUiMode uiMode, + @Default(VideoCallConnectPhase.idle) VideoCallConnectPhase connectPhase, + @Default(VideoCallLinkIssue.none) VideoCallLinkIssue linkIssue, + @Default(false) bool callStageEntered, + }) = _VideoCallSessionState; +} diff --git a/lib/features/video_calls/presentation/pages/video_call_screen.dart b/lib/features/video_calls/presentation/pages/video_call_screen.dart index 327bdafd..53d22515 100644 --- a/lib/features/video_calls/presentation/pages/video_call_screen.dart +++ b/lib/features/video_calls/presentation/pages/video_call_screen.dart @@ -2,23 +2,24 @@ import 'dart:async'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/custom_text_style.dart'; -import 'package:eatvio_app/core/widgets/show_error_dialog.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; -import 'package:eatvio_app/features/video_calls/repositories/video_call_repository.dart'; +import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; +import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_connecting_entrance_screen.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_flow_placeholder_screens.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_stage_layout.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:go_router/go_router.dart'; -import 'package:livekit_client/livekit_client.dart' as lk; -import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_stage_layout.dart'; -import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_top_overlay.dart'; -import 'package:permission_handler/permission_handler.dart'; class VideoCallScreen extends StatefulWidget { const VideoCallScreen({ super.key, required this.conversationUuid, this.counterpartName, + this.counterpartAvatarUrl, this.localParticipantName, this.onBackToChat, this.onHangUp, @@ -29,6 +30,7 @@ class VideoCallScreen extends StatefulWidget { final String conversationUuid; final String? counterpartName; + final String? counterpartAvatarUrl; final String? localParticipantName; final VoidCallback? onBackToChat; final VoidCallback? onHangUp; @@ -38,437 +40,53 @@ class VideoCallScreen extends StatefulWidget { } class _VideoCallScreenState extends State { - static const lk.AudioCaptureOptions _micCaptureOptions = lk.AudioCaptureOptions(stopAudioCaptureOnMute: false); - static const Duration _mutedMicPollInterval = Duration(milliseconds: 320); - static const Duration _mutedMicOverlayMinInterval = Duration(milliseconds: 2600); - static const double _mutedMicAudioLevelThreshold = 0.008; - static const double _mutedMicEnergyDelta = 1e-6; + static const SystemUiOverlayStyle _videoCallOverlayStyle = + SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + statusBarBrightness: Brightness.dark, + systemNavigationBarColor: Colors.transparent, + systemNavigationBarIconBrightness: Brightness.light, + systemNavigationBarContrastEnforced: false, + ); - final lk.Room _room = lk.Room( - roomOptions: const lk.RoomOptions( - adaptiveStream: true, - defaultCameraCaptureOptions: lk.CameraCaptureOptions(params: lk.VideoParametersPresets.h1080_169), - defaultAudioCaptureOptions: _micCaptureOptions, - ), - ); - final VideoCallRepository _videoCallRepository = sl(); - - bool _micEnabled = true; - bool _videoEnabled = true; - bool _splitParticipantView = true; - bool _mainStageShowsLocalVideo = false; - bool _useFrontCamera = true; - String? _connectError; - bool _liveKitDisposed = false; - bool _callEndedNotified = false; - - lk.EventsListener? _roomEventsListener; - OverlayEntry? _poorConnectionEntry; - Timer? _mutedMicPollTimer; - DateTime? _lastMutedMicOverlayAt; - bool _recordingOverlayScheduled = false; - bool _mutedMicStatsInFlight = false; - num? _lastMutedMicTotalEnergy; - - lk.CameraCaptureOptions _cameraCaptureOptions({lk.CameraPosition? cameraPosition}) { - return lk.CameraCaptureOptions( - cameraPosition: cameraPosition ?? (_useFrontCamera ? lk.CameraPosition.front : lk.CameraPosition.back), - params: lk.VideoParametersPresets.h1080_169, - ); - } + VideoCallSessionCubit get _cubit => sl(); @override void initState() { super.initState(); - _attachRoomEventListeners(); - _mutedMicPollTimer = Timer.periodic(_mutedMicPollInterval, (_) { - if (!mounted || _liveKitDisposed || _micEnabled || _mutedMicStatsInFlight) return; - if (_room.connectionState != lk.ConnectionState.connected) return; - _mutedMicStatsInFlight = true; - unawaited( - _pollMutedMicFromWebRtcStats().whenComplete(() { - _mutedMicStatsInFlight = false; - }), - ); - }); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - unawaited(_applyVideoCallSystemUi()); - _scheduleRecordingNoticeOnce(); - }); - unawaited(_connectLiveKit()); - } - - void _attachRoomEventListeners() { - _roomEventsListener = _room.createListener() - ..on((e) { - if (e.participant != _room.localParticipant) return; - if (!mounted) return; - _syncPoorConnectionOverlay(e.connectionQuality); - }) - ..on((e) { - if (!mounted || _micEnabled) return; - final lp = _room.localParticipant; - if (lp == null) return; - if (e.speakers.any((p) => p.sid == lp.sid)) { - _maybeShowMutedMicOverlay(); - } - }); - } - - void _scheduleRecordingNoticeOnce() { - if (_recordingOverlayScheduled || !mounted) return; - _recordingOverlayScheduled = true; - VideoCallTopOverlay.insertBanner( - context, - message: S.of(context).video_call_recording_notice, - backgroundColor: appTheme.videoCallNoticeBannerBackground, - svgAsset: VideoCallTopOverlay.warningSvg, - autoRemoveAfter: const Duration(seconds: 3), - ); - } - - void _syncPoorConnectionOverlay(lk.ConnectionQuality q) { - final bad = q == lk.ConnectionQuality.poor || q == lk.ConnectionQuality.lost; - if (bad) { - if (_poorConnectionEntry != null) return; - if (!mounted) return; - _poorConnectionEntry = VideoCallTopOverlay.insertBanner( - context, - message: S.of(context).video_call_poor_connection, - backgroundColor: appTheme.strongRed, - svgAsset: VideoCallTopOverlay.poorConnectionSvg, + unawaited( + _cubit.onCallScreenVisible( + conversationUuid: widget.conversationUuid, + counterpartName: widget.counterpartName, + localParticipantName: widget.localParticipantName, + ), ); - } else { - final e = _poorConnectionEntry; - if (e != null && e.mounted) { - e.remove(); - } - _poorConnectionEntry = null; - } - } - - void _maybeShowMutedMicOverlay() { - if (!mounted || _micEnabled) return; - final now = DateTime.now(); - if (_lastMutedMicOverlayAt != null && now.difference(_lastMutedMicOverlayAt!) < _mutedMicOverlayMinInterval) { - return; - } - _lastMutedMicOverlayAt = now; - VideoCallTopOverlay.insertBanner( - context, - message: S.of(context).video_call_muted_mic_warning, - backgroundColor: appTheme.videoCallNoticeBannerBackground, - svgAsset: VideoCallTopOverlay.warningSvg, - autoRemoveAfter: const Duration(seconds: 2), - ); - } - - Future _pollMutedMicFromWebRtcStats() async { - if (!mounted || _liveKitDisposed || _micEnabled) return; - if (_room.connectionState != lk.ConnectionState.connected) return; - final lp = _room.localParticipant; - if (lp == null) return; - final pub = lp.getTrackPublicationBySource(lk.TrackSource.microphone); - final track = pub?.track; - if (track is! lk.LocalAudioTrack) return; - if (!track.muted) return; - try { - final stats = await track.getSenderStats(); - if (!mounted || _liveKitDisposed || _micEnabled) return; - final src = stats?.audioSourceStats; - if (src == null) return; - - final level = src.audioLevel; - if (level != null && level >= _mutedMicAudioLevelThreshold) { - _maybeShowMutedMicOverlay(); - _lastMutedMicTotalEnergy = src.totalAudioEnergy; - return; - } - - final energy = src.totalAudioEnergy; - if (energy != null) { - final prev = _lastMutedMicTotalEnergy; - if (prev != null && energy > prev + _mutedMicEnergyDelta) { - _maybeShowMutedMicOverlay(); - } - _lastMutedMicTotalEnergy = energy; - } - } catch (_) {} - } - - Future _applyVideoCallSystemUi() async { - try { - await SystemChrome.setPreferredOrientations(const [ - DeviceOrientation.portraitUp, - DeviceOrientation.portraitDown, - DeviceOrientation.landscapeLeft, - DeviceOrientation.landscapeRight, - ]); - if (!mounted) return; - await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); - } on PlatformException catch (_) { - if (!mounted) return; - await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); - } - } - - Future _restoreSystemUiAfterCall() async { - try { - await SystemChrome.setPreferredOrientations(const []); - await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values); - } on PlatformException catch (_) {} - } - - Future _connectLiveKit() async { - setState(() => _connectError = null); - try { - final tokenResponse = await _videoCallRepository.getParticipantToken(widget.conversationUuid); - if (!mounted) return; - - var micOn = _micEnabled; - var videoOn = _videoEnabled; - if (micOn) { - final granted = await _ensureMicrophonePermission(); - if (!mounted) return; - if (!granted) micOn = false; - } - if (videoOn) { - final granted = await _ensureCameraPermission(); - if (!mounted) return; - if (!granted) videoOn = false; - } - if (micOn != _micEnabled || videoOn != _videoEnabled) { - setState(() { - _micEnabled = micOn; - _videoEnabled = videoOn; - }); - } - - await _room.connect(tokenResponse.wsUrl, tokenResponse.token); - if (!mounted || _liveKitDisposed) { - return; - } - - var effectiveMic = micOn; - var effectiveVideo = videoOn; - final lp = _room.localParticipant; - - if (micOn) { - try { - await lp?.setMicrophoneEnabled(true, audioCaptureOptions: _micCaptureOptions); - } catch (_) { - effectiveMic = false; - } - } - if (!mounted || _liveKitDisposed) return; - - if (videoOn) { - try { - await lp?.setCameraEnabled(true, cameraCaptureOptions: _cameraCaptureOptions()); - } catch (_) { - effectiveVideo = false; - } - } - - if (effectiveMic != _micEnabled || effectiveVideo != _videoEnabled) { - setState(() { - _micEnabled = effectiveMic; - _videoEnabled = effectiveVideo; - }); - } - - if (mounted) { - _syncPoorConnectionOverlay(_room.localParticipant?.connectionQuality ?? lk.ConnectionQuality.unknown); - } - } catch (e) { - if (mounted) setState(() => _connectError = e.toString()); - } - } - - Future _disconnectLiveKitOnly() async { - if (_liveKitDisposed) return; - _liveKitDisposed = true; - try { - if (_room.connectionState != lk.ConnectionState.disconnected) { - await _room.disconnect(); - } - } catch (_) {} - try { - await _room.dispose(); - } catch (_) {} - } - - Future _notifyCallEndedOnce() async { - if (_callEndedNotified) return; - _callEndedNotified = true; - try { - await _videoCallRepository.notifyCallEnded(widget.conversationUuid); - } catch (_) {} - } - - Future _cleanupLiveKitAndNotifyBackend() async { - await _disconnectLiveKitOnly(); - await _notifyCallEndedOnce(); + }); } @override void dispose() { - _mutedMicPollTimer?.cancel(); - final listener = _roomEventsListener; - _roomEventsListener = null; - if (listener != null) { - unawaited(listener.dispose()); - } - final poor = _poorConnectionEntry; - _poorConnectionEntry = null; - if (poor != null && poor.mounted) { - poor.remove(); - } - unawaited(_cleanupLiveKitAndNotifyBackend()); - unawaited(_restoreSystemUiAfterCall()); + _cubit.onCallScreenDisposed(); super.dispose(); } Future _handleBackToChat() async { widget.onBackToChat?.call(); - await _cleanupLiveKitAndNotifyBackend(); + await _cubit.leaveCallRoute(); if (!mounted) return; if (context.canPop()) context.pop(); } Future _handleHangUp() async { widget.onHangUp?.call(); - await _cleanupLiveKitAndNotifyBackend(); + await _cubit.endCall(); if (!mounted) return; if (context.canPop()) context.pop(); } - Future _toggleMic() async { - if (_liveKitDisposed || _room.connectionState != lk.ConnectionState.connected) { - setState(() => _micEnabled = !_micEnabled); - _lastMutedMicTotalEnergy = null; - return; - } - final next = !_micEnabled; - if (next) { - final granted = await _ensureMicrophonePermission(); - if (!mounted) return; - if (!granted) return; - } - setState(() => _micEnabled = next); - _lastMutedMicTotalEnergy = null; - await _room.localParticipant?.setMicrophoneEnabled(next, audioCaptureOptions: _micCaptureOptions); - } - - Future _toggleVideo() async { - if (_liveKitDisposed || _room.connectionState != lk.ConnectionState.connected) { - setState(() => _videoEnabled = !_videoEnabled); - return; - } - final next = !_videoEnabled; - if (next) { - final granted = await _ensureCameraPermission(); - if (!mounted) return; - if (!granted) return; - } - setState(() => _videoEnabled = next); - final lp = _room.localParticipant; - if (lp == null) return; - if (next) { - await lp.setCameraEnabled(true, cameraCaptureOptions: _cameraCaptureOptions()); - } else { - await lp.setCameraEnabled(false); - } - } - - Future _switchCameraFacing() async { - if (_liveKitDisposed || _room.connectionState != lk.ConnectionState.connected) { - setState(() => _useFrontCamera = !_useFrontCamera); - return; - } - if (!_videoEnabled) return; - - final lp = _room.localParticipant; - if (lp == null) return; - - final currentPosition = _useFrontCamera ? lk.CameraPosition.front : lk.CameraPosition.back; - final newPosition = currentPosition.switched(); - - final pub = lp.getTrackPublicationBySource(lk.TrackSource.camera); - final track = pub?.track; - if (track is lk.LocalVideoTrack) { - try { - await track.setCameraPosition(newPosition); - if (mounted) { - setState(() => _useFrontCamera = newPosition == lk.CameraPosition.front); - } - } catch (_) {} - return; - } - - try { - await lp.setCameraEnabled(true, cameraCaptureOptions: _cameraCaptureOptions(cameraPosition: newPosition)); - if (mounted) { - setState(() => _useFrontCamera = newPosition == lk.CameraPosition.front); - } - } catch (_) {} - } - - bool _permissionNeedsSettings(PermissionStatus status) => status.isPermanentlyDenied || status.isRestricted; - - Future _ensureCameraPermission() async { - var status = await Permission.camera.status; - if (status.isGranted) return true; - status = await Permission.camera.request(); - if (status.isGranted) return true; - if (!mounted) return false; - final s = S.of(context); - await showErrorDialog( - context, - title: s.video_call_permission_camera_title, - description: s.video_call_permission_camera_description, - buttonTitle: s.allowAccess, - needToOpenSettings: _permissionNeedsSettings(status), - ); - return false; - } - - Future _ensureMicrophonePermission() async { - var status = await Permission.microphone.status; - if (status.isGranted) return true; - status = await Permission.microphone.request(); - if (status.isGranted) return true; - if (!mounted) return false; - final s = S.of(context); - await showErrorDialog( - context, - title: s.video_call_permission_microphone_title, - description: s.video_call_permission_microphone_description, - buttonTitle: s.allowAccess, - needToOpenSettings: _permissionNeedsSettings(status), - ); - return false; - } - - String _resolvedLocalDisplayName(BuildContext context) { - final fromNav = widget.localParticipantName?.trim(); - if (fromNav != null && fromNav.isNotEmpty) return fromNav; - final n = (_room.localParticipant?.name ?? '').trim(); - if (n.isNotEmpty) return n; - return S.of(context).video_call_you; - } - - String _resolvedRemoteDisplayName(BuildContext context, String fallback) { - for (final p in _room.remoteParticipants.values) { - final n = p.name.trim(); - if (n.isNotEmpty) return n; - } - final c = widget.counterpartName?.trim(); - if (c != null && c.isNotEmpty) return c; - return fallback; - } - @override Widget build(BuildContext context) { final s = S.of(context); @@ -477,54 +95,179 @@ class _VideoCallScreenState extends State { final bottomInset = viewPadding.bottom; final counterpartFallback = s.video_call_counterpart_default; - final controlLabelStyle = TextStyles.textSMedium.copyWith(color: appTheme.whiteA700, height: 20 / 14); + final controlLabelStyle = TextStyles.textSMedium.copyWith( + color: appTheme.whiteA700, + height: 20 / 14, + ); - return AnnotatedRegion( - value: const SystemUiOverlayStyle( - statusBarColor: Colors.transparent, - statusBarIconBrightness: Brightness.light, - statusBarBrightness: Brightness.dark, - systemNavigationBarColor: Colors.transparent, - systemNavigationBarIconBrightness: Brightness.light, - systemNavigationBarContrastEnforced: false, - ), - child: Scaffold( - backgroundColor: appTheme.whiteA700, - body: ListenableBuilder( - listenable: _room, - builder: (context, _) { - return VideoCallStageLayout( - room: _room, - connectError: _connectError, - localDisplayName: _resolvedLocalDisplayName(context), - remoteDisplayName: _resolvedRemoteDisplayName(context, counterpartFallback), - micEnabled: _micEnabled, - videoEnabled: _videoEnabled, - splitParticipantView: _splitParticipantView, - mainStageShowsLocalVideo: _mainStageShowsLocalVideo, - useFrontCamera: _useFrontCamera, - onExpandLocal: () => setState(() { - _splitParticipantView = false; - _mainStageShowsLocalVideo = true; - }), - onExpandRemote: () => setState(() { - _splitParticipantView = false; - _mainStageShowsLocalVideo = false; - }), - onShrinkFromFullscreen: () => setState(() => _splitParticipantView = true), - onToggleStageSwap: () => setState(() => _mainStageShowsLocalVideo = !_mainStageShowsLocalVideo), - onChat: () => unawaited(_handleBackToChat()), - onToggleMic: () => unawaited(_toggleMic()), - onToggleVideo: () => unawaited(_toggleVideo()), - onHangUp: () => unawaited(_handleHangUp()), - onSwitchCamera: () => unawaited(_switchCameraFacing()), - controlLabelStyle: controlLabelStyle, - labels: (chat: s.video_call_chat, sound: s.video_call_sound, video: s.video_call_video, leave: s.video_call_leave), - topInset: topInset, - bottomInset: bottomInset, + return PopScope( + canPop: false, + onPopInvokedWithResult: (bool didPop, Object? result) async { + if (didPop) return; + await _cubit.leaveCallRoute(); + if (!context.mounted) return; + if (context.canPop()) context.pop(); + }, + child: BlocBuilder( + bloc: _cubit, + buildWhen: (a, b) => + a.connectPhase != b.connectPhase || + a.connectError != b.connectError || + a.linkIssue != b.linkIssue || + a.counterpartName != b.counterpartName || + a.callStageEntered != b.callStageEntered || + a.micEnabled != b.micEnabled || + a.videoEnabled != b.videoEnabled || + a.splitParticipantView != b.splitParticipantView || + a.mainStageShowsLocalVideo != b.mainStageShowsLocalVideo || + a.useFrontCamera != b.useFrontCamera, + builder: (context, sessionState) { + final room = _cubit.liveKitRoom; + + final showLost = + sessionState.linkIssue == VideoCallLinkIssue.lost || + (sessionState.connectPhase == VideoCallConnectPhase.idle && + sessionState.connectError != null); + + if (showLost) { + return AnnotatedRegion( + value: _videoCallOverlayStyle, + child: Scaffold( + backgroundColor: appTheme.whiteA700, + body: VideoCallConnectionLostScreen( + detailMessage: sessionState.connectError, + onRetry: () => unawaited(_cubit.retryAfterConnectionLoss()), + onLeave: () => unawaited(_handleHangUp()), + ), + ), ); - }, - ), + } + + final showReconnecting = + sessionState.connectPhase == VideoCallConnectPhase.connected && + sessionState.linkIssue == VideoCallLinkIssue.reconnecting; + + if (showReconnecting) { + return AnnotatedRegion( + value: _videoCallOverlayStyle, + child: Scaffold( + backgroundColor: appTheme.whiteA700, + body: const VideoCallConnectingScreen(isReconnecting: true), + ), + ); + } + + final showEntranceLobby = + !sessionState.callStageEntered && + sessionState.linkIssue == VideoCallLinkIssue.none && + (sessionState.connectPhase == VideoCallConnectPhase.connecting || + sessionState.connectPhase == VideoCallConnectPhase.connected); + + if (showEntranceLobby) { + final remoteName = _cubit.resolvedRemoteDisplayName( + counterpartFallback, + ); + final canEnterCall = + sessionState.connectPhase == VideoCallConnectPhase.connected; + return AnnotatedRegion( + value: _videoCallOverlayStyle, + child: Scaffold( + backgroundColor: appTheme.whiteA700, + body: VideoCallConnectingEntranceScreen( + counterpartDisplayName: remoteName, + counterpartAvatarUrl: widget.counterpartAvatarUrl, + micEnabled: sessionState.micEnabled, + videoEnabled: sessionState.videoEnabled, + canEnterCall: canEnterCall, + onEnterCall: _cubit.enterCallStage, + labels: ( + chat: s.video_call_chat, + sound: s.video_call_sound, + video: s.video_call_video, + leave: s.video_call_leave, + ), + controlLabelStyle: controlLabelStyle, + onToggleMic: () => unawaited(_cubit.toggleMic()), + onToggleVideo: () => unawaited(_cubit.toggleVideo()), + onHangUp: () => unawaited(_handleHangUp()), + bottomInset: bottomInset, + ), + ), + ); + } + + if (room == null && + sessionState.linkIssue == VideoCallLinkIssue.none && + sessionState.connectError == null) { + return AnnotatedRegion( + value: _videoCallOverlayStyle, + child: Scaffold( + backgroundColor: appTheme.whiteA700, + body: VideoCallLoadingScreen( + onClose: () => unawaited(_handleHangUp()), + ), + ), + ); + } + + final activeRoom = room; + if (activeRoom == null) { + return AnnotatedRegion( + value: _videoCallOverlayStyle, + child: Scaffold( + backgroundColor: appTheme.whiteA700, + body: VideoCallLoadingScreen( + onClose: () => unawaited(_handleHangUp()), + ), + ), + ); + } + + return AnnotatedRegion( + value: _videoCallOverlayStyle, + child: Scaffold( + backgroundColor: appTheme.whiteA700, + body: ListenableBuilder( + listenable: activeRoom, + builder: (context, _) { + return VideoCallStageLayout( + room: activeRoom, + connectError: sessionState.connectError, + localDisplayName: _cubit.resolvedLocalDisplayName(), + remoteDisplayName: _cubit.resolvedRemoteDisplayName( + counterpartFallback, + ), + micEnabled: sessionState.micEnabled, + videoEnabled: sessionState.videoEnabled, + splitParticipantView: sessionState.splitParticipantView, + mainStageShowsLocalVideo: + sessionState.mainStageShowsLocalVideo, + useFrontCamera: sessionState.useFrontCamera, + onExpandLocal: () => _cubit.expandLocal(), + onExpandRemote: () => _cubit.expandRemote(), + onShrinkFromFullscreen: () => _cubit.shrinkToSplit(), + onToggleStageSwap: () => _cubit.toggleStageSwap(), + onChat: () => unawaited(_handleBackToChat()), + onToggleMic: () => unawaited(_cubit.toggleMic()), + onToggleVideo: () => unawaited(_cubit.toggleVideo()), + onHangUp: () => unawaited(_handleHangUp()), + onSwitchCamera: () => + unawaited(_cubit.switchCameraFacing()), + controlLabelStyle: controlLabelStyle, + labels: ( + chat: s.video_call_chat, + sound: s.video_call_sound, + video: s.video_call_video, + leave: s.video_call_leave, + ), + topInset: topInset, + bottomInset: bottomInset, + ); + }, + ), + ), + ); + }, ), ); } diff --git a/lib/features/video_calls/presentation/widgets/video_call_connecting_entrance_screen.dart b/lib/features/video_calls/presentation/widgets/video_call_connecting_entrance_screen.dart new file mode 100644 index 00000000..350e02e0 --- /dev/null +++ b/lib/features/video_calls/presentation/widgets/video_call_connecting_entrance_screen.dart @@ -0,0 +1,340 @@ +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_controls.dart'; +import 'package:eatvio_app/generated/l10n.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +class VideoCallConnectingEntranceScreen extends StatelessWidget { + const VideoCallConnectingEntranceScreen({ + super.key, + required this.counterpartDisplayName, + this.counterpartAvatarUrl, + required this.micEnabled, + required this.videoEnabled, + required this.canEnterCall, + required this.onEnterCall, + required this.labels, + required this.controlLabelStyle, + required this.onToggleMic, + required this.onToggleVideo, + required this.onHangUp, + required this.bottomInset, + }); + + final String counterpartDisplayName; + final String? counterpartAvatarUrl; + final bool micEnabled; + final bool videoEnabled; + final bool canEnterCall; + final VoidCallback onEnterCall; + final VideoCallControlLabels labels; + final TextStyle controlLabelStyle; + final VoidCallback onToggleMic; + final VoidCallback onToggleVideo; + final VoidCallback onHangUp; + final double bottomInset; + + static const double _bottomPanelHeight = 132; + static const double _topContentSpacing = 60; + + String _firstChar(String s) { + if (s.isEmpty) return ''; + return String.fromCharCode(s.runes.first); + } + + String _initials(String name) { + final trimmed = name.trim(); + if (trimmed.isEmpty) return '?'; + final parts = trimmed.split(RegExp(r'\s+')); + if (parts.length >= 2) { + final a = _firstChar(parts[0]); + final b = _firstChar(parts[1]); + if (a.isNotEmpty && b.isNotEmpty) { + return (a + b).toUpperCase(); + } + } + final single = parts[0]; + final runes = single.runes.toList(); + if (runes.length >= 2) { + return String.fromCharCodes([runes[0], runes[1]]).toUpperCase(); + } + final c = _firstChar(single); + return c.isNotEmpty ? c.toUpperCase() : '?'; + } + + Widget _initialsPlaceholder(String initials) { + return ColoredBox( + color: appTheme.backgroundAvatar, + child: Center( + child: Text( + initials, + style: TextStyles.textFS24FW700.copyWith( + color: appTheme.blueA400, + fontWeight: FontWeight.w600, + fontSize: 28, + ), + ), + ), + ); + } + + Widget _avatar(BuildContext context, String name, String? urlRaw) { + final url = urlRaw?.trim(); + final initials = _initials(name); + final border = Border.all(color: appTheme.blueA400, width: 1); + if (url != null && url.isNotEmpty) { + return Container( + width: 100, + height: 100, + decoration: BoxDecoration(shape: BoxShape.circle, border: border), + child: ClipOval( + child: Image.network( + url, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + _initialsPlaceholder(initials), + ), + ), + ); + } + return Container( + width: 100, + height: 100, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: appTheme.backgroundAvatar, + border: border, + ), + alignment: Alignment.center, + child: Text( + initials, + style: TextStyles.textFS24FW700.copyWith( + color: appTheme.blueA400, + fontWeight: FontWeight.w600, + fontSize: 28, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final s = S.of(context); + return ColoredBox( + color: appTheme.whiteA700, + child: Stack( + fit: StackFit.expand, + children: [ + SafeArea( + child: Padding( + padding: EdgeInsets.only( + bottom: _bottomPanelHeight + bottomInset, + ), + child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: _topContentSpacing), + _avatar(context, counterpartDisplayName, counterpartAvatarUrl), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: appTheme.greenA700, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Text( + s.video_call_status_online, + style: CustomTextStyles.bodySmallMulishBlack900 + .copyWith(height: 14 / 12), + ), + ], + ), + const SizedBox(height: 20), + Text( + s.video_call_screen_connecting_title, + textAlign: TextAlign.center, + style: CustomTextStyles.videoCallFlowPlaceholderTitle, + ), + const SizedBox(height: 16), + Text( + s.video_call_recording_notice, + textAlign: TextAlign.center, + style: CustomTextStyles.videoCallFlowPlaceholderConnectingSubtitle, + ), + const SizedBox(height: 8), + Text( + counterpartDisplayName, + textAlign: TextAlign.center, + style: + CustomTextStyles.videoCallFlowPlaceholderConnectingSubtitle + .copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 24), + ], + ), + ), + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + height: _bottomPanelHeight + bottomInset, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0), + Colors.black.withValues(alpha: 0.52), + ], + ), + ), + child: Padding( + padding: EdgeInsets.fromLTRB(12, 16, 12, 12 + bottomInset), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Center( + child: IgnorePointer( + ignoring: !canEnterCall, + child: Opacity( + opacity: canEnterCall ? 1 : 0.45, + child: VideoCallControlTile( + label: s.video_call_enter, + labelStyle: controlLabelStyle, + onTap: onEnterCall, + face: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SizedBox( + width: 48, + height: 48, + child: ColoredBox( + color: appTheme.greenA700, + child: Center( + child: SvgPicture.asset( + 'assets/icons/video_call/vc_control_enter.svg', + width: 28, + height: 28, + fit: BoxFit.contain, + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + Expanded( + child: Center( + child: VideoCallControlTile( + label: labels.sound, + labelStyle: controlLabelStyle, + onTap: onToggleMic, + face: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SizedBox( + width: 48, + height: 48, + child: micEnabled + ? ColoredBox( + color: appTheme.blueA400, + child: Center( + child: SvgPicture.asset( + 'assets/icons/video_call/vc_mic_on.svg', + width: 28, + height: 28, + ), + ), + ) + : ColoredBox( + color: appTheme.videoCallControlOffBackground, + child: Center( + child: SvgPicture.asset( + 'assets/icons/video_call/vc_mic_off.svg', + width: 28, + height: 28, + ), + ), + ), + ), + ), + ), + ), + ), + Expanded( + child: Center( + child: VideoCallControlTile( + label: labels.video, + labelStyle: controlLabelStyle, + onTap: onToggleVideo, + face: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SizedBox( + width: 48, + height: 48, + child: videoEnabled + ? Image.asset( + 'assets/icons/video_call/vc_control_video.png', + fit: BoxFit.cover, + ) + : ColoredBox( + color: appTheme.videoCallControlOffBackground, + child: Center( + child: SvgPicture.asset( + 'assets/icons/video_call/vc_cam_off.svg', + width: 28, + height: 28, + ), + ), + ), + ), + ), + ), + ), + ), + Expanded( + child: Center( + child: VideoCallControlTile( + label: labels.leave, + labelStyle: controlLabelStyle, + onTap: onHangUp, + face: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SizedBox( + width: 48, + height: 48, + child: Image.asset( + 'assets/icons/video_call/vc_control_leave.png', + fit: BoxFit.cover, + alignment: const Alignment(0, -0.82), + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/video_calls/presentation/widgets/video_call_flow_placeholder_screens.dart b/lib/features/video_calls/presentation/widgets/video_call_flow_placeholder_screens.dart new file mode 100644 index 00000000..1207d56b --- /dev/null +++ b/lib/features/video_calls/presentation/widgets/video_call_flow_placeholder_screens.dart @@ -0,0 +1,305 @@ +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/generated/l10n.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +const double _kVideoCallFlowBottomBarHeight = 132; + +Widget _videoCallFlowBottomBarGradient({ + required double bottomInset, + required Widget child, +}) { + return Positioned( + left: 0, + right: 0, + bottom: 0, + height: _kVideoCallFlowBottomBarHeight + bottomInset, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0), + Colors.black.withValues(alpha: 0.52), + ], + ), + ), + child: Padding( + padding: EdgeInsets.fromLTRB(24, 24, 24, 24 + bottomInset), + child: child, + ), + ), + ); +} + +ButtonStyle _videoCallFlowBarButtonStyle({required Color backgroundColor}) { + return FilledButton.styleFrom( + backgroundColor: backgroundColor, + foregroundColor: appTheme.whiteA700, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + minimumSize: const Size(0, 48), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ); +} + +class _VideoCallFlowScaffold extends StatelessWidget { + const _VideoCallFlowScaffold({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: appTheme.whiteA700, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: Column( + children: [ + const SizedBox(height: 40), + Image.asset( + 'assets/icons/video_call/eatvio_c.png', + width: 88, + height: 88, + fit: BoxFit.contain, + ), + const SizedBox(height: 32), + Expanded(child: Center(child: child)), + ], + ), + ), + ), + ); + } +} + +class VideoCallLoadingScreen extends StatelessWidget { + const VideoCallLoadingScreen({super.key, required this.onClose}); + + final VoidCallback onClose; + + @override + Widget build(BuildContext context) { + final s = S.of(context); + final bottomInset = MediaQuery.viewPaddingOf(context).bottom; + return ColoredBox( + color: appTheme.whiteA700, + child: Stack( + fit: StackFit.expand, + children: [ + SafeArea( + bottom: false, + child: Padding( + padding: EdgeInsets.only( + bottom: _kVideoCallFlowBottomBarHeight + bottomInset, + ), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 40, + height: 40, + child: CircularProgressIndicator( + strokeWidth: 3, + color: appTheme.blueA400, + ), + ), + const SizedBox(height: 28), + Text( + s.video_call_screen_loading_title, + textAlign: TextAlign.center, + style: CustomTextStyles.videoCallFlowPlaceholderTitle, + ), + ], + ), + ), + ), + ), + _videoCallFlowBottomBarGradient( + bottomInset: bottomInset, + child: Align( + alignment: Alignment.bottomCenter, + child: FilledButton.icon( + onPressed: onClose, + style: _videoCallFlowBarButtonStyle( + backgroundColor: appTheme.videoCallControlOffBackground, + ), + icon: Icon( + Icons.close, + size: 18, + color: appTheme.whiteA700, + ), + label: Text( + s.video_call_close, + style: CustomTextStyles.videoCallFlowPlaceholderFilledButtonLabel, + ), + ), + ), + ), + ], + ), + ); + } +} + +class VideoCallConnectingScreen extends StatelessWidget { + const VideoCallConnectingScreen({super.key, required this.isReconnecting}); + + final bool isReconnecting; + + @override + Widget build(BuildContext context) { + final s = S.of(context); + final title = isReconnecting + ? s.video_call_screen_reconnecting_title + : s.video_call_screen_connecting_title; + final subtitle = isReconnecting + ? s.video_call_screen_reconnecting_subtitle + : s.video_call_screen_connecting_subtitle; + return _VideoCallFlowScaffold( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 44, + height: 44, + child: CircularProgressIndicator( + strokeWidth: 3, + color: appTheme.blueA400, + ), + ), + const SizedBox(height: 28), + Text( + title, + textAlign: TextAlign.center, + style: CustomTextStyles.videoCallFlowPlaceholderTitle, + ), + const SizedBox(height: 8), + Text( + subtitle, + textAlign: TextAlign.center, + style: isReconnecting + ? CustomTextStyles.videoCallFlowPlaceholderReconnectingSubtitle + : CustomTextStyles.videoCallFlowPlaceholderConnectingSubtitle, + ), + ], + ), + ); + } +} + +class VideoCallConnectionLostScreen extends StatelessWidget { + const VideoCallConnectionLostScreen({ + super.key, + required this.detailMessage, + required this.onRetry, + required this.onLeave, + }); + + final String? detailMessage; + final VoidCallback onRetry; + final VoidCallback onLeave; + + @override + Widget build(BuildContext context) { + final s = S.of(context); + final bottomInset = MediaQuery.viewPaddingOf(context).bottom; + return ColoredBox( + color: appTheme.whiteA700, + child: Stack( + fit: StackFit.expand, + children: [ + SafeArea( + bottom: false, + child: Padding( + padding: EdgeInsets.only( + left: 24, + right: 24, + bottom: _kVideoCallFlowBottomBarHeight + bottomInset, + ), + child: Center( + child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SvgPicture.asset( + 'assets/icons/video_call/poor_connection.svg', + width: 120, + height: 107, + fit: BoxFit.contain, + ), + const SizedBox(height: 16), + Text( + s.video_call_connection_lost_title, + textAlign: TextAlign.center, + style: CustomTextStyles.videoCallFlowPlaceholderTitle, + ), + const SizedBox(height: 8), + Text( + s.video_call_connection_lost_message, + textAlign: TextAlign.center, + style: CustomTextStyles + .videoCallFlowPlaceholderConnectionLostBody, + ), + if (detailMessage != null && + detailMessage!.trim().isNotEmpty) ...[ + const SizedBox(height: 16), + Text( + detailMessage!, + textAlign: TextAlign.center, + style: CustomTextStyles + .videoCallFlowPlaceholderConnectionLostDetail, + ), + ], + ], + ), + ), + ), + ), + ), + _videoCallFlowBottomBarGradient( + bottomInset: bottomInset, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + FilledButton( + onPressed: onRetry, + style: _videoCallFlowBarButtonStyle( + backgroundColor: appTheme.greenA700, + ), + child: Text( + s.video_call_try_again, + style: CustomTextStyles.videoCallFlowPlaceholderFilledButtonLabel, + ), + ), + FilledButton.icon( + onPressed: onLeave, + style: _videoCallFlowBarButtonStyle( + backgroundColor: appTheme.videoCallControlOffBackground, + ), + icon: Icon( + Icons.close, + size: 18, + color: appTheme.whiteA700, + ), + label: Text( + s.video_call_close, + style: CustomTextStyles.videoCallFlowPlaceholderFilledButtonLabel, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart b/lib/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart new file mode 100644 index 00000000..5db968ec --- /dev/null +++ b/lib/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart @@ -0,0 +1,167 @@ +import 'dart:math' as math; + +import 'package:eatvio_app/core/app_router.dart'; +import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; +import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; +import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_no_video_background.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:livekit_client/livekit_client.dart' as lk; + +class VideoCallGlobalPipLayer extends StatefulWidget { + const VideoCallGlobalPipLayer({super.key}); + + @override + State createState() => + _VideoCallGlobalPipLayerState(); +} + +class _VideoCallGlobalPipLayerState extends State { + static const double _pipW = 142; + static const double _pipH = 201; + static const double _edgeInset = 8; + static const double _pipInitialExtraLeft = 28; + static const double _pipInitialExtraDown = 16; + + double? _pipLeft; + double? _pipTop; + Offset? _pipPanPointerGlobal; + double? _pipPanOriginLeft; + double? _pipPanOriginTop; + + void _resetPipPanState() { + _pipPanPointerGlobal = null; + _pipPanOriginLeft = null; + _pipPanOriginTop = null; + } + + void _openFullscreenRoute(VideoCallSessionState s) { + final ctx = AppRouter.navigatorKey.currentContext; + if (ctx == null) return; + GoRouter.of(ctx).pushNamed( + VideoCallScreen.name, + extra: { + 'conversationUuid': s.conversationUuid, + 'counterpartName': s.counterpartName, + 'localParticipantName': s.localParticipantName, + }, + ); + } + + Widget _pipPreview(VideoCallSessionState sessionState, lk.Room room) { + if (!sessionState.videoEnabled) { + return const VideoCallNoVideoBackground(logoSize: 56); + } + final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room); + if (local != null) { + return IgnorePointer( + child: lk.VideoTrackRenderer( + local, + fit: lk.VideoViewFit.cover, + mirrorMode: lk.VideoViewMirrorMode.auto, + ), + ); + } + return const VideoCallNoVideoBackground(logoSize: 56); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (a, b) => + a.uiMode != b.uiMode || + a.connectPhase != b.connectPhase || + a.callStageEntered != b.callStageEntered || + a.videoEnabled != b.videoEnabled, + builder: (context, state) { + if (state.uiMode != VideoCallUiMode.pip || + state.connectPhase != VideoCallConnectPhase.connected || + !state.callStageEntered) { + return const SizedBox.shrink(); + } + final cubit = context.read(); + final room = cubit.liveKitRoom; + if (room == null) return const SizedBox.shrink(); + + final mq = MediaQuery.of(context); + final maxW = mq.size.width; + final maxH = mq.size.height; + final topInset = mq.viewPadding.top > 0 ? mq.viewPadding.top : 16.0; + final bottomInset = mq.viewPadding.bottom; + final defaultPipLeft = maxW - _pipW - _edgeInset - _pipInitialExtraLeft; + final minPipTop = topInset + _edgeInset; + final defaultPipTop = minPipTop + _pipInitialExtraDown; + final maxPipLeft = math.max(_edgeInset, maxW - _pipW - _edgeInset); + final maxPipTop = math.max( + minPipTop, + maxH - _pipH - _edgeInset - bottomInset, + ); + final pipLeft = + ((_pipLeft ?? defaultPipLeft).clamp(_edgeInset, maxPipLeft) as num) + .toDouble(); + final pipTop = + ((_pipTop ?? defaultPipTop).clamp(minPipTop, maxPipTop) as num) + .toDouble(); + + return Material( + type: MaterialType.transparency, + child: ListenableBuilder( + listenable: room, + builder: (context, _) { + return Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: pipLeft, + top: pipTop, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _openFullscreenRoute(state), + onPanStart: (details) { + _pipPanPointerGlobal = details.globalPosition; + _pipPanOriginLeft = pipLeft; + _pipPanOriginTop = pipTop; + }, + onPanUpdate: (details) { + if (_pipPanPointerGlobal == null || + _pipPanOriginLeft == null || + _pipPanOriginTop == null) { + return; + } + final delta = + details.globalPosition - _pipPanPointerGlobal!; + setState(() { + final nx = (_pipPanOriginLeft! + delta.dx) + .clamp(_edgeInset, maxPipLeft); + final ny = (_pipPanOriginTop! + delta.dy).clamp( + minPipTop, + maxPipTop, + ); + _pipLeft = (nx as num).toDouble(); + _pipTop = (ny as num).toDouble(); + }); + }, + onPanEnd: (_) => _resetPipPanState(), + onPanCancel: _resetPipPanState, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SizedBox( + width: _pipW, + height: _pipH, + child: _pipPreview(state, room), + ), + ), + ), + ), + ], + ); + }, + ), + ); + }, + ); + } +} diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index 096562d6..f4c23b3e 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -875,9 +875,17 @@ class MessageLookup extends MessageLookupByLibrary { ), "video_call": MessageLookupByLibrary.simpleMessage("Video call"), "video_call_chat": MessageLookupByLibrary.simpleMessage("Chat"), + "video_call_close": MessageLookupByLibrary.simpleMessage("Close"), + "video_call_connection_lost_message": MessageLookupByLibrary.simpleMessage( + "It looks like there is an internet issue. We\'re trying to restore the connection...", + ), + "video_call_connection_lost_title": MessageLookupByLibrary.simpleMessage( + "Connection lost", + ), "video_call_counterpart_default": MessageLookupByLibrary.simpleMessage( "Participant", ), + "video_call_enter": MessageLookupByLibrary.simpleMessage("Enter"), "video_call_leave": MessageLookupByLibrary.simpleMessage("Leave"), "video_call_muted_mic_warning": MessageLookupByLibrary.simpleMessage( "Attention!\nYour microphone is muted", @@ -904,7 +912,21 @@ class MessageLookup extends MessageLookupByLibrary { "video_call_role_consultant": MessageLookupByLibrary.simpleMessage( "Eatvio consultant", ), + "video_call_screen_connecting_subtitle": + MessageLookupByLibrary.simpleMessage("This may take a few seconds"), + "video_call_screen_connecting_title": MessageLookupByLibrary.simpleMessage( + "Connecting to\nthe consultation", + ), + "video_call_screen_loading_title": MessageLookupByLibrary.simpleMessage( + "Loading…", + ), + "video_call_screen_reconnecting_subtitle": + MessageLookupByLibrary.simpleMessage("Check your internet and wait"), + "video_call_screen_reconnecting_title": + MessageLookupByLibrary.simpleMessage("Reconnecting"), "video_call_sound": MessageLookupByLibrary.simpleMessage("Sound"), + "video_call_status_online": MessageLookupByLibrary.simpleMessage("Online"), + "video_call_try_again": MessageLookupByLibrary.simpleMessage("Retry"), "video_call_unknown_initial": MessageLookupByLibrary.simpleMessage("?"), "video_call_video": MessageLookupByLibrary.simpleMessage("Video"), "video_call_you": MessageLookupByLibrary.simpleMessage("You"), diff --git a/lib/generated/intl/messages_ru.dart b/lib/generated/intl/messages_ru.dart index 86adc84c..b0be5980 100644 --- a/lib/generated/intl/messages_ru.dart +++ b/lib/generated/intl/messages_ru.dart @@ -355,9 +355,17 @@ class MessageLookup extends MessageLookupByLibrary { ), "video_call": MessageLookupByLibrary.simpleMessage("Видеозвонок"), "video_call_chat": MessageLookupByLibrary.simpleMessage("Чат"), + "video_call_close": MessageLookupByLibrary.simpleMessage("Закрыть"), + "video_call_connection_lost_message": MessageLookupByLibrary.simpleMessage( + "Похоже, возникли проблемы с интернетом. Мы пытаемся восстановить соединение...", + ), + "video_call_connection_lost_title": MessageLookupByLibrary.simpleMessage( + "Связь прервалась", + ), "video_call_counterpart_default": MessageLookupByLibrary.simpleMessage( "Собеседник", ), + "video_call_enter": MessageLookupByLibrary.simpleMessage("Войти"), "video_call_leave": MessageLookupByLibrary.simpleMessage("Выйти"), "video_call_muted_mic_warning": MessageLookupByLibrary.simpleMessage( "Внимание!\nУ вас выключен микрофон", @@ -384,7 +392,21 @@ class MessageLookup extends MessageLookupByLibrary { "video_call_role_consultant": MessageLookupByLibrary.simpleMessage( "Консультант Итвио", ), + "video_call_screen_connecting_subtitle": + MessageLookupByLibrary.simpleMessage("Это займёт несколько секунд"), + "video_call_screen_connecting_title": MessageLookupByLibrary.simpleMessage( + "Подключение\nк консультации", + ), + "video_call_screen_loading_title": MessageLookupByLibrary.simpleMessage( + "Загрузка…", + ), + "video_call_screen_reconnecting_subtitle": + MessageLookupByLibrary.simpleMessage("Проверьте интернет и подождите"), + "video_call_screen_reconnecting_title": + MessageLookupByLibrary.simpleMessage("Восстанавливаем соединение"), "video_call_sound": MessageLookupByLibrary.simpleMessage("Звук"), + "video_call_status_online": MessageLookupByLibrary.simpleMessage("Онлайн"), + "video_call_try_again": MessageLookupByLibrary.simpleMessage("Повторить"), "video_call_unknown_initial": MessageLookupByLibrary.simpleMessage("?"), "video_call_video": MessageLookupByLibrary.simpleMessage("Видео"), "video_call_you": MessageLookupByLibrary.simpleMessage("Вы"), diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 3bca8ece..869a24fc 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -4079,6 +4079,15 @@ class S { ); } + String get video_call_status_online { + return Intl.message( + 'Online', + name: 'video_call_status_online', + desc: '', + args: [], + ); + } + /// `Chat` String get video_call_chat { return Intl.message('Chat', name: 'video_call_chat', desc: '', args: []); @@ -4148,6 +4157,86 @@ class S { args: [], ); } + + String get video_call_screen_loading_title { + return Intl.message( + 'Loading…', + name: 'video_call_screen_loading_title', + desc: '', + args: [], + ); + } + + String get video_call_screen_connecting_title { + return Intl.message( + 'Connecting to\nthe consultation', + name: 'video_call_screen_connecting_title', + desc: '', + args: [], + ); + } + + String get video_call_screen_connecting_subtitle { + return Intl.message( + 'This may take a few seconds', + name: 'video_call_screen_connecting_subtitle', + desc: '', + args: [], + ); + } + + String get video_call_screen_reconnecting_title { + return Intl.message( + 'Reconnecting', + name: 'video_call_screen_reconnecting_title', + desc: '', + args: [], + ); + } + + String get video_call_screen_reconnecting_subtitle { + return Intl.message( + 'Check your internet and wait', + name: 'video_call_screen_reconnecting_subtitle', + desc: '', + args: [], + ); + } + + String get video_call_connection_lost_title { + return Intl.message( + 'Connection lost', + name: 'video_call_connection_lost_title', + desc: '', + args: [], + ); + } + + String get video_call_connection_lost_message { + return Intl.message( + 'It looks like there is an internet issue. We\'re trying to restore the connection...', + name: 'video_call_connection_lost_message', + desc: '', + args: [], + ); + } + + String get video_call_try_again { + return Intl.message( + 'Retry', + name: 'video_call_try_again', + desc: '', + args: [], + ); + } + + String get video_call_enter { + return Intl.message('Enter', name: 'video_call_enter', desc: '', args: []); + } + + String get video_call_close { + return Intl.message('Close', name: 'video_call_close', desc: '', args: []); + } } class AppLocalizationDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 27c662dd..71d50fa0 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -480,6 +480,7 @@ "video_call_poor_connection": "You have a poor internet connection", "video_call_recording_notice": "The video call will be recorded.\nThe consultation may be reviewed by Eatvio staff for quality assurance.", "video_call_muted_mic_warning": "Attention!\nYour microphone is muted", + "video_call_status_online": "Online", "video_call_chat": "Chat", "video_call_sound": "Sound", "video_call_video": "Video", @@ -488,6 +489,16 @@ "video_call_permission_camera_title": "Camera access", "video_call_permission_camera_description": "Allow camera access so others can see you during the video call. If you declined, you can enable it in system settings.", "video_call_permission_microphone_title": "Microphone access", - "video_call_permission_microphone_description": "Allow microphone access so others can hear you during the video call. If you declined, you can enable it in system settings." + "video_call_permission_microphone_description": "Allow microphone access so others can hear you during the video call. If you declined, you can enable it in system settings.", + "video_call_screen_loading_title": "Loading…", + "video_call_screen_connecting_title": "Connecting to\nthe consultation", + "video_call_screen_connecting_subtitle": "This may take a few seconds", + "video_call_screen_reconnecting_title": "Reconnecting", + "video_call_screen_reconnecting_subtitle": "Check your internet and wait", + "video_call_connection_lost_title": "Connection lost", + "video_call_connection_lost_message": "It looks like there is an internet issue. We're trying to restore the connection...", + "video_call_try_again": "Retry", + "video_call_enter": "Enter", + "video_call_close": "Close" } diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index 687f142c..f9232441 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -225,6 +225,7 @@ "video_call_poor_connection": "У вас плохое интернет соединение", "video_call_recording_notice": "Видеозвонок будет записан.\nКонсультация может быть проверена сотрудниками Итвио, на предмет качества оказания услуг.", "video_call_muted_mic_warning": "Внимание!\nУ вас выключен микрофон", + "video_call_status_online": "Онлайн", "video_call_chat": "Чат", "video_call_sound": "Звук", "video_call_video": "Видео", @@ -233,5 +234,15 @@ "video_call_permission_camera_title": "Доступ к камере", "video_call_permission_camera_description": "Чтобы вас видели во время видеозвонка, разрешите доступ к камере. При отказе можно включить доступ в настройках устройства.", "video_call_permission_microphone_title": "Доступ к микрофону", - "video_call_permission_microphone_description": "Чтобы вас слышали во время видеозвонка, разрешите доступ к микрофону. При отказе можно включить доступ в настройках устройства." + "video_call_permission_microphone_description": "Чтобы вас слышали во время видеозвонка, разрешите доступ к микрофону. При отказе можно включить доступ в настройках устройства.", + "video_call_screen_loading_title": "Загрузка…", + "video_call_screen_connecting_title": "Подключение\nк консультации", + "video_call_screen_connecting_subtitle": "Это займёт несколько секунд", + "video_call_screen_reconnecting_title": "Восстанавливаем соединение", + "video_call_screen_reconnecting_subtitle": "Проверьте интернет и подождите", + "video_call_connection_lost_title": "Связь прервалась", + "video_call_connection_lost_message": "Похоже, возникли проблемы с интернетом. Мы пытаемся восстановить соединение...", + "video_call_try_again": "Повторить", + "video_call_enter": "Войти", + "video_call_close": "Закрыть" } diff --git a/lib/main.dart b/lib/main.dart index 3ab5cf6e..760b1c54 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,6 +4,7 @@ import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/notifications/push_navigation_handler.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_portal/flutter_portal.dart'; @@ -41,6 +42,15 @@ class _MyAppState extends State { debugShowCheckedModeBanner: false, localizationsDelegates: sl().localizationDelegates, supportedLocales: sl().supportedLocales, + builder: (context, child) { + return Stack( + clipBehavior: Clip.none, + children: [ + if (child != null) child, + const VideoCallGlobalPipLayer(), + ], + ); + }, ), ), ), -- 2.49.1 From 73cec1a01e1f9fecb10ef25015f479ea2fd0c720 Mon Sep 17 00:00:00 2001 From: misha Date: Mon, 25 May 2026 08:07:20 +0300 Subject: [PATCH 02/88] feat(vide_calls): gms calls handling --- android/app/proguard-rules.pro | 3 + android/app/src/main/AndroidManifest.xml | 8 + ios/Runner.xcodeproj/project.pbxproj | 6 +- ios/Runner/AppDelegate.swift | 4 + ios/Runner/Info.plist | 1 + lib/core/di/cubit_registrations.dart | 8 +- .../chat/presentation/pages/chat_screen.dart | 9 - .../managers/video_call_manager.dart | 1091 +++++++++++++++++ .../managers/video_call_manager_snapshot.dart | 59 + .../video_call_session_cubit.dart | 677 ++-------- .../video_call_session_state.dart | 10 +- .../presentation/pages/video_call_screen.dart | 11 +- ...video_call_connecting_entrance_screen.dart | 46 +- .../widgets/video_call_global_pip_layer.dart | 4 + .../video_call_livekit_track_utils.dart | 5 + .../video_call_remote_video_stage.dart | 5 +- .../widgets/video_call_stage_layout.dart | 73 ++ .../video_call_audio_session_coordinator.dart | 77 ++ .../video_call_foreground_bootstrap.dart | 35 + .../video_call_foreground_coordinator.dart | 79 ++ .../video_call_lifecycle_binding.dart | 29 + .../video_call_notification_navigator.dart | 43 + .../services/video_call_task_handler.dart | 45 + lib/generated/intl/messages_en.dart | 15 + lib/generated/intl/messages_ru.dart | 15 + lib/generated/l10n.dart | 61 + lib/l10n/intl_en.arb | 7 +- lib/l10n/intl_ru.arb | 7 +- lib/main.dart | 36 +- pubspec.yaml | 2 + ..._call_manager_audio_interruption_test.dart | 173 +++ .../video_call_manager_lifecycle_test.dart | 89 ++ .../video_call_session_cubit_test.dart | 146 +++ .../video_call_remote_video_stage_test.dart | 23 +- .../widgets/video_call_stage_layout_test.dart | 4 +- 35 files changed, 2288 insertions(+), 618 deletions(-) create mode 100644 lib/features/video_calls/managers/video_call_manager.dart create mode 100644 lib/features/video_calls/managers/video_call_manager_snapshot.dart create mode 100644 lib/features/video_calls/services/video_call_audio_session_coordinator.dart create mode 100644 lib/features/video_calls/services/video_call_foreground_bootstrap.dart create mode 100644 lib/features/video_calls/services/video_call_foreground_coordinator.dart create mode 100644 lib/features/video_calls/services/video_call_lifecycle_binding.dart create mode 100644 lib/features/video_calls/services/video_call_notification_navigator.dart create mode 100644 lib/features/video_calls/services/video_call_task_handler.dart create mode 100644 test/features/video_calls/presentation/video_call_manager_audio_interruption_test.dart create mode 100644 test/features/video_calls/presentation/video_call_manager_lifecycle_test.dart create mode 100644 test/features/video_calls/presentation/video_call_session_cubit_test.dart diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index b657fa16..b20e2682 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -4,6 +4,9 @@ -keep class org.bouncycastle.** { *; } -dontwarn org.bouncycastle.** +-keep class com.pravera.flutter_foreground_task.** { *; } +-dontwarn com.pravera.flutter_foreground_task.** + -keep class io.flutter.** { *; } -dontwarn io.flutter.** diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 116b494b..d63acff8 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -4,6 +4,9 @@ + + + @@ -77,6 +80,11 @@ + + + + + + + + + + + + + + + + + ().buildType == AppBuildType.prod) { try { await FirebaseMessaging.instance.requestPermission().timeout(const Duration(seconds: 5)); diff --git a/lib/core/notifications/push_navigation_handler.dart b/lib/core/notifications/push_navigation_handler.dart index a8d1d5d0..1c3b98a8 100644 --- a/lib/core/notifications/push_navigation_handler.dart +++ b/lib/core/notifications/push_navigation_handler.dart @@ -1,6 +1,10 @@ import 'dart:developer' as developer; +import 'dart:async'; + import 'package:eatvio_app/core/app_router.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; @@ -50,7 +54,25 @@ class PushNavigationHandler { } static void _handleMessageInternal(RemoteMessage message, {required String source}) { - final data = message.data; + final data = Map.from(message.data); + + final videoCallPayload = VideoCallPushPayload.tryParse(data); + if (videoCallPayload != null) { + developer.log( + 'PushNavigationHandler: opening incoming video call, source=$source, conversation=${videoCallPayload.conversationUuid}', + name: 'PushNavigationHandler', + ); + unawaited( + VideoCallNotificationNavigator.openIncomingCall( + conversationUuid: videoCallPayload.conversationUuid, + counterpartName: videoCallPayload.counterpartName, + counterpartAvatarUrl: videoCallPayload.counterpartAvatarUrl, + localParticipantName: videoCallPayload.localParticipantName, + ), + ); + return; + } + final actionUrl = data['action_url'] as String?; if (actionUrl == null || actionUrl.isEmpty) { developer.log('PushNavigationHandler: no action_url in message data, source=$source, data=$data', name: 'PushNavigationHandler'); diff --git a/lib/features/video_calls/managers/video_call_manager.dart b/lib/features/video_calls/managers/video_call_manager.dart index 961dcf07..87bafb3b 100644 --- a/lib/features/video_calls/managers/video_call_manager.dart +++ b/lib/features/video_calls/managers/video_call_manager.dart @@ -62,6 +62,9 @@ class VideoCallManager { bool _liveKitDisposed = true; bool _callEndedNotified = true; bool _callStageEntered = false; + bool _joinInProgress = false; + String? _preparedWsUrl; + String? _preparedToken; bool _videoEnabledBeforeBackground = false; bool _videoPausedForBackground = false; bool? _micEnabledBeforeSystemInterruption; @@ -171,18 +174,34 @@ class VideoCallManager { }) async { if (_room != null && !_liveKitDisposed && - _snapshot.conversationUuid == conversationUuid && - _room!.connectionState == lk.ConnectionState.connected) { - _emitSnapshot( - _snapshot.copyWith( - counterpartName: counterpartName ?? _snapshot.counterpartName, - localParticipantName: - localParticipantName ?? _snapshot.localParticipantName, - ), - ); - _ensureLifecycleAttached(); - unawaited(_startCallAudioSession()); - return; + _snapshot.conversationUuid == conversationUuid) { + if (_callStageEntered && + _room!.connectionState == lk.ConnectionState.connected) { + _emitSnapshot( + _snapshot.copyWith( + counterpartName: counterpartName ?? _snapshot.counterpartName, + localParticipantName: + localParticipantName ?? _snapshot.localParticipantName, + ), + ); + _ensureLifecycleAttached(); + unawaited(_startCallAudioSession()); + return; + } + if (!_callStageEntered && + _snapshot.connectPhase == VideoCallConnectPhase.connected && + _preparedWsUrl != null && + _preparedToken != null) { + _emitSnapshot( + _snapshot.copyWith( + counterpartName: counterpartName ?? _snapshot.counterpartName, + localParticipantName: + localParticipantName ?? _snapshot.localParticipantName, + ), + ); + _ensureLifecycleAttached(); + return; + } } await endCall(); @@ -217,7 +236,7 @@ class VideoCallManager { ), ); - await _connectLiveKit(); + await _prepareLiveKitConnection(); } Future onCallScreenVisible({ @@ -248,13 +267,33 @@ class VideoCallManager { } } - Future onEnterCallStage() async { - if (_callStageEntered) return; - if (_snapshot.connectPhase != VideoCallConnectPhase.connected) return; - _callStageEntered = true; - await _publishLocalMedia(); - await _startForegroundIfPossible(); - await _startCallAudioSession(); + Future onEnterCallStage() async { + if (_callStageEntered) return true; + if (_joinInProgress) return false; + if (_snapshot.connectPhase != VideoCallConnectPhase.connected) return false; + + _joinInProgress = true; + try { + await _joinLiveKitRoom(); + if (_liveKitDisposed) return false; + + _callStageEntered = true; + await _publishLocalMedia(); + if (_liveKitDisposed || !_callStageEntered) return false; + await _startForegroundIfPossible(); + await _startCallAudioSession(); + return true; + } catch (e) { + _emitSnapshot( + _snapshot.copyWith( + connectError: e.toString(), + linkIssue: VideoCallLinkIssue.lost, + ), + ); + return false; + } finally { + _joinInProgress = false; + } } Future retryAfterConnectionLoss() async { @@ -282,7 +321,7 @@ class VideoCallManager { connectError: null, ), ); - await _connectLiveKit(); + await _prepareLiveKitConnection(); } Future endCall() async { @@ -306,6 +345,25 @@ class VideoCallManager { _emitSnapshot(VideoCallManagerSnapshot.initial); } + /// Decline from incoming UI before joining the LiveKit room. + Future declineIncomingCall(String conversationUuid) async { + final uuid = conversationUuid.trim(); + if (uuid.isEmpty) { + await endCall(); + return; + } + + if (_snapshot.conversationUuid == uuid) { + await endCall(); + return; + } + + await endCall(); + try { + await _videoCallRepository.notifyCallEnded(uuid); + } catch (_) {} + } + /// [currentUiEnabled] reflects what the user sees on the mic control /// ([VideoCallSessionState.isMicActiveForUi]), not only stored intent. Future toggleMic(bool currentUiEnabled) async { @@ -550,10 +608,17 @@ class VideoCallManager { _recordingOverlayScheduled = false; _micEnabledBeforeSystemInterruption = null; _audioInterruptionHoldCount = 0; + _joinInProgress = false; + _clearPreparedConnectionCredentials(); _syncSystemInterruptionOverlay(show: false); _emitSnapshot(_snapshot.copyWith(systemAudioInterrupted: false)); } + void _clearPreparedConnectionCredentials() { + _preparedWsUrl = null; + _preparedToken = null; + } + void _disposeRoomEphemerals() { _mutedMicPollTimer?.cancel(); _mutedMicPollTimer = null; @@ -704,7 +769,7 @@ class VideoCallManager { } catch (_) {} } - Future _connectLiveKit() async { + Future _prepareLiveKitConnection() async { final room = _room; if (room == null) return; _emitSnapshot(_snapshot.copyWith(connectError: null)); @@ -714,15 +779,15 @@ class VideoCallManager { ); if (_liveKitDisposed) return; + _preparedWsUrl = tokenResponse.wsUrl; + _preparedToken = tokenResponse.token; + await room.prepareConnection( tokenResponse.wsUrl, tokenResponse.token, ); if (_liveKitDisposed) return; - await room.connect(tokenResponse.wsUrl, tokenResponse.token); - if (_liveKitDisposed) return; - _emitSnapshot( _snapshot.copyWith( connectPhase: VideoCallConnectPhase.connected, @@ -730,16 +795,8 @@ class VideoCallManager { clearConnectError: true, ), ); - _syncPoorConnectionOverlay( - room.localParticipant?.connectionQuality ?? - lk.ConnectionQuality.unknown, - ); - - if (_callStageEntered) { - await _publishLocalMedia(); - await _startCallAudioSession(); - } } catch (e) { + _clearPreparedConnectionCredentials(); _emitSnapshot( _snapshot.copyWith( connectError: e.toString(), @@ -750,6 +807,35 @@ class VideoCallManager { } } + Future _joinLiveKitRoom() async { + final room = _room; + if (room == null) { + throw StateError('VideoCallManager: room is null'); + } + if (room.connectionState == lk.ConnectionState.connected) return; + + var wsUrl = _preparedWsUrl; + var token = _preparedToken; + if (wsUrl == null || token == null) { + final tokenResponse = await _videoCallRepository.getParticipantToken( + _snapshot.conversationUuid, + ); + if (_liveKitDisposed) return; + wsUrl = tokenResponse.wsUrl; + token = tokenResponse.token; + _preparedWsUrl = wsUrl; + _preparedToken = token; + } + + await room.connect(wsUrl, token); + if (_liveKitDisposed) return; + + _syncPoorConnectionOverlay( + room.localParticipant?.connectionQuality ?? + lk.ConnectionQuality.unknown, + ); + } + Future _disconnectLiveKitOnly() async { if (_room == null) return; await _stopCallAudioSession(); diff --git a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart index 82657459..5eb112db 100644 --- a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart +++ b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart @@ -192,8 +192,11 @@ class VideoCallSessionCubit extends BaseCubit { if (isClosed) return; if (state.connectPhase != VideoCallConnectPhase.connected) return; if (state.callStageEntered) return; + + final entered = await _videoCallManager.onEnterCallStage(); + if (!entered || isClosed) return; + emit(state.copyWith(callStageEntered: true)); - await _videoCallManager.onEnterCallStage(); WidgetsBinding.instance.addPostFrameCallback((_) { if (isClosed) return; _videoCallManager.scheduleRecordingNoticeOnce(); diff --git a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart index f02c6c16..a7c38545 100644 --- a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart +++ b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart @@ -33,6 +33,5 @@ abstract class VideoCallSessionState with _$VideoCallSessionState { @Default(false) bool systemAudioInterrupted, }) = _VideoCallSessionState; - /// UI for mic control: off while GSM / system audio holds the session. bool get isMicActiveForUi => micEnabled && !systemAudioInterrupted; } 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 0ccd085d..47a310c0 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -5,10 +5,11 @@ import 'dart:io'; import 'package:eatvio_app/core/firebase_api.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart'; -import 'package:eatvio_app/features/video_calls/managers/video_call_manager_snapshot.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_fcm_handler.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_callkit_incoming/entities/call_event.dart'; import 'package:flutter_callkit_incoming/entities/call_kit_params.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; @@ -29,16 +30,33 @@ class VideoCallCallKitCoordinator { StreamSubscription? _eventSubscription; final Map _callKitParamsById = {}; + final Set _acceptHandledCallKitIds = {}; + final Set _suppressedEndCallKitIds = {}; String? _activeCallKitId; bool _initialized = false; Future initialize() async { if (_initialized) return; - if (!Platform.isIOS) return; _initialized = true; _eventSubscription = FlutterCallkitIncoming.onEvent.listen(_onCallKitEvent); - await _firebaseApi.registerPushToken(); + + if (Platform.isIOS) { + await _firebaseApi.registerPushToken(); + return; + } + + if (Platform.isAndroid) { + VideoCallFcmHandler.registerForegroundListener(); + FlutterCallkitIncoming.acceptCallHandle(_onNativeAcceptCallHandle); + await FlutterCallkitIncoming.requestNotificationPermission({ + 'title': 'Уведомления', + 'rationaleMessagePermission': + 'Разрешите уведомления, чтобы получать входящие видеозвонки.', + 'postNotificationMessageRequired': + 'Разрешите уведомления в настройках, чтобы получать входящие видеозвонки.', + }); + } } void dispose() { @@ -67,11 +85,11 @@ class VideoCallCallKitCoordinator { case CallEventActionCallCallback(:final id): await _handleAccept(id); case CallEventActionCallDecline(:final id): - await _handleEnd(id); + await _handleDecline(id); case CallEventActionCallEnded(:final id): - await _handleEnd(id); + await _handleDecline(id); case CallEventActionCallTimeout(:final id): - await _handleEnd(id); + await _handleDecline(id); default: break; } @@ -84,7 +102,41 @@ class VideoCallCallKitCoordinator { CallKitParams? _paramsForCallId(String id) => _callKitParamsById[id]; + Future _onNativeAcceptCallHandle(Map body) async { + final extra = Map.from(body); + final conversationUuid = _parseConversationUuid(extra); + if (conversationUuid.isEmpty) { + log( + 'VideoCallCallKitCoordinator: native accept without conversationUuid, extra=$extra', + name: 'VideoCallCallKit', + ); + return; + } + + await _deliverIncomingAccept( + callKitId: conversationUuid, + conversationUuid: conversationUuid, + counterpartName: _parseString(extra, const [ + 'counterpartName', + 'caller_name', + 'callerName', + ]), + counterpartAvatarUrl: _parseString(extra, const [ + 'counterpartAvatarUrl', + 'avatar', + 'avatarUrl', + 'caller_avatar', + ]), + localParticipantName: _parseString(extra, const [ + 'localParticipantName', + 'local_participant_name', + ]), + ); + } + Future _handleAccept(String callKitId) async { + if (_acceptHandledCallKitIds.contains(callKitId)) return; + _activeCallKitId = callKitId; final params = _paramsForCallId(callKitId); final extra = params?.extra ?? {}; @@ -101,55 +153,102 @@ class VideoCallCallKitCoordinator { return; } - final counterpartName = _parseString(extra, const [ - 'counterpartName', - 'caller_name', - 'callerName', - ]) ?? params?.nameCaller; - final localParticipantName = _parseString(extra, const [ - 'localParticipantName', - 'local_participant_name', - ]); - final avatarUrl = _parseString(extra, const [ - 'counterpartAvatarUrl', - 'avatar', - 'avatarUrl', - 'caller_avatar', - ]) ?? params?.avatar; - - await _cubit.startCall( + await _deliverIncomingAccept( + callKitId: callKitId, conversationUuid: conversationUuid, - counterpartName: counterpartName, - localParticipantName: localParticipantName, + counterpartName: _parseString(extra, const [ + 'counterpartName', + 'caller_name', + 'callerName', + ]) ?? params?.nameCaller, + counterpartAvatarUrl: _parseString(extra, const [ + 'counterpartAvatarUrl', + 'avatar', + 'avatarUrl', + 'caller_avatar', + ]) ?? params?.avatar, + localParticipantName: _parseString(extra, const [ + 'localParticipantName', + 'local_participant_name', + ]), ); - - await VideoCallNotificationNavigator.openIncomingCall( - conversationUuid: conversationUuid, - counterpartName: counterpartName, - counterpartAvatarUrl: avatarUrl, - localParticipantName: localParticipantName, - ); - - _waitForConnectedThenEnterCall(callKitId); } - void _waitForConnectedThenEnterCall(String callKitId) { - void onSnapshot(VideoCallManagerSnapshot snapshot) { - if (snapshot.connectPhase != VideoCallConnectPhase.connected) return; - _manager.removeListener(onSnapshot); - unawaited(_cubit.enterCallStage()); - unawaited(FlutterCallkitIncoming.setCallConnected(callKitId)); - } + Future _deliverIncomingAccept({ + required String callKitId, + required String conversationUuid, + String? counterpartName, + String? counterpartAvatarUrl, + String? localParticipantName, + }) async { + if (_acceptHandledCallKitIds.contains(callKitId)) return; + _acceptHandledCallKitIds.add(callKitId); + _suppressedEndCallKitIds.add(callKitId); - if (_manager.snapshot.connectPhase == VideoCallConnectPhase.connected) { - onSnapshot(_manager.snapshot); + try { + await FlutterCallkitIncoming.endCall(callKitId); + + final binding = WidgetsBinding.instance; + final completer = Completer(); + binding.addPostFrameCallback((_) { + unawaited(_openConnectingScreenAfterAccept( + conversationUuid: conversationUuid, + counterpartName: counterpartName, + counterpartAvatarUrl: counterpartAvatarUrl, + localParticipantName: localParticipantName, + ).whenComplete(completer.complete)); + }); + await completer.future; + } finally { + unawaited( + Future.delayed(const Duration(seconds: 2), () { + _acceptHandledCallKitIds.remove(callKitId); + _suppressedEndCallKitIds.remove(callKitId); + }), + ); + } + } + + Future _openConnectingScreenAfterAccept({ + required String conversationUuid, + String? counterpartName, + String? counterpartAvatarUrl, + String? localParticipantName, + }) { + return VideoCallNotificationNavigator.openIncomingCall( + conversationUuid: conversationUuid, + counterpartName: counterpartName, + counterpartAvatarUrl: counterpartAvatarUrl, + localParticipantName: localParticipantName, + ); + } + + Future _handleDecline(String callKitId) async { + if (_suppressedEndCallKitIds.contains(callKitId)) { + log( + 'VideoCallCallKitCoordinator: ignore spurious end/decline during accept, callKitId=$callKitId', + name: 'VideoCallCallKit', + ); return; } - _manager.addListener(onSnapshot); - } - Future _handleEnd(String callKitId) async { - await FlutterCallkitIncoming.endCall(callKitId); + final params = _paramsForCallId(callKitId); + final extra = params?.extra ?? {}; + final conversationUuid = _parseConversationUuid( + extra, + handle: params?.handle ?? callKitId, + ); + + _suppressedEndCallKitIds.add(callKitId); + try { + await FlutterCallkitIncoming.endCall(callKitId); + } finally { + unawaited( + Future.delayed(const Duration(seconds: 1), () { + _suppressedEndCallKitIds.remove(callKitId); + }), + ); + } _callKitParamsById.remove(callKitId); if (_activeCallKitId == callKitId) { _activeCallKitId = null; @@ -158,6 +257,12 @@ class VideoCallCallKitCoordinator { if (_manager.isActiveCall || _cubit.state.connectPhase != VideoCallConnectPhase.idle) { await _cubit.endCall(); + return; + } + + await _manager.declineIncomingCall(conversationUuid); + if (_cubit.state.connectPhase != VideoCallConnectPhase.idle) { + await _cubit.onCallEndedExternally(); } } diff --git a/lib/features/video_calls/services/video_call_fcm_handler.dart b/lib/features/video_calls/services/video_call_fcm_handler.dart new file mode 100644 index 00000000..6f058c5b --- /dev/null +++ b/lib/features/video_calls/services/video_call_fcm_handler.dart @@ -0,0 +1,75 @@ +import 'dart:developer'; +import 'dart:io'; + +import 'package:eatvio_app/features/video_calls/services/video_call_incoming_push_presenter.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart'; +import 'package:eatvio_app/firebase_options.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/widgets.dart'; + +/// Top-level FCM background handler (separate isolate). +@pragma('vm:entry-point') +Future videoCallFirebaseMessagingBackgroundHandler( + RemoteMessage message, +) async { + WidgetsFlutterBinding.ensureInitialized(); + await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + await VideoCallFcmHandler.handleRemoteMessage( + message, + source: 'background', + ); +} + +abstract final class VideoCallFcmHandler { + static bool _foregroundListenerRegistered = false; + + static void registerBackgroundHandler() { + FirebaseMessaging.onBackgroundMessage( + videoCallFirebaseMessagingBackgroundHandler, + ); + } + + static void registerForegroundListener() { + if (_foregroundListenerRegistered) return; + _foregroundListenerRegistered = true; + + FirebaseMessaging.onMessage.listen((message) { + handleRemoteMessage(message, source: 'foreground'); + }); + } + + static Future handleRemoteMessage( + RemoteMessage message, { + required String source, + }) async { + if (!Platform.isAndroid) return; + + final data = Map.from(message.data); + if (data.isEmpty) return; + + final payload = VideoCallPushPayload.tryParse(data); + if (payload == null) { + log( + 'VideoCallFcmHandler: skip non-video-call push, source=$source, data=$data', + name: 'VideoCallFcm', + ); + return; + } + + log( + 'VideoCallFcmHandler: show incoming call UI, source=$source, conversation=${payload.conversationUuid}', + name: 'VideoCallFcm', + ); + + try { + await VideoCallIncomingPushPresenter.showIncomingCall(payload); + } catch (e, st) { + log( + 'VideoCallFcmHandler: failed to show incoming call UI: $e', + name: 'VideoCallFcm', + stackTrace: st, + ); + } + } +} diff --git a/lib/features/video_calls/services/video_call_incoming_push_presenter.dart b/lib/features/video_calls/services/video_call_incoming_push_presenter.dart new file mode 100644 index 00000000..c3325be9 --- /dev/null +++ b/lib/features/video_calls/services/video_call_incoming_push_presenter.dart @@ -0,0 +1,53 @@ +import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart'; +import 'package:flutter_callkit_incoming/entities/entities.dart'; +import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; + +abstract final class VideoCallIncomingPushPresenter { + static const String _androidAccentColor = '#2787F5'; + + static Future showIncomingCall(VideoCallPushPayload payload) async { + final callerName = + payload.counterpartName?.trim().isNotEmpty == true + ? payload.counterpartName!.trim() + : 'Итвио'; + + final params = CallKitParams( + id: payload.conversationUuid, + nameCaller: callerName, + appName: 'Итвио', + avatar: payload.counterpartAvatarUrl, + handle: payload.conversationUuid, + type: 0, + duration: 45000, + extra: { + 'conversationUuid': payload.conversationUuid, + if (payload.counterpartName != null) + 'counterpartName': payload.counterpartName, + if (payload.counterpartAvatarUrl != null) + 'counterpartAvatarUrl': payload.counterpartAvatarUrl, + if (payload.localParticipantName != null) + 'localParticipantName': payload.localParticipantName, + }, + android: const AndroidParams( + isCustomNotification: true, + isShowLogo: false, + isShowFullLockedScreen: false, + isFullScreen: false, + ringtonePath: 'system_ringtone_default', + backgroundColor: _androidAccentColor, + actionColor: _androidAccentColor, + textColor: '#ffffff', + textAccept: 'Answer', + textDecline: 'Decline', + isShowCallID: false, + ), + ios: const IOSParams( + handleType: 'generic', + supportsVideo: true, + ringtonePath: 'system_ringtone_default', + ), + ); + + await FlutterCallkitIncoming.showCallkitIncoming(params); + } +} diff --git a/lib/features/video_calls/services/video_call_notification_navigator.dart b/lib/features/video_calls/services/video_call_notification_navigator.dart index c648bfe0..09d88086 100644 --- a/lib/features/video_calls/services/video_call_notification_navigator.dart +++ b/lib/features/video_calls/services/video_call_notification_navigator.dart @@ -39,13 +39,6 @@ class VideoCallNotificationNavigator { counterpartAvatarUrl: counterpartAvatarUrl, localParticipantName: localParticipantName, ); - - final manager = sl(); - await manager.onCallScreenVisible( - conversationUuid: conversationUuid, - counterpartName: counterpartName, - localParticipantName: localParticipantName, - ); } static Future _pushCallScreen({ diff --git a/lib/features/video_calls/services/video_call_push_payload.dart b/lib/features/video_calls/services/video_call_push_payload.dart new file mode 100644 index 00000000..3b0decac --- /dev/null +++ b/lib/features/video_calls/services/video_call_push_payload.dart @@ -0,0 +1,145 @@ +class VideoCallPushPayload { + const VideoCallPushPayload({ + required this.conversationUuid, + this.counterpartName, + this.counterpartAvatarUrl, + this.localParticipantName, + }); + + final String conversationUuid; + final String? counterpartName; + final String? counterpartAvatarUrl; + final String? localParticipantName; + + static VideoCallPushPayload? tryParse(Map data) { + if (!isIncomingVideoCall(data)) return null; + + final conversationUuid = _conversationUuid(data); + if (conversationUuid == null || conversationUuid.isEmpty) return null; + + return VideoCallPushPayload( + conversationUuid: conversationUuid, + counterpartName: _string(data, const [ + 'counterpartName', + 'counterpart_name', + 'caller_name', + 'callerName', + 'nameCaller', + 'caller', + ]), + counterpartAvatarUrl: _resolveAvatarUrl( + _string(data, const [ + 'counterpartAvatarUrl', + 'counterpart_avatar_url', + 'avatar', + 'avatarUrl', + 'avatar_url', + 'caller_avatar', + ]), + ), + localParticipantName: _string(data, const [ + 'localParticipantName', + 'local_participant_name', + ]), + ); + } + + static bool isIncomingVideoCall(Map data) { + final type = _string(data, const [ + 'type', + 'notification_type', + 'event', + 'action', + 'push_type', + 'call_type', + ]); + if (type != null) { + final normalized = type.toLowerCase().replaceAll('-', '_'); + if (normalized.contains('video_call')) { + if (normalized.contains('end') || normalized.contains('ended')) { + return false; + } + return true; + } + if (normalized == 'incoming_call' || + normalized == 'video_call_incoming' || + normalized == 'incoming_video_call') { + return true; + } + } + + if (_isTruthy(data['is_video_call']) || + _isTruthy(data['incoming_call']) || + _isTruthy(data['is_incoming_call'])) { + return _conversationUuid(data) != null; + } + + final actionUrl = _string(data, const ['action_url']); + if (actionUrl != null && + (actionUrl.contains('video-call') || actionUrl.contains('videoCall'))) { + return true; + } + + return false; + } + + static String? _conversationUuid(Map data) { + final fromData = _string(data, const [ + 'conversationUuid', + 'conversation_uuid', + 'conversation_id', + 'chat_uuid', + 'uuid', + ]); + if (fromData != null && fromData.isNotEmpty) return fromData; + + final actionUrl = _string(data, const ['action_url']); + if (actionUrl == null) return null; + + final uri = Uri.tryParse(actionUrl); + if (uri == null) return null; + + final fromQuery = uri.queryParameters['uuid'] ?? + uri.queryParameters['conversationUuid'] ?? + uri.queryParameters['conversation_uuid']; + if (fromQuery != null && fromQuery.trim().isNotEmpty) { + return fromQuery.trim(); + } + + final segments = uri.pathSegments; + for (var i = 0; i < segments.length - 1; i++) { + if (segments[i] == 'video-call' && segments[i + 1].isNotEmpty) { + return segments[i + 1]; + } + } + + return null; + } + + static String? _string(Map map, List keys) { + for (final key in keys) { + final value = map[key]; + if (value is String && value.trim().isNotEmpty) { + return value.trim(); + } + } + return null; + } + + static bool _isTruthy(Object? value) { + if (value == null) return false; + if (value is bool) return value; + final normalized = value.toString().trim().toLowerCase(); + return normalized == '1' || + normalized == 'true' || + normalized == 'yes'; + } + + static String? _resolveAvatarUrl(String? raw) { + if (raw == null || raw.isEmpty) return null; + if (raw.startsWith('http://') || raw.startsWith('https://')) { + return raw; + } + return raw; + } +} diff --git a/lib/main.dart b/lib/main.dart index ee56a8e7..2f8373da 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -20,6 +20,8 @@ Future main() async { FlutterForegroundTask.initCommunicationPort(); VideoCallForegroundBootstrap.ensureInitialized(); await AppInitializer.initialize(buildType: AppBuildType.prod); + await sl().initialize(); + await PushNavigationHandler.initialize(); await AppInitializer.runAppWithSentry(() => runApp(const MyApp())); } @@ -34,8 +36,6 @@ class _MyAppState extends State { @override void initState() { super.initState(); - PushNavigationHandler.initialize(); - unawaited(sl().initialize()); } @override diff --git a/test/features/video_calls/services/video_call_push_payload_test.dart b/test/features/video_calls/services/video_call_push_payload_test.dart new file mode 100644 index 00000000..9ddae36e --- /dev/null +++ b/test/features/video_calls/services/video_call_push_payload_test.dart @@ -0,0 +1,51 @@ +import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('VideoCallPushPayload', () { + test('parses video_call type with conversation uuid', () { + final payload = VideoCallPushPayload.tryParse({ + 'type': 'video_call_incoming', + 'conversation_uuid': 'abc-123', + 'caller_name': 'Anna', + 'avatar': 'https://cdn.example/avatar.jpg', + }); + + expect(payload, isNotNull); + expect(payload!.conversationUuid, 'abc-123'); + expect(payload.counterpartName, 'Anna'); + expect(payload.counterpartAvatarUrl, 'https://cdn.example/avatar.jpg'); + }); + + test('parses action_url with video-call path', () { + final payload = VideoCallPushPayload.tryParse({ + 'action_url': 'https://eatvio.ru/video-call-screen?uuid=room-42', + 'counterpartName': 'Ivan', + }); + + expect(payload, isNotNull); + expect(payload!.conversationUuid, 'room-42'); + expect(payload.counterpartName, 'Ivan'); + }); + + test('ignores ended video call push', () { + expect( + VideoCallPushPayload.tryParse({ + 'type': 'video_call_ended', + 'conversation_uuid': 'abc-123', + }), + isNull, + ); + }); + + test('ignores regular chat push', () { + expect( + VideoCallPushPayload.tryParse({ + 'type': 'chat_message', + 'action_url': 'https://eatvio.ru/chat/123', + }), + isNull, + ); + }); + }); +} -- 2.49.1 From de795c5274aa74abb9aa8b98e5ebf9963fd07021 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 10 Jun 2026 09:32:43 +0300 Subject: [PATCH 12/88] feat(video_calls): added system android pip for calls --- android/app/src/main/AndroidManifest.xml | 1 + .../kotlin/com/eatvioapp/app/MainActivity.kt | 37 ++++++++ lib/core/di/cubit_registrations.dart | 5 +- lib/core/di/service_registrations.dart | 4 + .../managers/video_call_manager.dart | 41 ++++++++ .../video_call_session_cubit.dart | 43 +++++++-- .../widgets/video_call_app_shell.dart | 33 +++++++ .../widgets/video_call_global_pip_layer.dart | 26 +----- .../widgets/video_call_pip_video_preview.dart | 42 +++++++++ .../video_call_system_pip_content.dart | 47 ++++++++++ .../video_call_system_pip_listener.dart | 93 +++++++++++++++++++ .../video_call_system_pip_coordinator.dart | 63 +++++++++++++ lib/main.dart | 6 +- pubspec.yaml | 1 + .../video_call_session_cubit_test.dart | 27 ++++++ 15 files changed, 436 insertions(+), 33 deletions(-) create mode 100644 lib/features/video_calls/presentation/widgets/video_call_app_shell.dart create mode 100644 lib/features/video_calls/presentation/widgets/video_call_pip_video_preview.dart create mode 100644 lib/features/video_calls/presentation/widgets/video_call_system_pip_content.dart create mode 100644 lib/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart create mode 100644 lib/features/video_calls/services/video_call_system_pip_coordinator.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 91bcaa52..daa5ec83 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -37,6 +37,7 @@ android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" + android:supportsPictureInPicture="true" android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt b/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt index 26bd8ed7..65903d96 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt @@ -1,8 +1,11 @@ package com.eatvioapp.app +import android.app.PictureInPictureParams import android.content.Intent import android.content.pm.ApplicationInfo import android.content.pm.PackageManager +import android.os.Build +import android.util.Rational import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel @@ -21,7 +24,10 @@ class MainActivity: FlutterActivity() { } private val channelName = "com.eatvioapp/hms_push" + private val videoCallPipChannelName = "com.eatvioapp/video_call_pip" private var methodChannel: MethodChannel? = null + private var videoCallPipChannel: MethodChannel? = null + private var videoCallPipEnabled: Boolean = false override fun onResume() { super.onResume() @@ -37,6 +43,16 @@ class MainActivity: FlutterActivity() { super.configureFlutterEngine(flutterEngine) methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName) + videoCallPipChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPipChannelName) + videoCallPipChannel?.setMethodCallHandler { call, result -> + when (call.method) { + "setPipEnabled" -> { + videoCallPipEnabled = call.arguments as? Boolean ?: false + result.success(null) + } + else -> result.notImplemented() + } + } methodChannel?.setMethodCallHandler { call, result -> when (call.method) { "getHuaweiToken" -> { @@ -94,6 +110,12 @@ class MainActivity: FlutterActivity() { handleIntentForActionUrl(intent) } + override fun onUserLeaveHint() { + super.onUserLeaveHint() + enterVideoCallPictureInPictureIfNeeded() + videoCallPipChannel?.invokeMethod("onUserLeaveHint", null) + } + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) // Обработка нового интента, когда Activity уже запущена (тап по уведомлению). @@ -122,4 +144,19 @@ class MainActivity: FlutterActivity() { // Проксируем URL во Flutter, где уже есть общая логика навигации по диплинкам. methodChannel?.invokeMethod("openActionUrl", actionUrl) } + + private fun enterVideoCallPictureInPictureIfNeeded() { + if (!videoCallPipEnabled) return + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + if (isInPictureInPictureMode) return + + try { + val params = PictureInPictureParams.Builder() + .setAspectRatio(Rational(2, 3)) + .build() + enterPictureInPictureMode(params) + } catch (e: Exception) { + android.util.Log.w("VideoCallPiP", "Unable to enter PiP from onUserLeaveHint", e) + } + } } diff --git a/lib/core/di/cubit_registrations.dart b/lib/core/di/cubit_registrations.dart index 157b1e56..4e6ff7ee 100644 --- a/lib/core/di/cubit_registrations.dart +++ b/lib/core/di/cubit_registrations.dart @@ -77,7 +77,10 @@ class CubitRegistrations { () => VideoCallManager(videoCallRepository: sl()), ); sl.registerLazySingleton( - () => VideoCallSessionCubit(videoCallManager: sl()), + () => VideoCallSessionCubit( + videoCallManager: sl(), + systemPipCoordinator: sl(), + ), ); } diff --git a/lib/core/di/service_registrations.dart b/lib/core/di/service_registrations.dart index a5ee09ee..d8f23ca2 100644 --- a/lib/core/di/service_registrations.dart +++ b/lib/core/di/service_registrations.dart @@ -37,6 +37,7 @@ import 'package:eatvio_app/features/registration_v2/services/registration_servic import 'package:eatvio_app/features/settings/services/settings_service.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_callkit_coordinator.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_service.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_system_pip_coordinator.dart'; import 'package:get_it/get_it.dart'; /// Регистрация всех Service в системе внедрения зависимостей @@ -79,6 +80,9 @@ class ServiceRegistrations { sl.registerLazySingleton( () => VideoCallCallKitCoordinator(firebaseApi: sl()), ); + sl.registerLazySingleton( + () => VideoCallSystemPipCoordinator(), + ); sl.registerLazySingleton(() => ResetPasswordService(sl())); sl.registerLazySingleton(() => BlockedUserService(sl())); sl.registerLazySingleton(() => CommentsService(sl())); diff --git a/lib/features/video_calls/managers/video_call_manager.dart b/lib/features/video_calls/managers/video_call_manager.dart index 87bafb3b..92fade24 100644 --- a/lib/features/video_calls/managers/video_call_manager.dart +++ b/lib/features/video_calls/managers/video_call_manager.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; @@ -46,6 +47,9 @@ class VideoCallManager { static const Duration _mutedMicOverlayMinInterval = Duration( milliseconds: 2600, ); + static const Duration _localMicActivationInterruptionGrace = Duration( + milliseconds: 1500, + ); static const double _mutedMicAudioLevelThreshold = 0.008; static const double _mutedMicEnergyDelta = 1e-6; @@ -69,6 +73,7 @@ class VideoCallManager { bool _videoPausedForBackground = false; bool? _micEnabledBeforeSystemInterruption; int _audioInterruptionHoldCount = 0; + DateTime? _ignoreAudioInterruptionUntil; OverlayEntry? _systemInterruptionEntry; VideoCallManagerSnapshot _snapshot = VideoCallManagerSnapshot.initial; @@ -367,6 +372,14 @@ class VideoCallManager { /// [currentUiEnabled] reflects what the user sees on the mic control /// ([VideoCallSessionState.isMicActiveForUi]), not only stored intent. Future toggleMic(bool currentUiEnabled) async { + if (!currentUiEnabled && + _snapshot.systemAudioInterrupted && + _snapshot.micEnabled && + _isLocalMicrophonePublishing()) { + _clearSystemInterruptionState(); + _lastMutedMicTotalEnergy = null; + return; + } final next = !currentUiEnabled; if (_snapshot.systemAudioInterrupted || _audioInterruptionHoldCount > 0) { if (next) { @@ -397,6 +410,9 @@ class VideoCallManager { } _emitSnapshot(_snapshot.copyWith(micEnabled: next)); _lastMutedMicTotalEnergy = null; + if (next) { + _suppressOwnMicActivationInterruptions(); + } await _room!.localParticipant?.setMicrophoneEnabled( next, audioCaptureOptions: _micCaptureOptions, @@ -608,6 +624,7 @@ class VideoCallManager { _recordingOverlayScheduled = false; _micEnabledBeforeSystemInterruption = null; _audioInterruptionHoldCount = 0; + _ignoreAudioInterruptionUntil = null; _joinInProgress = false; _clearPreparedConnectionCredentials(); _syncSystemInterruptionOverlay(show: false); @@ -1056,6 +1073,9 @@ class VideoCallManager { _snapshot.connectPhase != VideoCallConnectPhase.connected) { return; } + if (_shouldIgnoreAudioInterruption(event)) { + return; + } if (event.begin) { _audioInterruptionHoldCount++; if (_audioInterruptionHoldCount == 1) { @@ -1115,6 +1135,7 @@ class VideoCallManager { if (lp == null) return; try { + _suppressOwnMicActivationInterruptions(); await lp.setMicrophoneEnabled( true, audioCaptureOptions: _micCaptureOptions, @@ -1131,6 +1152,24 @@ class VideoCallManager { } } + void _suppressOwnMicActivationInterruptions() { + if (!Platform.isAndroid) return; + _ignoreAudioInterruptionUntil = DateTime.now().add( + _localMicActivationInterruptionGrace, + ); + } + + bool _shouldIgnoreAudioInterruption(AudioInterruptionEvent event) { + if (!Platform.isAndroid || !event.begin) return false; + final until = _ignoreAudioInterruptionUntil; + if (until == null) return false; + if (DateTime.now().isBefore(until)) { + return true; + } + _ignoreAudioInterruptionUntil = null; + return false; + } + Future _restartLocalAudioTrack() async { if (!_canHandleCallAudio || !_snapshot.micEnabled) return; final lp = _room!.localParticipant; @@ -1139,6 +1178,7 @@ class VideoCallManager { final track = pub?.track; if (track is lk.LocalAudioTrack) { try { + _suppressOwnMicActivationInterruptions(); await track.restartTrack(); return; } catch (_) {} @@ -1148,6 +1188,7 @@ class VideoCallManager { false, audioCaptureOptions: _micCaptureOptions, ); + _suppressOwnMicActivationInterruptions(); await lp.setMicrophoneEnabled( true, audioCaptureOptions: _micCaptureOptions, diff --git a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart index 5eb112db..a7c73fc8 100644 --- a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart +++ b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart @@ -4,21 +4,29 @@ import 'package:eatvio_app/core/cubit/base_cubit.dart'; import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart'; import 'package:eatvio_app/features/video_calls/managers/video_call_manager_snapshot.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_system_pip_coordinator.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:livekit_client/livekit_client.dart' as lk; class VideoCallSessionCubit extends BaseCubit { - VideoCallSessionCubit({required VideoCallManager videoCallManager}) - : _videoCallManager = videoCallManager, - super(const VideoCallSessionState()) { + VideoCallSessionCubit({ + required VideoCallManager videoCallManager, + VideoCallSystemPipCoordinator? systemPipCoordinator, + }) : _videoCallManager = videoCallManager, + _systemPipCoordinator = + systemPipCoordinator ?? VideoCallSystemPipCoordinator(), + super(const VideoCallSessionState()) { _videoCallManager.addListener(_onManagerSnapshot); _syncFromManager(_videoCallManager.snapshot); } final VideoCallManager _videoCallManager; + final VideoCallSystemPipCoordinator _systemPipCoordinator; + bool _callScreenRouteVisible = false; lk.Room? get liveKitRoom => _videoCallManager.room; + bool get isCallScreenRouteVisible => _callScreenRouteVisible; static bool _isCallSessionInactive(VideoCallManagerSnapshot snapshot) { return snapshot.conversationUuid.isEmpty || @@ -56,6 +64,7 @@ class VideoCallSessionCubit extends BaseCubit { void _onManagerSnapshot(VideoCallManagerSnapshot snapshot) { if (isClosed) return; if (_isCallSessionInactive(snapshot)) { + unawaited(_systemPipCoordinator.disarm()); emit(const VideoCallSessionState()); unawaited(_restoreSystemUiAfterCall()); return; @@ -79,6 +88,7 @@ class VideoCallSessionCubit extends BaseCubit { Future _onCallEndedExternally() async { if (isClosed) return; + await _systemPipCoordinator.disarm(); await _restoreSystemUiAfterCall(); emit(const VideoCallSessionState()); } @@ -102,6 +112,7 @@ class VideoCallSessionCubit extends BaseCubit { String? counterpartName, String? localParticipantName, }) async { + _callScreenRouteVisible = true; final isResumingActiveCall = state.conversationUuid == conversationUuid && state.callStageEntered && @@ -144,6 +155,7 @@ class VideoCallSessionCubit extends BaseCubit { } void onCallScreenDisposed() { + _callScreenRouteVisible = false; if (state.uiMode != VideoCallUiMode.fullscreen) return; if (state.connectPhase == VideoCallConnectPhase.connected && state.linkIssue != VideoCallLinkIssue.lost) { @@ -159,17 +171,32 @@ class VideoCallSessionCubit extends BaseCubit { } } - Future minimizeToPip() async { - if (state.connectPhase != VideoCallConnectPhase.connected) return; - if (state.linkIssue == VideoCallLinkIssue.lost) return; - if (!state.callStageEntered) return; + bool get _canMinimizeToPip => + state.connectPhase == VideoCallConnectPhase.connected && + state.linkIssue != VideoCallLinkIssue.lost && + state.callStageEntered; + + Future _applyPipUiState() async { emit(state.copyWith(uiMode: VideoCallUiMode.pip)); await _restoreSystemUiAfterCall(); } + /// Called when Android enters system PiP (e.g. Home gesture). + Future syncPipUiStateFromSystem() async { + if (!_canMinimizeToPip) return; + if (state.uiMode == VideoCallUiMode.pip) return; + await _applyPipUiState(); + } + + Future minimizeToPip() async { + if (!_canMinimizeToPip) return; + await _applyPipUiState(); + } + Future restoreFullscreenFromPip() async { if (state.uiMode != VideoCallUiMode.pip) return; emit(state.copyWith(uiMode: VideoCallUiMode.fullscreen)); + unawaited(_systemPipCoordinator.armAutoPipOnLeave()); WidgetsBinding.instance.addPostFrameCallback((_) { unawaited(_applyVideoCallSystemUi()); }); @@ -197,6 +224,7 @@ class VideoCallSessionCubit extends BaseCubit { if (!entered || isClosed) return; emit(state.copyWith(callStageEntered: true)); + unawaited(_systemPipCoordinator.armAutoPipOnLeave()); WidgetsBinding.instance.addPostFrameCallback((_) { if (isClosed) return; _videoCallManager.scheduleRecordingNoticeOnce(); @@ -209,6 +237,7 @@ class VideoCallSessionCubit extends BaseCubit { } Future endCall() async { + await _systemPipCoordinator.disarm(); await _videoCallManager.endCall(); } diff --git a/lib/features/video_calls/presentation/widgets/video_call_app_shell.dart b/lib/features/video_calls/presentation/widgets/video_call_app_shell.dart new file mode 100644 index 00000000..5a972711 --- /dev/null +++ b/lib/features/video_calls/presentation/widgets/video_call_app_shell.dart @@ -0,0 +1,33 @@ +import 'dart:io'; + +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_system_pip_content.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart'; +import 'package:floating/floating.dart'; +import 'package:flutter/material.dart'; + +/// Root overlay shell: system PiP on Android, in-app PiP fallback on iOS. +class VideoCallAppShell extends StatelessWidget { + const VideoCallAppShell({super.key, required this.child}); + + final Widget? child; + + @override + Widget build(BuildContext context) { + final shell = Stack( + clipBehavior: Clip.none, + children: [ + child ?? const SizedBox.shrink(), + const VideoCallGlobalPipLayer(), + const VideoCallSystemPipListener(), + ], + ); + + if (!Platform.isAndroid) return shell; + + return PiPSwitcher( + childWhenDisabled: shell, + childWhenEnabled: const VideoCallSystemPipContent(), + ); + } +} diff --git a/lib/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart b/lib/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart index f07ae217..e9c9b281 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart @@ -6,12 +6,10 @@ import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart'; -import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart'; -import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_no_video_background.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_pip_video_preview.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; -import 'package:livekit_client/livekit_client.dart' as lk; class VideoCallGlobalPipLayer extends StatefulWidget { const VideoCallGlobalPipLayer({super.key}); @@ -54,23 +52,6 @@ class _VideoCallGlobalPipLayerState extends State { ); } - Widget _pipPreview(VideoCallSessionState sessionState, lk.Room room) { - if (!sessionState.videoEnabled) { - return const VideoCallNoVideoBackground(logoSize: 56); - } - final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room); - if (local != null) { - return IgnorePointer( - child: lk.VideoTrackRenderer( - local, - fit: lk.VideoViewFit.cover, - mirrorMode: lk.VideoViewMirrorMode.auto, - ), - ); - } - return const VideoCallNoVideoBackground(logoSize: 56); - } - @override Widget build(BuildContext context) { return BlocBuilder( @@ -155,7 +136,10 @@ class _VideoCallGlobalPipLayerState extends State { child: SizedBox( width: _pipW, height: _pipH, - child: _pipPreview(state, room), + child: VideoCallPipVideoPreview( + sessionState: state, + room: room, + ), ), ), ), diff --git a/lib/features/video_calls/presentation/widgets/video_call_pip_video_preview.dart b/lib/features/video_calls/presentation/widgets/video_call_pip_video_preview.dart new file mode 100644 index 00000000..0653f7e2 --- /dev/null +++ b/lib/features/video_calls/presentation/widgets/video_call_pip_video_preview.dart @@ -0,0 +1,42 @@ +import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_no_video_background.dart'; +import 'package:flutter/material.dart'; +import 'package:livekit_client/livekit_client.dart' as lk; + +/// Shared compact preview for both in-app and Android system PiP. +class VideoCallPipVideoPreview extends StatelessWidget { + const VideoCallPipVideoPreview({ + super.key, + required this.sessionState, + required this.room, + }); + + final VideoCallSessionState sessionState; + final lk.Room room; + + @override + Widget build(BuildContext context) { + final remote = VideoCallLiveKitTrackUtils.remoteCameraVideoTrack(room); + if (remote != null) { + return IgnorePointer( + child: lk.VideoTrackRenderer(remote, fit: lk.VideoViewFit.cover), + ); + } + + if (sessionState.videoEnabled) { + final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room); + if (local != null) { + return IgnorePointer( + child: lk.VideoTrackRenderer( + local, + fit: lk.VideoViewFit.cover, + mirrorMode: lk.VideoViewMirrorMode.auto, + ), + ); + } + } + + return const VideoCallNoVideoBackground(logoSize: 56); + } +} diff --git a/lib/features/video_calls/presentation/widgets/video_call_system_pip_content.dart b/lib/features/video_calls/presentation/widgets/video_call_system_pip_content.dart new file mode 100644 index 00000000..da20656f --- /dev/null +++ b/lib/features/video_calls/presentation/widgets/video_call_system_pip_content.dart @@ -0,0 +1,47 @@ +import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; +import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_pip_video_preview.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// Compact call UI rendered by [PiPSwitcher] while Android is in system PiP mode. +class VideoCallSystemPipContent extends StatelessWidget { + const VideoCallSystemPipContent({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + bloc: sl(), + buildWhen: (a, b) => + a.connectPhase != b.connectPhase || + a.linkIssue != b.linkIssue || + a.callStageEntered != b.callStageEntered || + a.videoEnabled != b.videoEnabled, + builder: (context, state) { + if (state.connectPhase != VideoCallConnectPhase.connected || + state.linkIssue == VideoCallLinkIssue.lost || + !state.callStageEntered) { + return const ColoredBox(color: Colors.black); + } + + final cubit = sl(); + final room = cubit.liveKitRoom; + if (room == null) { + return const ColoredBox(color: Colors.black); + } + + return Material( + color: Colors.black, + child: ListenableBuilder( + listenable: room, + builder: (context, _) => VideoCallPipVideoPreview( + sessionState: state, + room: room, + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart b/lib/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart new file mode 100644 index 00000000..7760e27f --- /dev/null +++ b/lib/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart @@ -0,0 +1,93 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:eatvio_app/core/app_router.dart'; +import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; +import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; +import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_system_pip_coordinator.dart'; +import 'package:floating/floating.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; + +/// Syncs [VideoCallSessionCubit] with Android system PiP lifecycle events. +class VideoCallSystemPipListener extends StatefulWidget { + const VideoCallSystemPipListener({super.key}); + + @override + State createState() => + _VideoCallSystemPipListenerState(); +} + +class _VideoCallSystemPipListenerState extends State { + static const MethodChannel _userLeaveHintChannel = MethodChannel( + 'com.eatvioapp/video_call_pip', + ); + + StreamSubscription? _subscription; + PiPStatus? _lastStatus; + + @override + void initState() { + super.initState(); + if (!Platform.isAndroid) return; + + _userLeaveHintChannel.setMethodCallHandler(_onNativePipMethodCall); + final coordinator = sl(); + _subscription = coordinator.statusStream.listen(_onPipStatus); + } + + Future _onNativePipMethodCall(MethodCall call) async { + if (call.method != 'onUserLeaveHint') return; + await sl().syncPipUiStateFromSystem(); + } + + Future _onPipStatus(PiPStatus status) async { + final cubit = sl(); + final wasEnabled = _lastStatus == PiPStatus.enabled; + _lastStatus = status; + + if (status == PiPStatus.enabled) { + await cubit.syncPipUiStateFromSystem(); + return; + } + + if (!wasEnabled || status != PiPStatus.disabled) return; + if (cubit.state.uiMode != VideoCallUiMode.pip) return; + + await cubit.restoreFullscreenFromPip(); + if (!mounted) return; + if (cubit.isCallScreenRouteVisible) return; + _openCallScreen(cubit.state); + } + + void _openCallScreen(VideoCallSessionState state) { + if (state.conversationUuid.isEmpty) return; + + final ctx = AppRouter.navigatorKey.currentContext; + if (ctx == null) return; + + GoRouter.of(ctx).pushNamed( + VideoCallScreen.name, + extra: { + 'conversationUuid': state.conversationUuid, + 'counterpartName': state.counterpartName, + 'localParticipantName': state.localParticipantName, + }, + ); + } + + @override + void dispose() { + if (Platform.isAndroid) { + _userLeaveHintChannel.setMethodCallHandler(null); + } + unawaited(_subscription?.cancel()); + super.dispose(); + } + + @override + Widget build(BuildContext context) => const SizedBox.shrink(); +} diff --git a/lib/features/video_calls/services/video_call_system_pip_coordinator.dart b/lib/features/video_calls/services/video_call_system_pip_coordinator.dart new file mode 100644 index 00000000..ca7c9c4d --- /dev/null +++ b/lib/features/video_calls/services/video_call_system_pip_coordinator.dart @@ -0,0 +1,63 @@ +import 'dart:io'; + +import 'package:floating/floating.dart'; +import 'package:flutter/services.dart'; + +/// Android system Picture-in-Picture via the [floating] package. +class VideoCallSystemPipCoordinator { + VideoCallSystemPipCoordinator({Floating? floating}) + : _floating = floating ?? Floating(); + + static const MethodChannel _nativePipChannel = MethodChannel( + 'com.eatvioapp/video_call_pip', + ); + + final Floating _floating; + + /// Portrait ratio close to the in-call mini preview (142×201). + static const Rational callAspectRatio = Rational(2, 3); + + bool get isPlatformSupported => Platform.isAndroid; + + Future get isAvailable async { + if (!isPlatformSupported) return false; + return _floating.isPipAvailable; + } + + Stream get statusStream => _floating.pipStatusStream; + + /// Enter PiP when the user leaves the app (Home / recent apps). + /// + /// Requires Android 12+ (API 31); on older versions this is a no-op. + Future armAutoPipOnLeave() async { + if (!await isAvailable) return; + await _setNativeUserLeaveHintPipEnabled(true); + try { + await _floating.enable( + const OnLeavePiP(aspectRatio: callAspectRatio), + ); + } on PlatformException { + // OnLeavePiP is unavailable below Android 12; native onUserLeaveHint + // still handles Home / recent apps on Android 8+. + } + } + + Future disarm() async { + if (!isPlatformSupported) return; + await _setNativeUserLeaveHintPipEnabled(false); + try { + await _floating.cancelOnLeavePiP(); + } on PlatformException { + // The activity may already be detached while the call is being torn down. + } + } + + Future _setNativeUserLeaveHintPipEnabled(bool enabled) async { + if (!isPlatformSupported) return; + try { + await _nativePipChannel.invokeMethod('setPipEnabled', enabled); + } on PlatformException { + // Native channel may be unavailable during startup/teardown. + } + } +} diff --git a/lib/main.dart b/lib/main.dart index 2f8373da..2813a4d0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,7 +6,7 @@ import 'package:eatvio_app/core/notifications/push_navigation_handler.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_callkit_coordinator.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_foreground_bootstrap.dart'; -import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_app_shell.dart'; import 'dart:async'; import 'package:flutter/material.dart'; @@ -51,9 +51,7 @@ class _MyAppState extends State { debugShowCheckedModeBanner: false, localizationsDelegates: sl().localizationDelegates, supportedLocales: sl().supportedLocales, - builder: (context, child) { - return Stack(clipBehavior: Clip.none, children: [(child ?? const SizedBox.shrink()), const VideoCallGlobalPipLayer()]); - }, + builder: (context, child) => VideoCallAppShell(child: child), ), ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 3f87b816..b6a3d23c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -88,6 +88,7 @@ dependencies: audio_session: ^0.1.25 flutter_foreground_task: ^9.2.1 flutter_callkit_incoming: ^3.1.1 + floating: ^6.0.0 dev_dependencies: build_runner: ^2.10.4 diff --git a/test/features/video_calls/presentation/video_call_session_cubit_test.dart b/test/features/video_calls/presentation/video_call_session_cubit_test.dart index 552f5283..d9c67cd9 100644 --- a/test/features/video_calls/presentation/video_call_session_cubit_test.dart +++ b/test/features/video_calls/presentation/video_call_session_cubit_test.dart @@ -142,5 +142,32 @@ void main() { expect(cubit.state, const VideoCallSessionState()); }); + + test('tracks whether call screen route is visible', () async { + when( + mockManager.startCall( + conversationUuid: anyNamed('conversationUuid'), + counterpartName: anyNamed('counterpartName'), + localParticipantName: anyNamed('localParticipantName'), + micEnabled: anyNamed('micEnabled'), + videoEnabled: anyNamed('videoEnabled'), + ), + ).thenAnswer((_) async {}); + + await cubit.onCallScreenVisible(conversationUuid: 'uuid'); + expect(cubit.isCallScreenRouteVisible, isTrue); + + cubit.emit( + const VideoCallSessionState( + conversationUuid: 'uuid', + connectPhase: VideoCallConnectPhase.connected, + callStageEntered: true, + uiMode: VideoCallUiMode.pip, + ), + ); + + cubit.onCallScreenDisposed(); + expect(cubit.isCallScreenRouteVisible, isFalse); + }); }); } -- 2.49.1 From 5fa024a7a80fd01789a76574d4ee84fe49336284 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 10 Jun 2026 14:09:54 +0300 Subject: [PATCH 13/88] feat(ci/cd): changed repositories for ci/cd --- .gitea/workflows/test-flight-cd.yaml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.gitea/workflows/test-flight-cd.yaml b/.gitea/workflows/test-flight-cd.yaml index 5fea01c4..fbd2e833 100644 --- a/.gitea/workflows/test-flight-cd.yaml +++ b/.gitea/workflows/test-flight-cd.yaml @@ -15,11 +15,12 @@ jobs: uses: https://github.com/actions/checkout@v2 - name: Checkout env repository - uses: https://github.com/actions/checkout@v2 + uses: https://github.com/actions/checkout@v3 with: - repository: mso7nueh/eatvio_app_env + repository: eatvio/eatvio_app_env path: env_repo - token: ${{ vars.MSO7NUEH_TOKEN }} + # Здесь может быть токен любого юзера с доступом в gitea + token: ${{ vars.READ_USER_GITEA_TOKEN }} ref: main - name: Copy .env file @@ -76,7 +77,7 @@ jobs: LC_ALL: en_US.UTF-8 MATCH_PASSWORD: ${{ vars.IOS_RUNNER_CI_PASSWORD }} FASTLANE_PASSWORD: ${{ vars.FASTLANE_PASSWORD }} - MATCH_GIT_URL: http://gitea.office.local:3000/mso7nueh/fastlane + MATCH_GIT_URL: http://gitea.office.local:3000/eatvio/fastlane FASTLANE_USER: eatvio2023@icloud.com FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ vars.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }} API_APPSTORE_KEY: ${{ vars.API_APPSTORE_KEY }} -- 2.49.1 From 6fb151ef328d633082c44ec39e8123c8a98782d1 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 10 Jun 2026 14:19:21 +0300 Subject: [PATCH 14/88] feat(ci/cd): added pods updating --- .gitea/workflows/test-flight-cd.yaml | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/.gitea/workflows/test-flight-cd.yaml b/.gitea/workflows/test-flight-cd.yaml index fbd2e833..bd092a3b 100644 --- a/.gitea/workflows/test-flight-cd.yaml +++ b/.gitea/workflows/test-flight-cd.yaml @@ -58,19 +58,16 @@ jobs: - name: Flutter build run: | - flutter --version + flutter upgrade flutter clean flutter pub get + flutter pub upgrade flutter pub run build_runner build --delete-conflicting-outputs # Configure Flutter for release mode - cd ios - pod install --no-repo-update --verbose || { - echo "⚠️ Pod install failed, trying with repo update..." - pod install --repo-update --verbose - } - cd .. + pod repo update flutter build ios --config-only --release cd ios + pod update fastlane beta env: LANG: en_US.UTF-8 -- 2.49.1 From 9dc568ceab921d09ccfc839c8191d9fe659647f5 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 10 Jun 2026 14:35:20 +0300 Subject: [PATCH 15/88] feat(ci/cd): removed flutter updating --- .gitea/workflows/test-flight-cd.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitea/workflows/test-flight-cd.yaml b/.gitea/workflows/test-flight-cd.yaml index bd092a3b..828c0d1f 100644 --- a/.gitea/workflows/test-flight-cd.yaml +++ b/.gitea/workflows/test-flight-cd.yaml @@ -58,10 +58,9 @@ jobs: - name: Flutter build run: | - flutter upgrade + flutter --version flutter clean flutter pub get - flutter pub upgrade flutter pub run build_runner build --delete-conflicting-outputs # Configure Flutter for release mode pod repo update -- 2.49.1 From 885c6f66f98aa5f1ba3ac3b450afb118e0e6139f Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 10 Jun 2026 16:39:06 +0300 Subject: [PATCH 16/88] feat(ci/cd): added gitea token for fastlane url --- .gitea/workflows/test-flight-cd.yaml | 2 +- ios/fastlane/Fastfile | 11 +---------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/.gitea/workflows/test-flight-cd.yaml b/.gitea/workflows/test-flight-cd.yaml index 828c0d1f..052fb05b 100644 --- a/.gitea/workflows/test-flight-cd.yaml +++ b/.gitea/workflows/test-flight-cd.yaml @@ -73,7 +73,7 @@ jobs: LC_ALL: en_US.UTF-8 MATCH_PASSWORD: ${{ vars.IOS_RUNNER_CI_PASSWORD }} FASTLANE_PASSWORD: ${{ vars.FASTLANE_PASSWORD }} - MATCH_GIT_URL: http://gitea.office.local:3000/eatvio/fastlane + MATCH_GIT_URL: http://eatvio:${{ vars.READ_USER_GITEA_TOKEN }}@gitea.office.local:3000/eatvio/fastlane.git FASTLANE_USER: eatvio2023@icloud.com FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ vars.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }} API_APPSTORE_KEY: ${{ vars.API_APPSTORE_KEY }} diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index 15a7d506..1e40f5d2 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -67,17 +67,8 @@ platform :ios do end UI.message "Flutter build number: #{flutter_build_number}" - - api_key = app_store_connect_api_key( - key_id: "325JS9JCZH", - issuer_id: "dfa27d1d-2bb8-4d4d-847e-0b32f9b880b3", - key_filepath: auth_key_path, - duration: 1200, - in_house: false, - - ) - match(type: "appstore", readonly: false, api_key: api_key) + match(type: "appstore", readonly: true) # Set build number explicitly instead of incrementing increment_build_number( -- 2.49.1 From 3e89bb5ca9dd6947d6e24bc59453c264d6276059 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 10 Jun 2026 17:14:15 +0300 Subject: [PATCH 17/88] feat(ci/cd): added team id to build_app for ios --- ios/fastlane/Fastfile | 1 + 1 file changed, 1 insertion(+) diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index 1e40f5d2..f40a1eb7 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -81,6 +81,7 @@ platform :ios do scheme: "Runner", export_method: "app-store", codesigning_identity: "Apple Distribution", + team_id: "VL68C36HYS" ) -- 2.49.1 From 4dd32e1a42d1ba003f5a67897edd6f97f7485b6d Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 10 Jun 2026 17:21:40 +0300 Subject: [PATCH 18/88] feat(ci/cd): fixed team id --- ios/fastlane/Fastfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index f40a1eb7..e6209338 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -81,7 +81,7 @@ platform :ios do scheme: "Runner", export_method: "app-store", codesigning_identity: "Apple Distribution", - team_id: "VL68C36HYS" + export_team_id: "VL68C36HYS" ) -- 2.49.1 From f4f884484dcecd6af6296a91dde3ab63b7918e12 Mon Sep 17 00:00:00 2001 From: misha Date: Thu, 11 Jun 2026 11:55:43 +0300 Subject: [PATCH 19/88] feat(ci/cd): separeted code sign from build_app --- ios/fastlane/Fastfile | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index e6209338..242ef07d 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -75,12 +75,21 @@ platform :ios do xcodeproj: "Runner.xcodeproj", build_number: flutter_build_number ) + + update_code_signing_settings( + use_automatic_signing: false, + path: "Runner.xcodeproj", + team_id: "VL68C36HYS", + targets: ["Runner"], + code_sign_identity: "Apple Distribution", + profile_name: "match AppStore com.eatvioapp.app 1781098260" + ) build_app( workspace: "Runner.xcworkspace", scheme: "Runner", export_method: "app-store", - codesigning_identity: "Apple Distribution", + # codesigning_identity: "Apple Distribution", export_team_id: "VL68C36HYS" ) -- 2.49.1 From 5daa1870450cc05a92907a623c5a5d0b763db258 Mon Sep 17 00:00:00 2001 From: misha Date: Thu, 11 Jun 2026 12:14:42 +0300 Subject: [PATCH 20/88] feat(ci/cd): fixed app delegate for ios --- ios/Runner/AppDelegate.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c439d251..dcfc1df5 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -132,7 +132,7 @@ import flutter_foreground_task handle: conversationUuid.isEmpty ? nameCaller : conversationUuid, type: isVideo ? 1 : 0 ) - data.extra = extra + data.extra = extra as NSDictionary SwiftFlutterCallkitIncomingPlugin.sharedInstance?.showCallkitIncoming( data, @@ -160,4 +160,6 @@ import flutter_foreground_task func didActivateAudioSession(_ audioSession: AVAudioSession) {} func didDeactivateAudioSession(_ audioSession: AVAudioSession) {} + + func providerDidReset() {} } -- 2.49.1 From 1cafb4b05a7305ae308a0864267e62c9b82c58af Mon Sep 17 00:00:00 2001 From: misha Date: Mon, 15 Jun 2026 14:07:56 +0300 Subject: [PATCH 21/88] feat(video_calls): changed chat button layoute --- .../chat/presentation/pages/chat_screen.dart | 38 ++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/lib/features/chat/presentation/pages/chat_screen.dart b/lib/features/chat/presentation/pages/chat_screen.dart index f5f37438..8d02a68a 100644 --- a/lib/features/chat/presentation/pages/chat_screen.dart +++ b/lib/features/chat/presentation/pages/chat_screen.dart @@ -217,19 +217,31 @@ class _ChatScreenState extends State { ), ), const SizedBox(width: 16), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(state.chatRoom.users.receiver.name, style: CustomTextStyles.titleSmallBold.copyWith(color: PrimaryColors().black)), - AnimatedCrossFade( - firstChild: Text('Печатает...', style: CustomTextStyles.bodySmallMulishPrimary), - firstCurve: Curves.easeIn, - secondCurve: Curves.easeOut, - secondChild: const SizedBox.shrink(), - crossFadeState: state.isTyping ? CrossFadeState.showFirst : CrossFadeState.showSecond, - duration: const Duration(milliseconds: 150), - ), - ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + state.chatRoom.users.receiver.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: CustomTextStyles.titleSmallBold.copyWith(color: PrimaryColors().black), + ), + AnimatedCrossFade( + firstChild: Text( + 'Печатает...', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: CustomTextStyles.bodySmallMulishPrimary, + ), + firstCurve: Curves.easeIn, + secondCurve: Curves.easeOut, + secondChild: const SizedBox.shrink(), + crossFadeState: state.isTyping ? CrossFadeState.showFirst : CrossFadeState.showSecond, + duration: const Duration(milliseconds: 150), + ), + ], + ), ), ], ), -- 2.49.1 From 4d531036fa03670922bf3b255519d394939a9a00 Mon Sep 17 00:00:00 2001 From: misha Date: Mon, 15 Jun 2026 14:12:37 +0300 Subject: [PATCH 22/88] Version changed --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index b6a3d23c..2cf34b05 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.24+105 +version: 1.3.25+106 publish_to: 'none' environment: -- 2.49.1 From b9cb4eeb84a6781b676ff71a18734f429ac4a7a1 Mon Sep 17 00:00:00 2001 From: misha Date: Mon, 15 Jun 2026 16:27:24 +0300 Subject: [PATCH 23/88] feat(video_calls): app delegate fix --- ios/Runner/AppDelegate.swift | 21 ++++++++++++++------- pubspec.yaml | 2 +- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index dcfc1df5..a1632593 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -10,6 +10,8 @@ import flutter_foreground_task @main @objc class AppDelegate: FlutterAppDelegate, PKPushRegistryDelegate, CallkitIncomingAppDelegate { + private var voipRegistry: PKPushRegistry? + override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? @@ -20,9 +22,9 @@ import flutter_foreground_task } let mainQueue = DispatchQueue.main - let voipRegistry = PKPushRegistry(queue: mainQueue) - voipRegistry.delegate = self - voipRegistry.desiredPushTypes = [PKPushType.voIP] + voipRegistry = PKPushRegistry(queue: mainQueue) + voipRegistry?.delegate = self + voipRegistry?.desiredPushTypes = [PKPushType.voIP] if #available(iOS 10.0, *) { UNUserNotificationCenter.current().delegate = self @@ -84,10 +86,6 @@ import flutter_foreground_task } let dict = payload.dictionaryPayload - let id = (dict["id"] as? String) - ?? (dict["call_id"] as? String) - ?? (dict["message_id"] as? String) - ?? UUID().uuidString let nameCaller = (dict["nameCaller"] as? String) ?? (dict["caller_name"] as? String) ?? (dict["callerName"] as? String) @@ -96,6 +94,15 @@ import flutter_foreground_task ?? (dict["conversationUuid"] as? String) ?? (dict["handle"] as? String) ?? "" + let id = [ + dict["id"] as? String, + dict["call_id"] as? String, + conversationUuid, + dict["message_id"] as? String, + ] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .first { !$0.isEmpty && UUID(uuidString: $0) != nil } + ?? UUID().uuidString let isVideo = (dict["isVideo"] as? Bool) ?? (dict["is_video"] as? Bool) ?? true diff --git a/pubspec.yaml b/pubspec.yaml index 2cf34b05..cfd60cd9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.25+106 +version: 1.3.26+107 publish_to: 'none' environment: -- 2.49.1 From a07565fca9508b752d0cf856118358423852783d Mon Sep 17 00:00:00 2001 From: misha Date: Mon, 15 Jun 2026 16:47:17 +0300 Subject: [PATCH 24/88] feat(ci/cd): removed pods updating --- .gitea/workflows/test-flight-cd.yaml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.gitea/workflows/test-flight-cd.yaml b/.gitea/workflows/test-flight-cd.yaml index 052fb05b..ddf59034 100644 --- a/.gitea/workflows/test-flight-cd.yaml +++ b/.gitea/workflows/test-flight-cd.yaml @@ -62,11 +62,9 @@ jobs: flutter clean flutter pub get flutter pub run build_runner build --delete-conflicting-outputs - # Configure Flutter for release mode - pod repo update flutter build ios --config-only --release cd ios - pod update + pod install fastlane beta env: LANG: en_US.UTF-8 -- 2.49.1 From 03f037518e0141bf6edc4b5fdc86a0ef1d81570e Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 16 Jun 2026 15:01:46 +0300 Subject: [PATCH 25/88] feat(video_calls): fixed ios and HMS pushes for calls --- .../com/eatvioapp/app/HmsPushService.kt | 85 ++++++++++++++ ios/Runner/AppDelegate.swift | 14 +++ .../video_call_callkit_coordinator.dart | 106 +++++++++++++----- .../video_call_notification_navigator.dart | 4 +- pubspec.yaml | 2 +- 5 files changed, 183 insertions(+), 28 deletions(-) diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt b/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt index f18b3992..f8db072b 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt @@ -2,11 +2,14 @@ package com.eatvioapp.app import android.app.PendingIntent import android.content.Intent +import android.os.Bundle import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import com.hiennv.flutter_callkit_incoming.CallkitConstants import com.huawei.hms.push.HmsMessageService import com.huawei.hms.push.RemoteMessage +import java.util.HashMap class HmsPushService : HmsMessageService() { override fun onNewToken(token: String?) { @@ -20,6 +23,11 @@ class HmsPushService : HmsMessageService() { if (message == null) return + if (isIncomingVideoCall(message.dataOfMap)) { + showIncomingVideoCallNotification(message.dataOfMap) + return + } + // Берём action_url из data-поля, если оно есть. val actionUrl = message.dataOfMap["action_url"] @@ -57,6 +65,83 @@ class HmsPushService : HmsMessageService() { NotificationManagerCompat.from(context).notify(message.messageId.hashCode(), notification) } + + private fun isIncomingVideoCall(data: Map): Boolean { + val rawType = data["type"] + ?: data["notification_type"] + ?: data["event"] + ?: data["action"] + ?: data["push_type"] + ?: data["call_type"] + val type = rawType?.lowercase()?.replace("-", "_") + return type?.contains("video_call") == true && + !type.contains("end") && + !type.contains("ended") + } + + private fun showIncomingVideoCallNotification(data: Map) { + val conversationUuid = data["conversationUuid"] + ?: data["conversation_uuid"] + ?: data["conversation_id"] + ?: data["chat_uuid"] + ?: data["uuid"] + ?: return + val callerName = data["counterpartName"] + ?: data["counterpart_name"] + ?: data["caller_name"] + ?: data["callerName"] + ?: data["nameCaller"] + ?: data["caller"] + ?: "Итвио" + val avatarUrl = data["counterpartAvatarUrl"] + ?: data["counterpart_avatar_url"] + ?: data["avatar"] + ?: data["avatarUrl"] + ?: data["avatar_url"] + ?: data["caller_avatar"] + ?: "" + val localParticipantName = data["localParticipantName"] + ?: data["local_participant_name"] + + val extra = HashMap() + extra["conversationUuid"] = conversationUuid + extra["counterpartName"] = callerName + if (avatarUrl.isNotBlank()) { + extra["counterpartAvatarUrl"] = avatarUrl + } + if (!localParticipantName.isNullOrBlank()) { + extra["localParticipantName"] = localParticipantName + } + + val callData = Bundle().apply { + putString(CallkitConstants.EXTRA_CALLKIT_ID, conversationUuid) + putString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, callerName) + putString(CallkitConstants.EXTRA_CALLKIT_APP_NAME, "Итвио") + putString(CallkitConstants.EXTRA_CALLKIT_HANDLE, conversationUuid) + putString(CallkitConstants.EXTRA_CALLKIT_AVATAR, avatarUrl) + putInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 0) + putLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45000L) + putString(CallkitConstants.EXTRA_CALLKIT_TEXT_ACCEPT, "Answer") + putString(CallkitConstants.EXTRA_CALLKIT_TEXT_DECLINE, "Decline") + putSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA, extra) + putSerializable(CallkitConstants.EXTRA_CALLKIT_HEADERS, HashMap()) + putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_CUSTOM_NOTIFICATION, true) + putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_CUSTOM_SMALL_EX_NOTIFICATION, false) + putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_SHOW_LOGO, false) + putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_SHOW_CALL_ID, false) + putString(CallkitConstants.EXTRA_CALLKIT_RINGTONE_PATH, "system_ringtone_default") + putString(CallkitConstants.EXTRA_CALLKIT_BACKGROUND_COLOR, "#2787F5") + putString(CallkitConstants.EXTRA_CALLKIT_ACTION_COLOR, "#2787F5") + putString(CallkitConstants.EXTRA_CALLKIT_TEXT_COLOR, "#ffffff") + putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_SHOW_FULL_LOCKED_SCREEN, false) + putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_FULL_SCREEN, false) + } + + val intent = com.hiennv.flutter_callkit_incoming.CallkitIncomingBroadcastReceiver + .getIntent(this, CallkitConstants.ACTION_CALL_INCOMING, callData) + .addFlags(Intent.FLAG_RECEIVER_FOREGROUND) + sendBroadcast(intent) + } } diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index a1632593..63277f90 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -86,6 +86,20 @@ import flutter_foreground_task } let dict = payload.dictionaryPayload + if let pushType = ( + dict["type"] as? String + ?? dict["notification_type"] as? String + ?? dict["event"] as? String + ?? dict["action"] as? String + )?.lowercased().replacingOccurrences(of: "-", with: "_"), + pushType.contains("video_call"), + pushType.contains("end") || pushType.contains("cancel") + { + SwiftFlutterCallkitIncomingPlugin.sharedInstance?.endAllCalls() + completion() + return + } + let nameCaller = (dict["nameCaller"] as? String) ?? (dict["caller_name"] as? String) ?? (dict["callerName"] as? String) 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 47a310c0..8b4f63cf 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -14,7 +14,6 @@ import 'package:flutter_callkit_incoming/entities/call_event.dart'; import 'package:flutter_callkit_incoming/entities/call_kit_params.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; - class VideoCallCallKitCoordinator { VideoCallCallKitCoordinator({ required FirebaseApi firebaseApi, @@ -29,6 +28,7 @@ class VideoCallCallKitCoordinator { final VideoCallSessionCubit? _videoCallSessionCubit; StreamSubscription? _eventSubscription; + AppLifecycleListener? _lifecycleListener; final Map _callKitParamsById = {}; final Set _acceptHandledCallKitIds = {}; final Set _suppressedEndCallKitIds = {}; @@ -43,13 +43,22 @@ class VideoCallCallKitCoordinator { if (Platform.isIOS) { await _firebaseApi.registerPushToken(); + _lifecycleListener = AppLifecycleListener( + onResume: () => unawaited(_openActiveCallKitCallIfNeeded()), + ); + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_openActiveCallKitCallIfNeeded()); + }); return; } if (Platform.isAndroid) { VideoCallFcmHandler.registerForegroundListener(); FlutterCallkitIncoming.acceptCallHandle(_onNativeAcceptCallHandle); - await FlutterCallkitIncoming.requestNotificationPermission({ + await FlutterCallkitIncoming.requestNotificationPermission(< + String, + dynamic + >{ 'title': 'Уведомления', 'rationaleMessagePermission': 'Разрешите уведомления, чтобы получать входящие видеозвонки.', @@ -61,13 +70,14 @@ class VideoCallCallKitCoordinator { void dispose() { unawaited(_eventSubscription?.cancel()); + _lifecycleListener?.dispose(); _eventSubscription = null; + _lifecycleListener = null; _callKitParamsById.clear(); _initialized = false; } - VideoCallManager get _manager => - _videoCallManager ?? sl(); + VideoCallManager get _manager => _videoCallManager ?? sl(); VideoCallSessionCubit get _cubit => _videoCallSessionCubit ?? sl(); @@ -102,6 +112,49 @@ class VideoCallCallKitCoordinator { CallKitParams? _paramsForCallId(String id) => _callKitParamsById[id]; + Future _openActiveCallKitCallIfNeeded() async { + if (!Platform.isIOS) return; + if (_cubit.state.connectPhase != VideoCallConnectPhase.idle) return; + + final activeCalls = await FlutterCallkitIncoming.activeCalls(); + if (activeCalls.isEmpty) return; + + for (final params in activeCalls) { + _cacheCallKitParams(params); + final extra = params.extra ?? {}; + final conversationUuid = _parseConversationUuid( + extra, + handle: params.handle, + ); + if (conversationUuid.isEmpty) continue; + + await _deliverIncomingAccept( + callKitId: params.id, + conversationUuid: conversationUuid, + counterpartName: + _parseString(extra, const [ + 'counterpartName', + 'caller_name', + 'callerName', + ]) ?? + params.nameCaller, + counterpartAvatarUrl: + _parseString(extra, const [ + 'counterpartAvatarUrl', + 'avatar', + 'avatarUrl', + 'caller_avatar', + ]) ?? + params.avatar, + localParticipantName: _parseString(extra, const [ + 'localParticipantName', + 'local_participant_name', + ]), + ); + return; + } + } + Future _onNativeAcceptCallHandle(Map body) async { final extra = Map.from(body); final conversationUuid = _parseConversationUuid(extra); @@ -156,17 +209,21 @@ class VideoCallCallKitCoordinator { await _deliverIncomingAccept( callKitId: callKitId, conversationUuid: conversationUuid, - counterpartName: _parseString(extra, const [ - 'counterpartName', - 'caller_name', - 'callerName', - ]) ?? params?.nameCaller, - counterpartAvatarUrl: _parseString(extra, const [ - 'counterpartAvatarUrl', - 'avatar', - 'avatarUrl', - 'caller_avatar', - ]) ?? params?.avatar, + counterpartName: + _parseString(extra, const [ + 'counterpartName', + 'caller_name', + 'callerName', + ]) ?? + params?.nameCaller, + counterpartAvatarUrl: + _parseString(extra, const [ + 'counterpartAvatarUrl', + 'avatar', + 'avatarUrl', + 'caller_avatar', + ]) ?? + params?.avatar, localParticipantName: _parseString(extra, const [ 'localParticipantName', 'local_participant_name', @@ -191,12 +248,14 @@ class VideoCallCallKitCoordinator { final binding = WidgetsBinding.instance; final completer = Completer(); binding.addPostFrameCallback((_) { - unawaited(_openConnectingScreenAfterAccept( - conversationUuid: conversationUuid, - counterpartName: counterpartName, - counterpartAvatarUrl: counterpartAvatarUrl, - localParticipantName: localParticipantName, - ).whenComplete(completer.complete)); + unawaited( + _openConnectingScreenAfterAccept( + conversationUuid: conversationUuid, + counterpartName: counterpartName, + counterpartAvatarUrl: counterpartAvatarUrl, + localParticipantName: localParticipantName, + ).whenComplete(completer.complete), + ); }); await completer.future; } finally { @@ -284,10 +343,7 @@ class VideoCallCallKitCoordinator { return ''; } - static String? _parseString( - Map map, - List keys, - ) { + static String? _parseString(Map map, List keys) { for (final key in keys) { final value = map[key]; if (value is String && value.trim().isNotEmpty) { diff --git a/lib/features/video_calls/services/video_call_notification_navigator.dart b/lib/features/video_calls/services/video_call_notification_navigator.dart index 09d88086..5ffbd983 100644 --- a/lib/features/video_calls/services/video_call_notification_navigator.dart +++ b/lib/features/video_calls/services/video_call_notification_navigator.dart @@ -47,7 +47,7 @@ class VideoCallNotificationNavigator { String? counterpartAvatarUrl, String? localParticipantName, }) async { - for (var attempt = 0; attempt < 40; attempt++) { + for (var attempt = 0; attempt < 100; attempt++) { final ctx = AppRouter.navigatorKey.currentContext; if (ctx != null && ctx.mounted) { final router = GoRouter.of(ctx); @@ -68,7 +68,7 @@ class VideoCallNotificationNavigator { } return; } - await Future.delayed(const Duration(milliseconds: 50)); + await Future.delayed(const Duration(milliseconds: 100)); } } } diff --git a/pubspec.yaml b/pubspec.yaml index cfd60cd9..8f353be8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.26+107 +version: 1.3.27+108 publish_to: 'none' environment: -- 2.49.1 From 705538ac85d11749abcfa6312cea93fde3c8e3f5 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 16 Jun 2026 17:56:59 +0300 Subject: [PATCH 26/88] fix: added NSLocationWhenInUseUsageDescription key description --- ios/Runner/Info.plist | 2 +- pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index f217c73d..9a4dbd70 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -38,7 +38,7 @@ NSMicrophoneUsageDescription Нужен для звука в видеозвонках с консультантом NSLocationWhenInUseUsageDescription - + Может использоваться для стабильного поддержания связи во время звонка. NSPhotoLibraryUsageDescription Предоставление доступа позволит Вам добавлять фото к записям UIApplicationSupportsIndirectInputEvents diff --git a/pubspec.yaml b/pubspec.yaml index 8f353be8..7a6062eb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.27+108 +version: 1.3.27+109 publish_to: 'none' environment: -- 2.49.1 From e0d697e8d0055eb194c3f47f33184da13258bc52 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 16 Jun 2026 18:30:16 +0300 Subject: [PATCH 27/88] feat(video_calls): fixed ios push UI --- ios/Runner/AppDelegate.swift | 74 ++++++++++++++++--- .../video_call_callkit_coordinator.dart | 17 ++++- pubspec.yaml | 2 +- 3 files changed, 78 insertions(+), 15 deletions(-) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 63277f90..c800247d 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -60,6 +60,34 @@ import flutter_foreground_task ) } + private func conversationUuid(fromActionUrl actionUrl: String?) -> String? { + guard + let actionUrl = actionUrl, + let components = URLComponents(string: actionUrl) + else { + return nil + } + + let queryItems = components.queryItems ?? [] + for key in ["uuid", "conversationUuid", "conversation_uuid"] { + if let value = queryItems.first(where: { $0.name == key })?.value, + !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return value.trimmingCharacters(in: .whitespacesAndNewlines) + } + } + + let segments = components.path.split(separator: "/").map(String.init) + for index in segments.indices where segments[index] == "video-call" { + let nextIndex = segments.index(after: index) + if segments.indices.contains(nextIndex), !segments[nextIndex].isEmpty { + return segments[nextIndex] + } + } + + return nil + } + func pushRegistry( _ registry: PKPushRegistry, @@ -86,32 +114,42 @@ import flutter_foreground_task } let dict = payload.dictionaryPayload - if let pushType = ( - dict["type"] as? String - ?? dict["notification_type"] as? String - ?? dict["event"] as? String - ?? dict["action"] as? String - )?.lowercased().replacingOccurrences(of: "-", with: "_"), - pushType.contains("video_call"), - pushType.contains("end") || pushType.contains("cancel") - { + let pushMarkers = [ + dict["type"] as? String, + dict["notification_type"] as? String, + dict["event"] as? String, + dict["action"] as? String, + dict["push_type"] as? String, + dict["call_type"] as? String, + ] + .compactMap { $0?.lowercased().replacingOccurrences(of: "-", with: "_") } + + if pushMarkers.contains(where: { $0.contains("video_call") }) + && pushMarkers.contains(where: { $0.contains("end") || $0.contains("cancel") }) { SwiftFlutterCallkitIncomingPlugin.sharedInstance?.endAllCalls() completion() return } let nameCaller = (dict["nameCaller"] as? String) + ?? (dict["counterpartName"] as? String) + ?? (dict["counterpart_name"] as? String) ?? (dict["caller_name"] as? String) ?? (dict["callerName"] as? String) + ?? (dict["caller"] as? String) ?? "" let conversationUuid = (dict["conversation_uuid"] as? String) ?? (dict["conversationUuid"] as? String) + ?? (dict["conversation_id"] as? String) + ?? (dict["chat_uuid"] as? String) + ?? (dict["uuid"] as? String) + ?? conversationUuid(fromActionUrl: dict["action_url"] as? String) ?? (dict["handle"] as? String) ?? "" let id = [ - dict["id"] as? String, - dict["call_id"] as? String, conversationUuid, + dict["call_id"] as? String, + dict["id"] as? String, dict["message_id"] as? String, ] .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } @@ -127,6 +165,9 @@ import flutter_foreground_task } if let avatar = dict["caller_avatar"] as? String ?? dict["avatar"] as? String ?? dict["avatarUrl"] as? String + ?? dict["avatar_url"] as? String + ?? dict["counterpartAvatarUrl"] as? String + ?? dict["counterpart_avatar_url"] as? String { extra["counterpartAvatarUrl"] = avatar } @@ -143,6 +184,8 @@ import flutter_foreground_task } if let counterpartName = dict["counterpartName"] as? String { extra["counterpartName"] = counterpartName + } else if let counterpartName = dict["counterpart_name"] as? String { + extra["counterpartName"] = counterpartName } else if !nameCaller.isEmpty { extra["counterpartName"] = nameCaller } @@ -151,8 +194,15 @@ import flutter_foreground_task id: id, nameCaller: nameCaller, handle: conversationUuid.isEmpty ? nameCaller : conversationUuid, - type: isVideo ? 1 : 0 + type: 0 ) + data.appName = "Итвио" + data.duration = 45000 + data.handleType = "generic" + data.supportsVideo = isVideo + data.ringtonePath = "system_ringtone_default" + data.configureAudioSession = false + data.includesCallsInRecents = false data.extra = extra as NSDictionary SwiftFlutterCallkitIncomingPlugin.sharedInstance?.showCallkitIncoming( 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 8b4f63cf..860525b3 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -112,6 +112,18 @@ class VideoCallCallKitCoordinator { CallKitParams? _paramsForCallId(String id) => _callKitParamsById[id]; + Future _paramsForCallIdOrActiveCall(String id) async { + final cached = _paramsForCallId(id); + if (cached != null || !Platform.isIOS) return cached; + + final activeCalls = await FlutterCallkitIncoming.activeCalls(); + for (final params in activeCalls) { + _cacheCallKitParams(params); + } + + return _paramsForCallId(id); + } + Future _openActiveCallKitCallIfNeeded() async { if (!Platform.isIOS) return; if (_cubit.state.connectPhase != VideoCallConnectPhase.idle) return; @@ -121,6 +133,7 @@ class VideoCallCallKitCoordinator { for (final params in activeCalls) { _cacheCallKitParams(params); + if (!params.isAccepted) continue; final extra = params.extra ?? {}; final conversationUuid = _parseConversationUuid( extra, @@ -191,12 +204,12 @@ class VideoCallCallKitCoordinator { if (_acceptHandledCallKitIds.contains(callKitId)) return; _activeCallKitId = callKitId; - final params = _paramsForCallId(callKitId); + final params = await _paramsForCallIdOrActiveCall(callKitId); final extra = params?.extra ?? {}; final conversationUuid = _parseConversationUuid( extra, - handle: params?.handle, + handle: params?.handle ?? callKitId, ); if (conversationUuid.isEmpty) { log( diff --git a/pubspec.yaml b/pubspec.yaml index 7a6062eb..cbc66a07 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.27+109 +version: 1.3.27+110 publish_to: 'none' environment: -- 2.49.1 From b13ff7422099983929125b20aa2439a1fdfec680 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 16 Jun 2026 18:33:21 +0300 Subject: [PATCH 28/88] fix: added NSLocationAlwaysAndWhenInUseUsageDescription key description --- ios/Runner/Info.plist | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 9a4dbd70..c35ead46 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -37,6 +37,8 @@ Предоставление доступа позволит Вам делать фотографии и добавлять к записям NSMicrophoneUsageDescription Нужен для звука в видеозвонках с консультантом + NSLocationAlwaysAndWhenInUseUsageDescription + Может использоваться для стабильного поддержания связи во время звонка. NSLocationWhenInUseUsageDescription Может использоваться для стабильного поддержания связи во время звонка. NSPhotoLibraryUsageDescription -- 2.49.1 From e82866f936c565e3b1f143537a7a9180afead8f1 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 16 Jun 2026 19:28:16 +0300 Subject: [PATCH 29/88] feat(video_calls): voip push handling fix --- ios/Runner/AppDelegate.swift | 117 ++++++++++++++-- .../video_call_callkit_coordinator.dart | 127 +++++++++++------- 2 files changed, 183 insertions(+), 61 deletions(-) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c800247d..4fa2445b 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -11,6 +11,9 @@ import flutter_foreground_task @main @objc class AppDelegate: FlutterAppDelegate, PKPushRegistryDelegate, CallkitIncomingAppDelegate { private var voipRegistry: PKPushRegistry? + private var voipDiagnosticsChannel: FlutterMethodChannel? + private let voipDiagnosticsStorageKey = "EatvioVoipDiagnostics" + private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics" override func application( _ application: UIApplication, @@ -30,7 +33,24 @@ import flutter_foreground_task UNUserNotificationCenter.current().delegate = self } - return super.application(application, didFinishLaunchingWithOptions: launchOptions) + let launched = super.application(application, didFinishLaunchingWithOptions: launchOptions) + + if let controller = window?.rootViewController as? FlutterViewController { + let channel = FlutterMethodChannel( + name: voipDiagnosticsChannelName, + binaryMessenger: controller.binaryMessenger + ) + voipDiagnosticsChannel = channel + channel.setMethodCallHandler { [weak self] call, result in + guard call.method == "drainVoipDiagnostics" else { + result(FlutterMethodNotImplemented) + return + } + result(self?.drainVoipDiagnostics() ?? []) + } + } + + return launched } @available(iOS 8.0, *) @@ -88,6 +108,54 @@ import flutter_foreground_task return nil } + private func sanitizedDiagnosticValue(_ value: Any?) -> Any { + guard let value = value else { + return "" + } + if let string = value as? String { + return string + } + if let number = value as? NSNumber { + return number + } + if let bool = value as? Bool { + return bool + } + if let dictionary = value as? [AnyHashable: Any] { + return dictionary.reduce(into: [String: Any]()) { result, entry in + result[String(describing: entry.key)] = sanitizedDiagnosticValue(entry.value) + } + } + if let array = value as? [Any] { + return array.map { sanitizedDiagnosticValue($0) } + } + return String(describing: value) + } + + private func recordVoipDiagnostic(_ name: String, data: [String: Any]) { + let defaults = UserDefaults.standard + var events = defaults.array(forKey: voipDiagnosticsStorageKey) as? [[String: Any]] ?? [] + var event: [String: Any] = [ + "name": name, + "timestamp": ISO8601DateFormatter().string(from: Date()), + "appState": UIApplication.shared.applicationState.rawValue, + ] + event["data"] = sanitizedDiagnosticValue(data) + events.append(event) + if events.count > 20 { + events = Array(events.suffix(20)) + } + defaults.set(events, forKey: voipDiagnosticsStorageKey) + voipDiagnosticsChannel?.invokeMethod("voipDiagnosticsUpdated", arguments: nil) + } + + private func drainVoipDiagnostics() -> [[String: Any]] { + let defaults = UserDefaults.standard + let events = defaults.array(forKey: voipDiagnosticsStorageKey) as? [[String: Any]] ?? [] + defaults.removeObject(forKey: voipDiagnosticsStorageKey) + return events + } + func pushRegistry( _ registry: PKPushRegistry, @@ -126,6 +194,13 @@ import flutter_foreground_task if pushMarkers.contains(where: { $0.contains("video_call") }) && pushMarkers.contains(where: { $0.contains("end") || $0.contains("cancel") }) { + recordVoipDiagnostic( + "voip_end_or_cancel_received", + data: [ + "markers": pushMarkers, + "payload": dict, + ] + ) SwiftFlutterCallkitIncomingPlugin.sharedInstance?.endAllCalls() completion() return @@ -147,10 +222,10 @@ import flutter_foreground_task ?? (dict["handle"] as? String) ?? "" let id = [ - conversationUuid, dict["call_id"] as? String, dict["id"] as? String, dict["message_id"] as? String, + conversationUuid, ] .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } .first { !$0.isEmpty && UUID(uuidString: $0) != nil } @@ -194,21 +269,43 @@ import flutter_foreground_task id: id, nameCaller: nameCaller, handle: conversationUuid.isEmpty ? nameCaller : conversationUuid, - type: 0 + type: isVideo ? 1 : 0 ) - data.appName = "Итвио" - data.duration = 45000 - data.handleType = "generic" - data.supportsVideo = isVideo - data.ringtonePath = "system_ringtone_default" - data.configureAudioSession = false - data.includesCallsInRecents = false data.extra = extra as NSDictionary + NSLog( + "[EatvioVoIP] report incoming call id=\(id) conversation=\(conversationUuid) state=\(UIApplication.shared.applicationState.rawValue) markers=\(pushMarkers.joined(separator: ",")) payload=\(dict)" + ) + recordVoipDiagnostic( + "voip_incoming_received", + data: [ + "callKitId": id, + "conversationUuid": conversationUuid, + "nameCaller": nameCaller, + "isVideo": isVideo, + "markers": pushMarkers, + "payload": dict, + ] + ) + SwiftFlutterCallkitIncomingPlugin.sharedInstance?.showCallkitIncoming( data, fromPushKit: true ) { + let activeCalls = SwiftFlutterCallkitIncomingPlugin.sharedInstance?.activeCalls() ?? [] + let activeIds = activeCalls.compactMap { $0["id"] as? String }.joined(separator: ",") + NSLog( + "[EatvioVoIP] report incoming completed id=\(id) activeCallIds=\(activeIds)" + ) + self.recordVoipDiagnostic( + "callkit_report_completed", + data: [ + "callKitId": id, + "conversationUuid": conversationUuid, + "activeCallIds": activeIds, + "activeCallsCount": activeCalls.count, + ] + ) completion() } } 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 860525b3..a6006f7d 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -9,10 +9,12 @@ import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_ses import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_fcm_handler.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart'; +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_callkit_incoming/entities/call_event.dart'; import 'package:flutter_callkit_incoming/entities/call_kit_params.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; class VideoCallCallKitCoordinator { VideoCallCallKitCoordinator({ @@ -28,12 +30,16 @@ class VideoCallCallKitCoordinator { final VideoCallSessionCubit? _videoCallSessionCubit; StreamSubscription? _eventSubscription; - AppLifecycleListener? _lifecycleListener; final Map _callKitParamsById = {}; final Set _acceptHandledCallKitIds = {}; final Set _suppressedEndCallKitIds = {}; String? _activeCallKitId; bool _initialized = false; + bool _nativeDiagnosticsDrainScheduled = false; + + static const MethodChannel _voipDiagnosticsChannel = MethodChannel( + 'com.eatvioapp/voip_diagnostics', + ); Future initialize() async { if (_initialized) return; @@ -42,12 +48,14 @@ class VideoCallCallKitCoordinator { _eventSubscription = FlutterCallkitIncoming.onEvent.listen(_onCallKitEvent); if (Platform.isIOS) { + _voipDiagnosticsChannel.setMethodCallHandler((call) async { + if (call.method == 'voipDiagnosticsUpdated') { + _scheduleNativeVoipDiagnosticsDrain(); + } + }); await _firebaseApi.registerPushToken(); - _lifecycleListener = AppLifecycleListener( - onResume: () => unawaited(_openActiveCallKitCallIfNeeded()), - ); WidgetsBinding.instance.addPostFrameCallback((_) { - unawaited(_openActiveCallKitCallIfNeeded()); + _scheduleNativeVoipDiagnosticsDrain(); }); return; } @@ -70,9 +78,7 @@ class VideoCallCallKitCoordinator { void dispose() { unawaited(_eventSubscription?.cancel()); - _lifecycleListener?.dispose(); _eventSubscription = null; - _lifecycleListener = null; _callKitParamsById.clear(); _initialized = false; } @@ -82,6 +88,52 @@ class VideoCallCallKitCoordinator { VideoCallSessionCubit get _cubit => _videoCallSessionCubit ?? sl(); + void _scheduleNativeVoipDiagnosticsDrain() { + if (_nativeDiagnosticsDrainScheduled) return; + _nativeDiagnosticsDrainScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _nativeDiagnosticsDrainScheduled = false; + unawaited(_drainNativeVoipDiagnosticsToSentry()); + }); + } + + Future _drainNativeVoipDiagnosticsToSentry() async { + if (!Platform.isIOS) return; + try { + final events = await _voipDiagnosticsChannel.invokeListMethod( + 'drainVoipDiagnostics', + ); + if (events == null || events.isEmpty) return; + + for (final rawEvent in events) { + final event = _stringKeyedMap(rawEvent); + final name = event['name']?.toString() ?? 'unknown'; + await Sentry.captureMessage( + 'iOS VoIP diagnostic: $name', + level: SentryLevel.info, + withScope: (scope) { + scope.setTag('origin', 'iOS VoIP'); + scope.setTag('voip_diagnostic', 'true'); + scope.setTag('voip_event', name); + scope.setContexts('voip_diagnostic', _stringifyMapValues(event)); + }, + ); + } + } on PlatformException catch (e, st) { + log( + 'VideoCallCallKitCoordinator: failed to drain native VoIP diagnostics: $e', + name: 'VideoCallCallKit', + stackTrace: st, + ); + } catch (e, st) { + log( + 'VideoCallCallKitCoordinator: failed to send native VoIP diagnostics: $e', + name: 'VideoCallCallKit', + stackTrace: st, + ); + } + } + Future _onCallKitEvent(CallEvent? event) async { if (event == null) return; @@ -124,50 +176,6 @@ class VideoCallCallKitCoordinator { return _paramsForCallId(id); } - Future _openActiveCallKitCallIfNeeded() async { - if (!Platform.isIOS) return; - if (_cubit.state.connectPhase != VideoCallConnectPhase.idle) return; - - final activeCalls = await FlutterCallkitIncoming.activeCalls(); - if (activeCalls.isEmpty) return; - - for (final params in activeCalls) { - _cacheCallKitParams(params); - if (!params.isAccepted) continue; - final extra = params.extra ?? {}; - final conversationUuid = _parseConversationUuid( - extra, - handle: params.handle, - ); - if (conversationUuid.isEmpty) continue; - - await _deliverIncomingAccept( - callKitId: params.id, - conversationUuid: conversationUuid, - counterpartName: - _parseString(extra, const [ - 'counterpartName', - 'caller_name', - 'callerName', - ]) ?? - params.nameCaller, - counterpartAvatarUrl: - _parseString(extra, const [ - 'counterpartAvatarUrl', - 'avatar', - 'avatarUrl', - 'caller_avatar', - ]) ?? - params.avatar, - localParticipantName: _parseString(extra, const [ - 'localParticipantName', - 'local_participant_name', - ]), - ); - return; - } - } - Future _onNativeAcceptCallHandle(Map body) async { final extra = Map.from(body); final conversationUuid = _parseConversationUuid(extra); @@ -365,4 +373,21 @@ class VideoCallCallKitCoordinator { } return null; } + + static Map _stringKeyedMap(Object? value) { + if (value is! Map) return {}; + return value.map((key, value) => MapEntry(key.toString(), value)); + } + + static Map _stringifyMapValues(Map map) { + return map.map((key, value) { + if (value is Map) { + return MapEntry(key, _stringifyMapValues(_stringKeyedMap(value))); + } + if (value is Iterable) { + return MapEntry(key, value.map((item) => item?.toString()).toList()); + } + return MapEntry(key, value?.toString()); + }); + } } -- 2.49.1 From 367753d40971091e09958def3ccd5ea65793423d Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 16 Jun 2026 19:30:01 +0300 Subject: [PATCH 30/88] Version change --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index cbc66a07..c6783736 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.27+110 +version: 1.3.27+111 publish_to: 'none' environment: -- 2.49.1 From 895443e747e070363fb043bea538ec77f43c8e24 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 16 Jun 2026 21:11:45 +0300 Subject: [PATCH 31/88] feat(video_calls): added more logs for sentry --- ios/Runner/AppDelegate.swift | 53 +++++++++++++++---- .../video_call_callkit_coordinator.dart | 37 +++++++++++++ pubspec.yaml | 2 +- 3 files changed, 81 insertions(+), 11 deletions(-) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 4fa2445b..e2f16042 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -221,15 +221,19 @@ import flutter_foreground_task ?? conversationUuid(fromActionUrl: dict["action_url"] as? String) ?? (dict["handle"] as? String) ?? "" - let id = [ - dict["call_id"] as? String, - dict["id"] as? String, - dict["message_id"] as? String, - conversationUuid, + let callKitIdSource = [ + (key: "call_id", value: dict["call_id"] as? String), + (key: "id", value: dict["id"] as? String), + (key: "message_id", value: dict["message_id"] as? String), ] - .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } - .first { !$0.isEmpty && UUID(uuidString: $0) != nil } - ?? UUID().uuidString + .first { item in + guard let trimmed = item.value?.trimmingCharacters(in: .whitespacesAndNewlines) else { + return false + } + return !trimmed.isEmpty && UUID(uuidString: trimmed) != nil + } + let id = callKitIdSource?.value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? UUID().uuidString + let callKitIdSourceName = callKitIdSource?.key ?? "generated" let isVideo = (dict["isVideo"] as? Bool) ?? (dict["is_video"] as? Bool) ?? true @@ -280,6 +284,7 @@ import flutter_foreground_task "voip_incoming_received", data: [ "callKitId": id, + "callKitIdSource": callKitIdSourceName, "conversationUuid": conversationUuid, "nameCaller": nameCaller, "isVideo": isVideo, @@ -288,11 +293,24 @@ import flutter_foreground_task ] ) - SwiftFlutterCallkitIncomingPlugin.sharedInstance?.showCallkitIncoming( + guard let callkitPlugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance else { + NSLog("[EatvioVoIP] CallKit plugin missing id=\(id)") + recordVoipDiagnostic( + "callkit_plugin_missing", + data: [ + "callKitId": id, + "conversationUuid": conversationUuid, + ] + ) + completion() + return + } + + callkitPlugin.showCallkitIncoming( data, fromPushKit: true ) { - let activeCalls = SwiftFlutterCallkitIncomingPlugin.sharedInstance?.activeCalls() ?? [] + let activeCalls = callkitPlugin.activeCalls() let activeIds = activeCalls.compactMap { $0["id"] as? String }.joined(separator: ",") NSLog( "[EatvioVoIP] report incoming completed id=\(id) activeCallIds=\(activeIds)" @@ -301,11 +319,26 @@ import flutter_foreground_task "callkit_report_completed", data: [ "callKitId": id, + "callKitIdSource": callKitIdSourceName, "conversationUuid": conversationUuid, "activeCallIds": activeIds, "activeCallsCount": activeCalls.count, ] ) + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + let delayedActiveCalls = callkitPlugin.activeCalls() + let delayedActiveIds = delayedActiveCalls.compactMap { $0["id"] as? String }.joined(separator: ",") + self.recordVoipDiagnostic( + "callkit_active_calls_delayed", + data: [ + "callKitId": id, + "callKitIdSource": callKitIdSourceName, + "conversationUuid": conversationUuid, + "activeCallIds": delayedActiveIds, + "activeCallsCount": delayedActiveCalls.count, + ] + ) + } completion() } } 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 a6006f7d..02c7c3fd 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -136,6 +136,7 @@ class VideoCallCallKitCoordinator { Future _onCallKitEvent(CallEvent? event) async { if (event == null) return; + unawaited(_captureCallKitEventToSentry(event)); switch (event) { case CallEventActionDidUpdateDevicePushTokenVoip(): @@ -157,6 +158,42 @@ class VideoCallCallKitCoordinator { } } + Future _captureCallKitEventToSentry(CallEvent event) async { + if (!Platform.isIOS) return; + final context = { + 'eventName': event.eventName, + 'eventType': event.runtimeType.toString(), + }; + switch (event) { + case CallEventActionCallIncoming(:final callKitParams): + context.addAll({ + 'id': callKitParams.id, + 'handle': callKitParams.handle, + 'nameCaller': callKitParams.nameCaller, + 'isAccepted': callKitParams.isAccepted, + 'extra': callKitParams.extra?.toString(), + }); + case CallEventActionCallAccept(:final id): + case CallEventActionCallCallback(:final id): + case CallEventActionCallDecline(:final id): + case CallEventActionCallEnded(:final id): + case CallEventActionCallTimeout(:final id): + context['id'] = id; + default: + break; + } + + await Sentry.captureMessage( + 'iOS CallKit event: ${event.runtimeType}', + level: SentryLevel.info, + withScope: (scope) { + scope.setTag('origin', 'iOS CallKit'); + scope.setTag('callkit_event', event.runtimeType.toString()); + scope.setContexts('callkit_event', context); + }, + ); + } + void _cacheCallKitParams(CallKitParams params) { _callKitParamsById[params.id] = params; _activeCallKitId = params.id; diff --git a/pubspec.yaml b/pubspec.yaml index c6783736..209f5b05 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.27+111 +version: 1.3.27+112 publish_to: 'none' environment: -- 2.49.1 From 95c1d40c791ce024aafa17d62f6fab5561be8e03 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 16 Jun 2026 22:57:17 +0300 Subject: [PATCH 32/88] feat(video_calls): fixed nullable overlay state handling --- .../presentation/widgets/video_call_top_overlay.dart | 10 ++++++---- pubspec.yaml | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart b/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart index 1f0bff71..85f2dd0e 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart @@ -2,12 +2,11 @@ import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; - abstract final class VideoCallTopOverlay { - static const String poorConnectionSvg = 'assets/icons/video_call/poor_connection.svg'; + static const String poorConnectionSvg = + 'assets/icons/video_call/poor_connection.svg'; static const String warningSvg = 'assets/icons/video_call/warning.svg'; - static OverlayEntry insertBanner( BuildContext context, { required String message, @@ -15,7 +14,7 @@ abstract final class VideoCallTopOverlay { required String svgAsset, Duration? autoRemoveAfter, }) { - final overlayState = Overlay.of(context, rootOverlay: true); + final overlayState = Overlay.maybeOf(context, rootOverlay: true); late OverlayEntry entry; entry = OverlayEntry( builder: (ctx) => Positioned( @@ -50,6 +49,9 @@ abstract final class VideoCallTopOverlay { ), ), ); + if (overlayState == null) { + return entry; + } overlayState.insert(entry); if (autoRemoveAfter != null) { Future.delayed(autoRemoveAfter, () { diff --git a/pubspec.yaml b/pubspec.yaml index 209f5b05..96f67cf1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.27+112 +version: 1.3.27+113 publish_to: 'none' environment: -- 2.49.1 From 65d529b0afb4dc272d60051115773c51f0c145e1 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 17 Jun 2026 11:06:22 +0300 Subject: [PATCH 33/88] feat(video_calls): fixed navigation from voip push --- ios/Runner/AppDelegate.swift | 113 ++++++++++++++++++ ios/Runner/Info.plist | 8 ++ .../video_call_callkit_coordinator.dart | 62 +++++++++- pubspec.yaml | 2 +- .../widgets/video_call_top_overlay_test.dart | 33 ++++- 5 files changed, 210 insertions(+), 8 deletions(-) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index e2f16042..ed5fa3dd 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -14,6 +14,7 @@ import flutter_foreground_task private var voipDiagnosticsChannel: FlutterMethodChannel? private let voipDiagnosticsStorageKey = "EatvioVoipDiagnostics" private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics" + private let appUrlScheme = "eatvio" override func application( _ application: UIApplication, @@ -74,6 +75,37 @@ import flutter_foreground_task open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:] ) -> Bool { + if url.scheme == appUrlScheme { + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + let queryItems = components?.queryItems ?? [] + let conversationUuid = queryItems.first { $0.name == "conversationUuid" }?.value? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let callKitId = queryItems.first { $0.name == "callKitId" }?.value? + .trimmingCharacters(in: .whitespacesAndNewlines) + recordVoipDiagnostic( + "accepted_call_url_opened", + data: [ + "callKitId": callKitId ?? "", + "hasCallKitId": callKitId != nil, + "conversationUuid": conversationUuid, + "url": url.absoluteString, + ] + ) + if let callKitId, !callKitId.isEmpty, !conversationUuid.isEmpty { + voipDiagnosticsChannel?.invokeMethod( + "acceptedVoipCall", + arguments: [ + "id": callKitId, + "handle": conversationUuid, + "extra": [ + "conversationUuid": conversationUuid + ], + ] + ) + } + return true + } + return YXLSdk.shared.handleOpen( url, sourceApplication: options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String @@ -108,6 +140,75 @@ import flutter_foreground_task return nil } + private func conversationUuid(fromCall call: Call) -> String? { + let extra = call.data.extra as? [String: Any] ?? [:] + for key in ["conversationUuid", "conversation_uuid", "conversation_id", "chat_uuid", "uuid"] { + if let value = extra[key] as? String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + } + } + + let handle = call.data.handle.trimmingCharacters(in: .whitespacesAndNewlines) + if !handle.isEmpty { + return handle + } + + return nil + } + + 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] + ) + return + } + + var components = URLComponents() + components.scheme = appUrlScheme + components.host = "video-call" + components.queryItems = [ + URLQueryItem(name: "callKitId", value: call.data.uuid), + URLQueryItem(name: "conversationUuid", value: conversationUuid) + ] + + guard let url = components.url else { + recordVoipDiagnostic( + "accepted_call_url_build_failed", + data: [ + "callKitId": call.data.uuid, + "conversationUuid": conversationUuid, + ] + ) + return + } + + recordVoipDiagnostic( + "accepted_call_open_app_requested", + data: [ + "callKitId": call.data.uuid, + "conversationUuid": conversationUuid, + "url": url.absoluteString, + "appState": UIApplication.shared.applicationState.rawValue, + ] + ) + + 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, + ] + ) + } + } + private func sanitizedDiagnosticValue(_ value: Any?) -> Any { guard let value = value else { return "" @@ -345,6 +446,18 @@ import flutter_foreground_task func onAccept(_ call: Call, _ action: CXAnswerCallAction) { + recordVoipDiagnostic( + "callkit_accept_native", + data: [ + "callKitId": call.data.uuid, + "conversationUuid": conversationUuid(fromCall: call) ?? "", + "data": call.data.toJSON(), + ] + ) + voipDiagnosticsChannel?.invokeMethod("acceptedVoipCall", arguments: call.data.toJSON()) + DispatchQueue.main.async { [weak self] in + self?.openAppForAcceptedCall(call) + } action.fulfill() } diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index c35ead46..5bcdccbd 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -91,6 +91,14 @@ yx5399b03636104c6d920b87c1ad329254 + + CFBundleURLName + Eatvio + CFBundleURLSchemes + + eatvio + + 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 02c7c3fd..8c28fbab 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -49,8 +49,11 @@ class VideoCallCallKitCoordinator { if (Platform.isIOS) { _voipDiagnosticsChannel.setMethodCallHandler((call) async { - if (call.method == 'voipDiagnosticsUpdated') { - _scheduleNativeVoipDiagnosticsDrain(); + switch (call.method) { + case 'voipDiagnosticsUpdated': + _scheduleNativeVoipDiagnosticsDrain(); + case 'acceptedVoipCall': + await _handleNativeAcceptedVoipCall(call.arguments); } }); await _firebaseApi.registerPushToken(); @@ -245,6 +248,57 @@ class VideoCallCallKitCoordinator { ); } + Future _handleNativeAcceptedVoipCall(Object? arguments) async { + if (!Platform.isIOS) return; + + final data = _stringKeyedMap(arguments); + final extra = _stringKeyedMap(data['extra']); + final callKitId = _parseString(data, const ['id', 'uuid']); + if (callKitId == null) { + log( + 'VideoCallCallKitCoordinator: native acceptedVoipCall without callKitId, data=$data', + name: 'VideoCallCallKit', + ); + return; + } + + final conversationUuid = _parseConversationUuid( + extra, + handle: _parseString(data, const ['handle']), + ); + if (conversationUuid.isEmpty) { + log( + 'VideoCallCallKitCoordinator: native acceptedVoipCall without conversationUuid, data=$data', + name: 'VideoCallCallKit', + ); + return; + } + + await _deliverIncomingAccept( + callKitId: callKitId, + conversationUuid: conversationUuid, + counterpartName: + _parseString(extra, const [ + 'counterpartName', + 'caller_name', + 'callerName', + ]) ?? + _parseString(data, const ['nameCaller']), + counterpartAvatarUrl: + _parseString(extra, const [ + 'counterpartAvatarUrl', + 'avatar', + 'avatarUrl', + 'caller_avatar', + ]) ?? + _parseString(data, const ['avatar']), + localParticipantName: _parseString(extra, const [ + 'localParticipantName', + 'local_participant_name', + ]), + ); + } + Future _handleAccept(String callKitId) async { if (_acceptHandledCallKitIds.contains(callKitId)) return; @@ -384,7 +438,7 @@ class VideoCallCallKitCoordinator { } static String _parseConversationUuid( - Map extra, { + Map extra, { String? handle, }) { final fromExtra = _parseString(extra, const [ @@ -401,7 +455,7 @@ class VideoCallCallKitCoordinator { return ''; } - static String? _parseString(Map map, List keys) { + static String? _parseString(Map map, List keys) { for (final key in keys) { final value = map[key]; if (value is String && value.trim().isNotEmpty) { diff --git a/pubspec.yaml b/pubspec.yaml index 96f67cf1..3cbf6779 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.27+113 +version: 1.3.27+114 publish_to: 'none' environment: diff --git a/test/features/video_calls/widgets/video_call_top_overlay_test.dart b/test/features/video_calls/widgets/video_call_top_overlay_test.dart index 4b2aea2d..77d7c312 100644 --- a/test/features/video_calls/widgets/video_call_top_overlay_test.dart +++ b/test/features/video_calls/widgets/video_call_top_overlay_test.dart @@ -3,8 +3,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - testWidgets('insertBanner shows message and can be removed manually', (tester) async { - await tester.pumpWidget(const MaterialApp(home: Scaffold(body: SizedBox.shrink()))); + testWidgets('insertBanner shows message and can be removed manually', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: SizedBox.shrink())), + ); final context = tester.element(find.byType(SizedBox)); final entry = VideoCallTopOverlay.insertBanner( @@ -23,7 +27,9 @@ void main() { }); testWidgets('insertBanner auto removes entry', (tester) async { - await tester.pumpWidget(const MaterialApp(home: Scaffold(body: SizedBox.shrink()))); + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: SizedBox.shrink())), + ); final context = tester.element(find.byType(SizedBox)); VideoCallTopOverlay.insertBanner( @@ -40,4 +46,25 @@ void main() { await tester.pump(const Duration(milliseconds: 40)); expect(find.text('Muted mic'), findsNothing); }); + + testWidgets('insertBanner does not throw without an overlay', (tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: SizedBox.shrink(), + ), + ); + + final context = tester.element(find.byType(SizedBox)); + final entry = VideoCallTopOverlay.insertBanner( + context, + message: 'Recording notice', + backgroundColor: Colors.grey, + svgAsset: VideoCallTopOverlay.warningSvg, + ); + + expect(entry.mounted, isFalse); + await tester.pump(); + expect(find.text('Recording notice'), findsNothing); + }); } -- 2.49.1 From 90e5c7e58d147f59a94d9f09262a45ba3f52c32e Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 17 Jun 2026 11:17:37 +0300 Subject: [PATCH 34/88] fix: ci/cd error fixed --- .../video_call_callkit_coordinator.dart | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) 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 8c28fbab..a8aa9101 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -167,23 +167,25 @@ class VideoCallCallKitCoordinator { 'eventName': event.eventName, 'eventType': event.runtimeType.toString(), }; - switch (event) { - case CallEventActionCallIncoming(:final callKitParams): - context.addAll({ - 'id': callKitParams.id, - 'handle': callKitParams.handle, - 'nameCaller': callKitParams.nameCaller, - 'isAccepted': callKitParams.isAccepted, - 'extra': callKitParams.extra?.toString(), - }); - case CallEventActionCallAccept(:final id): - case CallEventActionCallCallback(:final id): - case CallEventActionCallDecline(:final id): - case CallEventActionCallEnded(:final id): - case CallEventActionCallTimeout(:final id): - context['id'] = id; - default: - break; + if (event is CallEventActionCallIncoming) { + final callKitParams = event.callKitParams; + context.addAll({ + 'id': callKitParams.id, + 'handle': callKitParams.handle, + 'nameCaller': callKitParams.nameCaller, + 'isAccepted': callKitParams.isAccepted, + 'extra': callKitParams.extra?.toString(), + }); + } else if (event is CallEventActionCallAccept) { + context['id'] = event.id; + } else if (event is CallEventActionCallCallback) { + context['id'] = event.id; + } else if (event is CallEventActionCallDecline) { + context['id'] = event.id; + } else if (event is CallEventActionCallEnded) { + context['id'] = event.id; + } else if (event is CallEventActionCallTimeout) { + context['id'] = event.id; } await Sentry.captureMessage( -- 2.49.1 From e3dd8162fad23d2c0c6db81e16f89618d8367b6b Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 17 Jun 2026 11:33:17 +0300 Subject: [PATCH 35/88] fix: ci/cd error fixed --- .../video_call_callkit_coordinator.dart | 97 ++++++++++++++++--- 1 file changed, 82 insertions(+), 15 deletions(-) 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 a8aa9101..55f5821e 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -146,21 +146,45 @@ class VideoCallCallKitCoordinator { await _firebaseApi.registerPushToken(); case CallEventActionCallIncoming(:final callKitParams): _cacheCallKitParams(callKitParams); - case CallEventActionCallAccept(:final id): - await _handleAccept(id); - case CallEventActionCallCallback(:final id): - await _handleAccept(id); - case CallEventActionCallDecline(:final id): - await _handleDecline(id); - case CallEventActionCallEnded(:final id): - await _handleDecline(id); - case CallEventActionCallTimeout(:final id): - await _handleDecline(id); + case CallEventActionCallAccept(): + await _handleAcceptEvent(event); + case CallEventActionCallCallback(): + await _handleAcceptEvent(event); + case CallEventActionCallDecline(): + await _handleDeclineEvent(event); + case CallEventActionCallEnded(): + await _handleDeclineEvent(event); + case CallEventActionCallTimeout(): + await _handleDeclineEvent(event); default: break; } } + Future _handleAcceptEvent(CallEvent event) async { + final id = _callKitEventId(event); + if (id == null) { + log( + 'VideoCallCallKitCoordinator: accept event without callKit id, event=$event', + name: 'VideoCallCallKit', + ); + return; + } + await _handleAccept(id); + } + + Future _handleDeclineEvent(CallEvent event) async { + final id = _callKitEventId(event); + if (id == null) { + log( + 'VideoCallCallKitCoordinator: decline/end event without callKit id, event=$event', + name: 'VideoCallCallKit', + ); + return; + } + await _handleDecline(id); + } + Future _captureCallKitEventToSentry(CallEvent event) async { if (!Platform.isIOS) return; final context = { @@ -177,15 +201,15 @@ class VideoCallCallKitCoordinator { 'extra': callKitParams.extra?.toString(), }); } else if (event is CallEventActionCallAccept) { - context['id'] = event.id; + _addCallKitEventDetails(context, event); } else if (event is CallEventActionCallCallback) { - context['id'] = event.id; + _addCallKitEventDetails(context, event); } else if (event is CallEventActionCallDecline) { - context['id'] = event.id; + _addCallKitEventDetails(context, event); } else if (event is CallEventActionCallEnded) { - context['id'] = event.id; + _addCallKitEventDetails(context, event); } else if (event is CallEventActionCallTimeout) { - context['id'] = event.id; + _addCallKitEventDetails(context, event); } await Sentry.captureMessage( @@ -199,11 +223,54 @@ class VideoCallCallKitCoordinator { ); } + void _addCallKitEventDetails(Map context, CallEvent event) { + final params = _callKitEventParams(event); + if (params != null) { + context.addAll({ + 'id': params.id, + 'handle': params.handle, + 'nameCaller': params.nameCaller, + 'isAccepted': params.isAccepted, + 'extra': params.extra?.toString(), + }); + return; + } + + final id = _callKitEventId(event); + if (id != null) { + context['id'] = id; + } + } + void _cacheCallKitParams(CallKitParams params) { _callKitParamsById[params.id] = params; _activeCallKitId = params.id; } + CallKitParams? _callKitEventParams(CallEvent event) { + try { + final params = (event as dynamic).callKitParams; + if (params is CallKitParams) return params; + } catch (_) { + return null; + } + return null; + } + + String? _callKitEventId(CallEvent event) { + final params = _callKitEventParams(event); + final paramsId = params?.id.trim(); + if (paramsId != null && paramsId.isNotEmpty) return paramsId; + + try { + final id = (event as dynamic).id; + if (id is String && id.trim().isNotEmpty) return id.trim(); + } catch (_) { + return null; + } + return null; + } + CallKitParams? _paramsForCallId(String id) => _callKitParamsById[id]; Future _paramsForCallIdOrActiveCall(String id) async { -- 2.49.1 From ea5fced456cce5871fabf2021c8de633fc667a27 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 17 Jun 2026 16:47:51 +0300 Subject: [PATCH 36/88] feat(video_calls): added system ui for call and navigation to the app --- ios/Runner/AppDelegate.swift | 113 ----------------- ios/Runner/Info.plist | 8 -- .../video_call_callkit_coordinator.dart | 120 ++++++++++-------- pubspec.yaml | 2 +- 4 files changed, 65 insertions(+), 178 deletions(-) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index ed5fa3dd..e2f16042 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -14,7 +14,6 @@ import flutter_foreground_task private var voipDiagnosticsChannel: FlutterMethodChannel? private let voipDiagnosticsStorageKey = "EatvioVoipDiagnostics" private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics" - private let appUrlScheme = "eatvio" override func application( _ application: UIApplication, @@ -75,37 +74,6 @@ import flutter_foreground_task open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:] ) -> Bool { - if url.scheme == appUrlScheme { - let components = URLComponents(url: url, resolvingAgainstBaseURL: false) - let queryItems = components?.queryItems ?? [] - let conversationUuid = queryItems.first { $0.name == "conversationUuid" }?.value? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let callKitId = queryItems.first { $0.name == "callKitId" }?.value? - .trimmingCharacters(in: .whitespacesAndNewlines) - recordVoipDiagnostic( - "accepted_call_url_opened", - data: [ - "callKitId": callKitId ?? "", - "hasCallKitId": callKitId != nil, - "conversationUuid": conversationUuid, - "url": url.absoluteString, - ] - ) - if let callKitId, !callKitId.isEmpty, !conversationUuid.isEmpty { - voipDiagnosticsChannel?.invokeMethod( - "acceptedVoipCall", - arguments: [ - "id": callKitId, - "handle": conversationUuid, - "extra": [ - "conversationUuid": conversationUuid - ], - ] - ) - } - return true - } - return YXLSdk.shared.handleOpen( url, sourceApplication: options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String @@ -140,75 +108,6 @@ import flutter_foreground_task return nil } - private func conversationUuid(fromCall call: Call) -> String? { - let extra = call.data.extra as? [String: Any] ?? [:] - for key in ["conversationUuid", "conversation_uuid", "conversation_id", "chat_uuid", "uuid"] { - if let value = extra[key] as? String { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { - return trimmed - } - } - } - - let handle = call.data.handle.trimmingCharacters(in: .whitespacesAndNewlines) - if !handle.isEmpty { - return handle - } - - return nil - } - - 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] - ) - return - } - - var components = URLComponents() - components.scheme = appUrlScheme - components.host = "video-call" - components.queryItems = [ - URLQueryItem(name: "callKitId", value: call.data.uuid), - URLQueryItem(name: "conversationUuid", value: conversationUuid) - ] - - guard let url = components.url else { - recordVoipDiagnostic( - "accepted_call_url_build_failed", - data: [ - "callKitId": call.data.uuid, - "conversationUuid": conversationUuid, - ] - ) - return - } - - recordVoipDiagnostic( - "accepted_call_open_app_requested", - data: [ - "callKitId": call.data.uuid, - "conversationUuid": conversationUuid, - "url": url.absoluteString, - "appState": UIApplication.shared.applicationState.rawValue, - ] - ) - - 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, - ] - ) - } - } - private func sanitizedDiagnosticValue(_ value: Any?) -> Any { guard let value = value else { return "" @@ -446,18 +345,6 @@ import flutter_foreground_task func onAccept(_ call: Call, _ action: CXAnswerCallAction) { - recordVoipDiagnostic( - "callkit_accept_native", - data: [ - "callKitId": call.data.uuid, - "conversationUuid": conversationUuid(fromCall: call) ?? "", - "data": call.data.toJSON(), - ] - ) - voipDiagnosticsChannel?.invokeMethod("acceptedVoipCall", arguments: call.data.toJSON()) - DispatchQueue.main.async { [weak self] in - self?.openAppForAcceptedCall(call) - } action.fulfill() } diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 5bcdccbd..c35ead46 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -91,14 +91,6 @@ yx5399b03636104c6d920b87c1ad329254 - - CFBundleURLName - Eatvio - CFBundleURLSchemes - - eatvio - - 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 55f5821e..0bfff5a5 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -30,6 +30,7 @@ class VideoCallCallKitCoordinator { final VideoCallSessionCubit? _videoCallSessionCubit; StreamSubscription? _eventSubscription; + AppLifecycleListener? _lifecycleListener; final Map _callKitParamsById = {}; final Set _acceptHandledCallKitIds = {}; final Set _suppressedEndCallKitIds = {}; @@ -49,16 +50,17 @@ class VideoCallCallKitCoordinator { if (Platform.isIOS) { _voipDiagnosticsChannel.setMethodCallHandler((call) async { - switch (call.method) { - case 'voipDiagnosticsUpdated': - _scheduleNativeVoipDiagnosticsDrain(); - case 'acceptedVoipCall': - await _handleNativeAcceptedVoipCall(call.arguments); + if (call.method == 'voipDiagnosticsUpdated') { + _scheduleNativeVoipDiagnosticsDrain(); } }); await _firebaseApi.registerPushToken(); + _lifecycleListener = AppLifecycleListener( + onResume: () => unawaited(_openActiveCallKitCallIfNeeded()), + ); WidgetsBinding.instance.addPostFrameCallback((_) { _scheduleNativeVoipDiagnosticsDrain(); + unawaited(_openActiveCallKitCallIfNeeded()); }); return; } @@ -81,7 +83,9 @@ class VideoCallCallKitCoordinator { void dispose() { unawaited(_eventSubscription?.cancel()); + _lifecycleListener?.dispose(); _eventSubscription = null; + _lifecycleListener = null; _callKitParamsById.clear(); _initialized = false; } @@ -285,6 +289,53 @@ class VideoCallCallKitCoordinator { return _paramsForCallId(id); } + /// When the user opens the app from the system CallKit UI (e.g. taps "Итвио"), + /// navigate to the in-app call screen for an already-accepted active call. + Future _openActiveCallKitCallIfNeeded() async { + if (!Platform.isIOS) return; + if (_cubit.state.connectPhase != VideoCallConnectPhase.idle) return; + + final activeCalls = await FlutterCallkitIncoming.activeCalls(); + if (activeCalls.isEmpty) return; + + for (final params in activeCalls) { + _cacheCallKitParams(params); + if (!params.isAccepted) continue; + + final extra = params.extra ?? {}; + final conversationUuid = _parseConversationUuid( + extra, + handle: params.handle, + ); + if (conversationUuid.isEmpty) continue; + + await _deliverIncomingAccept( + callKitId: params.id, + conversationUuid: conversationUuid, + counterpartName: + _parseString(extra, const [ + 'counterpartName', + 'caller_name', + 'callerName', + ]) ?? + params.nameCaller, + counterpartAvatarUrl: + _parseString(extra, const [ + 'counterpartAvatarUrl', + 'avatar', + 'avatarUrl', + 'caller_avatar', + ]) ?? + params.avatar, + localParticipantName: _parseString(extra, const [ + 'localParticipantName', + 'local_participant_name', + ]), + ); + return; + } + } + Future _onNativeAcceptCallHandle(Map body) async { final extra = Map.from(body); final conversationUuid = _parseConversationUuid(extra); @@ -317,57 +368,6 @@ class VideoCallCallKitCoordinator { ); } - Future _handleNativeAcceptedVoipCall(Object? arguments) async { - if (!Platform.isIOS) return; - - final data = _stringKeyedMap(arguments); - final extra = _stringKeyedMap(data['extra']); - final callKitId = _parseString(data, const ['id', 'uuid']); - if (callKitId == null) { - log( - 'VideoCallCallKitCoordinator: native acceptedVoipCall without callKitId, data=$data', - name: 'VideoCallCallKit', - ); - return; - } - - final conversationUuid = _parseConversationUuid( - extra, - handle: _parseString(data, const ['handle']), - ); - if (conversationUuid.isEmpty) { - log( - 'VideoCallCallKitCoordinator: native acceptedVoipCall without conversationUuid, data=$data', - name: 'VideoCallCallKit', - ); - return; - } - - await _deliverIncomingAccept( - callKitId: callKitId, - conversationUuid: conversationUuid, - counterpartName: - _parseString(extra, const [ - 'counterpartName', - 'caller_name', - 'callerName', - ]) ?? - _parseString(data, const ['nameCaller']), - counterpartAvatarUrl: - _parseString(extra, const [ - 'counterpartAvatarUrl', - 'avatar', - 'avatarUrl', - 'caller_avatar', - ]) ?? - _parseString(data, const ['avatar']), - localParticipantName: _parseString(extra, const [ - 'localParticipantName', - 'local_participant_name', - ]), - ); - } - Future _handleAccept(String callKitId) async { if (_acceptHandledCallKitIds.contains(callKitId)) return; @@ -387,6 +387,14 @@ class VideoCallCallKitCoordinator { return; } + if (Platform.isIOS) { + log( + 'VideoCallCallKitCoordinator: iOS accept received (keeping CallKit UI), callKitId=$callKitId, conversationUuid=$conversationUuid', + name: 'VideoCallCallKit', + ); + return; + } + await _deliverIncomingAccept( callKitId: callKitId, conversationUuid: conversationUuid, diff --git a/pubspec.yaml b/pubspec.yaml index 3cbf6779..7732b34a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.27+114 +version: 1.3.27+115 publish_to: 'none' environment: -- 2.49.1 From e3c22f8ac8161fcb9343a1cd402866a44eaa8418 Mon Sep 17 00:00:00 2001 From: misha Date: Thu, 18 Jun 2026 14:41:56 +0300 Subject: [PATCH 37/88] Revert "feat(video_calls): added system ui for call and navigation to the app" This reverts commit ea5fced456cce5871fabf2021c8de633fc667a27. --- ios/Runner/AppDelegate.swift | 113 +++++++++++++++++ ios/Runner/Info.plist | 8 ++ .../video_call_callkit_coordinator.dart | 120 ++++++++---------- pubspec.yaml | 2 +- 4 files changed, 178 insertions(+), 65 deletions(-) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index e2f16042..ed5fa3dd 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -14,6 +14,7 @@ import flutter_foreground_task private var voipDiagnosticsChannel: FlutterMethodChannel? private let voipDiagnosticsStorageKey = "EatvioVoipDiagnostics" private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics" + private let appUrlScheme = "eatvio" override func application( _ application: UIApplication, @@ -74,6 +75,37 @@ import flutter_foreground_task open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:] ) -> Bool { + if url.scheme == appUrlScheme { + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + let queryItems = components?.queryItems ?? [] + let conversationUuid = queryItems.first { $0.name == "conversationUuid" }?.value? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let callKitId = queryItems.first { $0.name == "callKitId" }?.value? + .trimmingCharacters(in: .whitespacesAndNewlines) + recordVoipDiagnostic( + "accepted_call_url_opened", + data: [ + "callKitId": callKitId ?? "", + "hasCallKitId": callKitId != nil, + "conversationUuid": conversationUuid, + "url": url.absoluteString, + ] + ) + if let callKitId, !callKitId.isEmpty, !conversationUuid.isEmpty { + voipDiagnosticsChannel?.invokeMethod( + "acceptedVoipCall", + arguments: [ + "id": callKitId, + "handle": conversationUuid, + "extra": [ + "conversationUuid": conversationUuid + ], + ] + ) + } + return true + } + return YXLSdk.shared.handleOpen( url, sourceApplication: options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String @@ -108,6 +140,75 @@ import flutter_foreground_task return nil } + private func conversationUuid(fromCall call: Call) -> String? { + let extra = call.data.extra as? [String: Any] ?? [:] + for key in ["conversationUuid", "conversation_uuid", "conversation_id", "chat_uuid", "uuid"] { + if let value = extra[key] as? String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + } + } + + let handle = call.data.handle.trimmingCharacters(in: .whitespacesAndNewlines) + if !handle.isEmpty { + return handle + } + + return nil + } + + 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] + ) + return + } + + var components = URLComponents() + components.scheme = appUrlScheme + components.host = "video-call" + components.queryItems = [ + URLQueryItem(name: "callKitId", value: call.data.uuid), + URLQueryItem(name: "conversationUuid", value: conversationUuid) + ] + + guard let url = components.url else { + recordVoipDiagnostic( + "accepted_call_url_build_failed", + data: [ + "callKitId": call.data.uuid, + "conversationUuid": conversationUuid, + ] + ) + return + } + + recordVoipDiagnostic( + "accepted_call_open_app_requested", + data: [ + "callKitId": call.data.uuid, + "conversationUuid": conversationUuid, + "url": url.absoluteString, + "appState": UIApplication.shared.applicationState.rawValue, + ] + ) + + 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, + ] + ) + } + } + private func sanitizedDiagnosticValue(_ value: Any?) -> Any { guard let value = value else { return "" @@ -345,6 +446,18 @@ import flutter_foreground_task func onAccept(_ call: Call, _ action: CXAnswerCallAction) { + recordVoipDiagnostic( + "callkit_accept_native", + data: [ + "callKitId": call.data.uuid, + "conversationUuid": conversationUuid(fromCall: call) ?? "", + "data": call.data.toJSON(), + ] + ) + voipDiagnosticsChannel?.invokeMethod("acceptedVoipCall", arguments: call.data.toJSON()) + DispatchQueue.main.async { [weak self] in + self?.openAppForAcceptedCall(call) + } action.fulfill() } diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index c35ead46..5bcdccbd 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -91,6 +91,14 @@ yx5399b03636104c6d920b87c1ad329254 + + CFBundleURLName + Eatvio + CFBundleURLSchemes + + eatvio + + 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 0bfff5a5..55f5821e 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -30,7 +30,6 @@ class VideoCallCallKitCoordinator { final VideoCallSessionCubit? _videoCallSessionCubit; StreamSubscription? _eventSubscription; - AppLifecycleListener? _lifecycleListener; final Map _callKitParamsById = {}; final Set _acceptHandledCallKitIds = {}; final Set _suppressedEndCallKitIds = {}; @@ -50,17 +49,16 @@ class VideoCallCallKitCoordinator { if (Platform.isIOS) { _voipDiagnosticsChannel.setMethodCallHandler((call) async { - if (call.method == 'voipDiagnosticsUpdated') { - _scheduleNativeVoipDiagnosticsDrain(); + switch (call.method) { + case 'voipDiagnosticsUpdated': + _scheduleNativeVoipDiagnosticsDrain(); + case 'acceptedVoipCall': + await _handleNativeAcceptedVoipCall(call.arguments); } }); await _firebaseApi.registerPushToken(); - _lifecycleListener = AppLifecycleListener( - onResume: () => unawaited(_openActiveCallKitCallIfNeeded()), - ); WidgetsBinding.instance.addPostFrameCallback((_) { _scheduleNativeVoipDiagnosticsDrain(); - unawaited(_openActiveCallKitCallIfNeeded()); }); return; } @@ -83,9 +81,7 @@ class VideoCallCallKitCoordinator { void dispose() { unawaited(_eventSubscription?.cancel()); - _lifecycleListener?.dispose(); _eventSubscription = null; - _lifecycleListener = null; _callKitParamsById.clear(); _initialized = false; } @@ -289,53 +285,6 @@ class VideoCallCallKitCoordinator { return _paramsForCallId(id); } - /// When the user opens the app from the system CallKit UI (e.g. taps "Итвио"), - /// navigate to the in-app call screen for an already-accepted active call. - Future _openActiveCallKitCallIfNeeded() async { - if (!Platform.isIOS) return; - if (_cubit.state.connectPhase != VideoCallConnectPhase.idle) return; - - final activeCalls = await FlutterCallkitIncoming.activeCalls(); - if (activeCalls.isEmpty) return; - - for (final params in activeCalls) { - _cacheCallKitParams(params); - if (!params.isAccepted) continue; - - final extra = params.extra ?? {}; - final conversationUuid = _parseConversationUuid( - extra, - handle: params.handle, - ); - if (conversationUuid.isEmpty) continue; - - await _deliverIncomingAccept( - callKitId: params.id, - conversationUuid: conversationUuid, - counterpartName: - _parseString(extra, const [ - 'counterpartName', - 'caller_name', - 'callerName', - ]) ?? - params.nameCaller, - counterpartAvatarUrl: - _parseString(extra, const [ - 'counterpartAvatarUrl', - 'avatar', - 'avatarUrl', - 'caller_avatar', - ]) ?? - params.avatar, - localParticipantName: _parseString(extra, const [ - 'localParticipantName', - 'local_participant_name', - ]), - ); - return; - } - } - Future _onNativeAcceptCallHandle(Map body) async { final extra = Map.from(body); final conversationUuid = _parseConversationUuid(extra); @@ -368,6 +317,57 @@ class VideoCallCallKitCoordinator { ); } + Future _handleNativeAcceptedVoipCall(Object? arguments) async { + if (!Platform.isIOS) return; + + final data = _stringKeyedMap(arguments); + final extra = _stringKeyedMap(data['extra']); + final callKitId = _parseString(data, const ['id', 'uuid']); + if (callKitId == null) { + log( + 'VideoCallCallKitCoordinator: native acceptedVoipCall without callKitId, data=$data', + name: 'VideoCallCallKit', + ); + return; + } + + final conversationUuid = _parseConversationUuid( + extra, + handle: _parseString(data, const ['handle']), + ); + if (conversationUuid.isEmpty) { + log( + 'VideoCallCallKitCoordinator: native acceptedVoipCall without conversationUuid, data=$data', + name: 'VideoCallCallKit', + ); + return; + } + + await _deliverIncomingAccept( + callKitId: callKitId, + conversationUuid: conversationUuid, + counterpartName: + _parseString(extra, const [ + 'counterpartName', + 'caller_name', + 'callerName', + ]) ?? + _parseString(data, const ['nameCaller']), + counterpartAvatarUrl: + _parseString(extra, const [ + 'counterpartAvatarUrl', + 'avatar', + 'avatarUrl', + 'caller_avatar', + ]) ?? + _parseString(data, const ['avatar']), + localParticipantName: _parseString(extra, const [ + 'localParticipantName', + 'local_participant_name', + ]), + ); + } + Future _handleAccept(String callKitId) async { if (_acceptHandledCallKitIds.contains(callKitId)) return; @@ -387,14 +387,6 @@ class VideoCallCallKitCoordinator { return; } - if (Platform.isIOS) { - log( - 'VideoCallCallKitCoordinator: iOS accept received (keeping CallKit UI), callKitId=$callKitId, conversationUuid=$conversationUuid', - name: 'VideoCallCallKit', - ); - return; - } - await _deliverIncomingAccept( callKitId: callKitId, conversationUuid: conversationUuid, diff --git a/pubspec.yaml b/pubspec.yaml index 7732b34a..3cbf6779 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.27+115 +version: 1.3.27+114 publish_to: 'none' environment: -- 2.49.1 From 7493870a3d259fd80ccc4faac8cbc810444ff73e Mon Sep 17 00:00:00 2001 From: misha Date: Thu, 18 Jun 2026 15:01:21 +0300 Subject: [PATCH 38/88] feat(video_calls): removed system ui, fixed wrong app initializing --- ios/Runner/AppDelegate.swift | 44 +++++++++++++++++-- .../video_call_callkit_coordinator.dart | 42 ++++++++++++++++-- 2 files changed, 80 insertions(+), 6 deletions(-) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index ed5fa3dd..e1b521eb 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -13,6 +13,7 @@ import flutter_foreground_task private var voipRegistry: PKPushRegistry? private var voipDiagnosticsChannel: FlutterMethodChannel? private let voipDiagnosticsStorageKey = "EatvioVoipDiagnostics" + private let pendingAcceptedVoipCallKey = "EatvioPendingAcceptedVoipCall" private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics" private let appUrlScheme = "eatvio" @@ -43,11 +44,18 @@ import flutter_foreground_task ) voipDiagnosticsChannel = channel channel.setMethodCallHandler { [weak self] call, result in - guard call.method == "drainVoipDiagnostics" else { - result(FlutterMethodNotImplemented) + guard let self else { + result(nil) return } - result(self?.drainVoipDiagnostics() ?? []) + switch call.method { + case "drainVoipDiagnostics": + result(self.drainVoipDiagnostics()) + case "drainPendingAcceptedVoipCall": + result(self.drainPendingAcceptedVoipCall()) + default: + result(FlutterMethodNotImplemented) + } } } @@ -159,6 +167,32 @@ import flutter_foreground_task return nil } + private func storePendingAcceptedCall(_ call: Call) { + let payload = sanitizedDiagnosticValue(call.data.toJSON()) as? [String: Any] ?? [:] + UserDefaults.standard.set(payload, forKey: pendingAcceptedVoipCallKey) + } + + private func drainPendingAcceptedVoipCall() -> [String: Any]? { + let defaults = UserDefaults.standard + guard let payload = defaults.dictionary(forKey: pendingAcceptedVoipCallKey) else { + return nil + } + defaults.removeObject(forKey: pendingAcceptedVoipCallKey) + 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( @@ -197,6 +231,8 @@ import flutter_foreground_task ] ) + bringAppToForeground() + UIApplication.shared.open(url, options: [:]) { [weak self] success in self?.recordVoipDiagnostic( success ? "accepted_call_open_app_succeeded" : "accepted_call_open_app_failed", @@ -446,6 +482,7 @@ import flutter_foreground_task func onAccept(_ call: Call, _ action: CXAnswerCallAction) { + storePendingAcceptedCall(call) recordVoipDiagnostic( "callkit_accept_native", data: [ @@ -456,6 +493,7 @@ import flutter_foreground_task ) voipDiagnosticsChannel?.invokeMethod("acceptedVoipCall", arguments: call.data.toJSON()) DispatchQueue.main.async { [weak self] in + self?.bringAppToForeground() self?.openAppForAcceptedCall(call) } action.fulfill() 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 55f5821e..b25e0468 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -14,6 +14,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_callkit_incoming/entities/call_event.dart'; import 'package:flutter_callkit_incoming/entities/call_kit_params.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; +import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; class VideoCallCallKitCoordinator { @@ -57,8 +58,10 @@ class VideoCallCallKitCoordinator { } }); await _firebaseApi.registerPushToken(); + await _drainPendingNativeAcceptedVoipCall(); WidgetsBinding.instance.addPostFrameCallback((_) { _scheduleNativeVoipDiagnosticsDrain(); + unawaited(_drainPendingNativeAcceptedVoipCall()); }); return; } @@ -137,6 +140,29 @@ class VideoCallCallKitCoordinator { } } + Future _drainPendingNativeAcceptedVoipCall() async { + if (!Platform.isIOS) return; + try { + final pending = await _voipDiagnosticsChannel.invokeMethod( + 'drainPendingAcceptedVoipCall', + ); + if (pending == null) return; + await _handleNativeAcceptedVoipCall(pending); + } on PlatformException catch (e, st) { + log( + 'VideoCallCallKitCoordinator: failed to drain pending native accept: $e', + name: 'VideoCallCallKit', + stackTrace: st, + ); + } catch (e, st) { + log( + 'VideoCallCallKitCoordinator: failed to process pending native accept: $e', + name: 'VideoCallCallKit', + stackTrace: st, + ); + } + } + Future _onCallKitEvent(CallEvent? event) async { if (event == null) return; unawaited(_captureCallKitEventToSentry(event)); @@ -424,7 +450,9 @@ class VideoCallCallKitCoordinator { _suppressedEndCallKitIds.add(callKitId); try { - await FlutterCallkitIncoming.endCall(callKitId); + if (!Platform.isIOS) { + await FlutterCallkitIncoming.endCall(callKitId); + } final binding = WidgetsBinding.instance; final completer = Completer(); @@ -439,6 +467,10 @@ class VideoCallCallKitCoordinator { ); }); await completer.future; + + if (Platform.isIOS) { + await FlutterCallkitIncoming.endCall(callKitId); + } } finally { unawaited( Future.delayed(const Duration(seconds: 2), () { @@ -454,8 +486,12 @@ class VideoCallCallKitCoordinator { String? counterpartName, String? counterpartAvatarUrl, String? localParticipantName, - }) { - return VideoCallNotificationNavigator.openIncomingCall( + }) async { + if (Platform.isIOS) { + FlutterNativeSplash.remove(); + } + + await VideoCallNotificationNavigator.openIncomingCall( conversationUuid: conversationUuid, counterpartName: counterpartName, counterpartAvatarUrl: counterpartAvatarUrl, -- 2.49.1 From 8920fc280fa235061d6d5b000d80fbf9ff99644d Mon Sep 17 00:00:00 2001 From: misha Date: Thu, 18 Jun 2026 15:02:53 +0300 Subject: [PATCH 39/88] feat(ci/cd): version changed, anroind action runs only on main branch --- .gitea/workflows/test.yml | 5 ++++- pubspec.yaml | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 0628fd30..a44f6927 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -1,6 +1,9 @@ name: Eatvio App Android Test -on: [pull_request] +on: + pull_request: + branches: + - main env: flutter_version: '3.41.2' diff --git a/pubspec.yaml b/pubspec.yaml index 3cbf6779..29eee75c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.27+114 +version: 1.3.27+116 publish_to: 'none' environment: -- 2.49.1 From ed72ed9799007f0c53b175c965a4b9273f9db2f9 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 23 Jun 2026 08:42:55 +0300 Subject: [PATCH 40/88] feat(video_calls): changed accept flow, fixed ui bugs for PIP and video render sizes --- android/app/build.gradle | 2 + android/app/src/main/AndroidManifest.xml | 10 + .../app/EatvioFcmMessagingService.kt | 33 ++ .../com/eatvioapp/app/HmsPushService.kt | 92 +--- .../com/eatvioapp/app/VideoCallPushBridge.kt | 163 +++++++ lib/core/di/cubit_registrations.dart | 5 +- .../push_navigation_handler.dart | 2 + lib/core/routing/other_routes.dart | 10 + .../managers/video_call_manager.dart | 430 +++++++----------- .../managers/video_call_manager_snapshot.dart | 10 +- .../video_call_session_cubit.dart | 205 ++++++++- .../video_call_session_state.dart | 15 +- .../presentation/pages/video_call_screen.dart | 60 ++- .../widgets/video_call_app_shell.dart | 26 +- .../widgets/video_call_global_pip_layer.dart | 7 +- .../video_call_livekit_track_utils.dart | 8 +- .../widgets/video_call_pip_video_preview.dart | 63 ++- .../video_call_remote_video_stage.dart | 13 +- .../video_call_room_tracks_listenable.dart | 11 + .../widgets/video_call_stage_layout.dart | 418 ++++++++--------- .../video_call_system_pip_content.dart | 21 +- .../video_call_system_pip_listener.dart | 6 + .../video_call_audio_session_coordinator.dart | 11 +- .../video_call_callkit_coordinator.dart | 1 + .../services/video_call_fcm_handler.dart | 8 + .../video_call_incoming_push_presenter.dart | 1 + .../video_call_notification_navigator.dart | 23 +- ..._call_manager_audio_interruption_test.dart | 173 ------- .../video_call_session_cubit_test.dart | 232 +++++++++- .../video_call_livekit_track_utils_test.dart | 29 ++ .../widgets/video_call_stage_layout_test.dart | 148 +++++- 31 files changed, 1399 insertions(+), 837 deletions(-) create mode 100644 android/app/src/main/kotlin/com/eatvioapp/app/EatvioFcmMessagingService.kt create mode 100644 android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt create mode 100644 lib/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart delete mode 100644 test/features/video_calls/presentation/video_call_manager_audio_interruption_test.dart diff --git a/android/app/build.gradle b/android/app/build.gradle index 5fb4bf83..ff3634f9 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -69,6 +69,8 @@ android { } dependencies { + implementation platform('com.google.firebase:firebase-bom:34.4.0') + implementation 'com.google.firebase:firebase-messaging' implementation 'com.huawei.agconnect:agconnect-core:1.9.5.300' implementation 'com.huawei.hms:push:6.13.0.301' // Huawei Analytics dependency to satisfy com.huawei.hianalytics.* references during R8 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index daa5ec83..4ce279c3 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -17,6 +17,16 @@ android:usesCleartextTraffic="true" android:name="${applicationName}" android:icon="@mipmap/ic_launcher"> + + + + + + diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/EatvioFcmMessagingService.kt b/android/app/src/main/kotlin/com/eatvioapp/app/EatvioFcmMessagingService.kt new file mode 100644 index 00000000..7e9e7377 --- /dev/null +++ b/android/app/src/main/kotlin/com/eatvioapp/app/EatvioFcmMessagingService.kt @@ -0,0 +1,33 @@ +package com.eatvioapp.app + +import android.util.Log +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import io.flutter.plugins.firebase.messaging.FlutterFirebaseTokenLiveData + +class EatvioFcmMessagingService : FirebaseMessagingService() { + override fun onNewToken(token: String) { + super.onNewToken(token) + FlutterFirebaseTokenLiveData.getInstance().postToken(token) + Log.i(TAG, "onNewToken") + } + + override fun onMessageReceived(message: RemoteMessage) { + Log.i(TAG, "onMessageReceived: ${message.data}") + + if (!VideoCallPushBridge.isIncomingVideoCall(message.data)) { + return + } + + if (MainActivity.isInForeground) { + Log.i(TAG, "App in foreground, deferring incoming call to Flutter handler") + return + } + + VideoCallPushBridge.showIncomingCall(this, message.data) + } + + companion object { + private const val TAG = "EatvioFCM" + } +} diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt b/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt index f8db072b..b7b986a6 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt @@ -2,14 +2,11 @@ package com.eatvioapp.app import android.app.PendingIntent import android.content.Intent -import android.os.Bundle import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat -import com.hiennv.flutter_callkit_incoming.CallkitConstants import com.huawei.hms.push.HmsMessageService import com.huawei.hms.push.RemoteMessage -import java.util.HashMap class HmsPushService : HmsMessageService() { override fun onNewToken(token: String?) { @@ -23,22 +20,18 @@ class HmsPushService : HmsMessageService() { if (message == null) return - if (isIncomingVideoCall(message.dataOfMap)) { - showIncomingVideoCallNotification(message.dataOfMap) + if (VideoCallPushBridge.isIncomingVideoCall(message.dataOfMap)) { + VideoCallPushBridge.showIncomingCall(this, message.dataOfMap) return } - // Берём action_url из data-поля, если оно есть. val actionUrl = message.dataOfMap["action_url"] - - // Строим системное уведомление, по тапу по которому откроется MainActivity с action_url. showNotification(message, actionUrl) } private fun showNotification(message: RemoteMessage, actionUrl: String?) { val context = applicationContext - // Интент для запуска Flutter-активити. val intent = Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP if (!actionUrl.isNullOrBlank()) { @@ -56,7 +49,7 @@ class HmsPushService : HmsMessageService() { val channelId = "hms_push_channel" val notification = NotificationCompat.Builder(context, channelId) - .setSmallIcon(R.mipmap.ic_launcher) // используем иконку приложения + .setSmallIcon(R.mipmap.ic_launcher) .setContentTitle(message.notification?.title ?: context.getString(R.string.app_name)) .setContentText(message.notification?.body ?: "") .setAutoCancel(true) @@ -65,83 +58,4 @@ class HmsPushService : HmsMessageService() { NotificationManagerCompat.from(context).notify(message.messageId.hashCode(), notification) } - - private fun isIncomingVideoCall(data: Map): Boolean { - val rawType = data["type"] - ?: data["notification_type"] - ?: data["event"] - ?: data["action"] - ?: data["push_type"] - ?: data["call_type"] - val type = rawType?.lowercase()?.replace("-", "_") - return type?.contains("video_call") == true && - !type.contains("end") && - !type.contains("ended") - } - - private fun showIncomingVideoCallNotification(data: Map) { - val conversationUuid = data["conversationUuid"] - ?: data["conversation_uuid"] - ?: data["conversation_id"] - ?: data["chat_uuid"] - ?: data["uuid"] - ?: return - val callerName = data["counterpartName"] - ?: data["counterpart_name"] - ?: data["caller_name"] - ?: data["callerName"] - ?: data["nameCaller"] - ?: data["caller"] - ?: "Итвио" - val avatarUrl = data["counterpartAvatarUrl"] - ?: data["counterpart_avatar_url"] - ?: data["avatar"] - ?: data["avatarUrl"] - ?: data["avatar_url"] - ?: data["caller_avatar"] - ?: "" - val localParticipantName = data["localParticipantName"] - ?: data["local_participant_name"] - - val extra = HashMap() - extra["conversationUuid"] = conversationUuid - extra["counterpartName"] = callerName - if (avatarUrl.isNotBlank()) { - extra["counterpartAvatarUrl"] = avatarUrl - } - if (!localParticipantName.isNullOrBlank()) { - extra["localParticipantName"] = localParticipantName - } - - val callData = Bundle().apply { - putString(CallkitConstants.EXTRA_CALLKIT_ID, conversationUuid) - putString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, callerName) - putString(CallkitConstants.EXTRA_CALLKIT_APP_NAME, "Итвио") - putString(CallkitConstants.EXTRA_CALLKIT_HANDLE, conversationUuid) - putString(CallkitConstants.EXTRA_CALLKIT_AVATAR, avatarUrl) - putInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 0) - putLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45000L) - putString(CallkitConstants.EXTRA_CALLKIT_TEXT_ACCEPT, "Answer") - putString(CallkitConstants.EXTRA_CALLKIT_TEXT_DECLINE, "Decline") - putSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA, extra) - putSerializable(CallkitConstants.EXTRA_CALLKIT_HEADERS, HashMap()) - putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_CUSTOM_NOTIFICATION, true) - putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_CUSTOM_SMALL_EX_NOTIFICATION, false) - putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_SHOW_LOGO, false) - putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_SHOW_CALL_ID, false) - putString(CallkitConstants.EXTRA_CALLKIT_RINGTONE_PATH, "system_ringtone_default") - putString(CallkitConstants.EXTRA_CALLKIT_BACKGROUND_COLOR, "#2787F5") - putString(CallkitConstants.EXTRA_CALLKIT_ACTION_COLOR, "#2787F5") - putString(CallkitConstants.EXTRA_CALLKIT_TEXT_COLOR, "#ffffff") - putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_SHOW_FULL_LOCKED_SCREEN, false) - putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_FULL_SCREEN, false) - } - - val intent = com.hiennv.flutter_callkit_incoming.CallkitIncomingBroadcastReceiver - .getIntent(this, CallkitConstants.ACTION_CALL_INCOMING, callData) - .addFlags(Intent.FLAG_RECEIVER_FOREGROUND) - sendBroadcast(intent) - } } - - diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt new file mode 100644 index 00000000..b4272684 --- /dev/null +++ b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt @@ -0,0 +1,163 @@ +package com.eatvioapp.app + +import android.content.Context +import android.os.Bundle +import android.util.Log +import com.hiennv.flutter_callkit_incoming.CallkitConstants +import com.hiennv.flutter_callkit_incoming.CallkitNotificationManager +import com.hiennv.flutter_callkit_incoming.CallkitSoundPlayerManager +import com.hiennv.flutter_callkit_incoming.Data +import com.hiennv.flutter_callkit_incoming.addCall +import java.util.HashMap + +object VideoCallPushBridge { + private const val TAG = "VideoCallPushBridge" + private const val ANDROID_ACCENT_COLOR = "#2787F5" + + fun isIncomingVideoCall(data: Map): Boolean { + val rawType = data["type"] + ?: data["notification_type"] + ?: data["event"] + ?: data["action"] + ?: data["push_type"] + ?: data["call_type"] + + if (rawType != null) { + val normalized = rawType.lowercase().replace("-", "_") + if (normalized.contains("video_call")) { + return !normalized.contains("end") && !normalized.contains("ended") + } + if (normalized == "incoming_call" || + normalized == "video_call_incoming" || + normalized == "incoming_video_call" + ) { + return true + } + } + + if (isTruthy(data["is_video_call"]) || + isTruthy(data["incoming_call"]) || + isTruthy(data["is_incoming_call"]) + ) { + return conversationUuid(data) != null + } + + val actionUrl = data["action_url"] + if (!actionUrl.isNullOrBlank() && + (actionUrl.contains("video-call") || actionUrl.contains("videoCall")) + ) { + return true + } + + return false + } + + fun showIncomingCall(context: Context, data: Map) { + val appContext = context.applicationContext + val callData = buildCallDataBundle(data) + if (callData == null) { + Log.w(TAG, "showIncomingCall: missing conversation uuid, data=$data") + return + } + + Log.i(TAG, "showIncomingCall: conversation=${callData.getString(CallkitConstants.EXTRA_CALLKIT_ID)}") + + val soundManager = CallkitSoundPlayerManager(appContext) + val notificationManager = CallkitNotificationManager(appContext, soundManager) + notificationManager.createNotificationChanel(callData) + notificationManager.showIncomingNotification(callData) + + try { + addCall(appContext, Data.fromBundle(callData)) + } catch (error: Exception) { + Log.w(TAG, "showIncomingCall: failed to persist active call", error) + } + } + + private fun buildCallDataBundle(data: Map): Bundle? { + val conversationUuid = conversationUuid(data) ?: return null + val callerName = data["counterpartName"] + ?: data["counterpart_name"] + ?: data["caller_name"] + ?: data["callerName"] + ?: data["nameCaller"] + ?: data["caller"] + ?: "Итвио" + val avatarUrl = data["counterpartAvatarUrl"] + ?: data["counterpart_avatar_url"] + ?: data["avatar"] + ?: data["avatarUrl"] + ?: data["avatar_url"] + ?: data["caller_avatar"] + ?: "" + val localParticipantName = data["localParticipantName"] + ?: data["local_participant_name"] + + val extra = HashMap() + extra["conversationUuid"] = conversationUuid + extra["counterpartName"] = callerName + if (avatarUrl.isNotBlank()) { + extra["counterpartAvatarUrl"] = avatarUrl + } + if (!localParticipantName.isNullOrBlank()) { + extra["localParticipantName"] = localParticipantName + } + + return Bundle().apply { + putString(CallkitConstants.EXTRA_CALLKIT_ID, conversationUuid) + putString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, callerName) + putString(CallkitConstants.EXTRA_CALLKIT_APP_NAME, "Итвио") + putString(CallkitConstants.EXTRA_CALLKIT_HANDLE, conversationUuid) + putString(CallkitConstants.EXTRA_CALLKIT_AVATAR, avatarUrl) + putInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 0) + putLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45000L) + putString(CallkitConstants.EXTRA_CALLKIT_TEXT_ACCEPT, "Answer") + putString(CallkitConstants.EXTRA_CALLKIT_TEXT_DECLINE, "Decline") + putSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA, extra) + putSerializable(CallkitConstants.EXTRA_CALLKIT_HEADERS, HashMap()) + putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_CUSTOM_NOTIFICATION, true) + putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_CUSTOM_SMALL_EX_NOTIFICATION, false) + putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_SHOW_LOGO, false) + putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_SHOW_CALL_ID, false) + putString(CallkitConstants.EXTRA_CALLKIT_RINGTONE_PATH, "system_ringtone_default") + putString(CallkitConstants.EXTRA_CALLKIT_BACKGROUND_COLOR, ANDROID_ACCENT_COLOR) + putString(CallkitConstants.EXTRA_CALLKIT_ACTION_COLOR, ANDROID_ACCENT_COLOR) + putString(CallkitConstants.EXTRA_CALLKIT_TEXT_COLOR, "#ffffff") + putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_SHOW_FULL_LOCKED_SCREEN, false) + putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_FULL_SCREEN, false) + putBoolean(CallkitConstants.EXTRA_CALLKIT_CALLING_SHOW, false) + } + } + + private fun conversationUuid(data: Map): String? { + val fromData = data["conversationUuid"] + ?: data["conversation_uuid"] + ?: data["conversation_id"] + ?: data["chat_uuid"] + ?: data["uuid"] + if (!fromData.isNullOrBlank()) { + return fromData + } + + val actionUrl = data["action_url"] ?: return null + val uuidFromQuery = Regex("""[?&](?:uuid|conversationUuid|conversation_uuid)=([^&]+)""") + .find(actionUrl) + ?.groupValues + ?.getOrNull(1) + ?.trim() + if (!uuidFromQuery.isNullOrEmpty()) { + return uuidFromQuery + } + + val pathMatch = Regex("""/video-call/([^/?#]+)""").find(actionUrl) + return pathMatch?.groupValues?.getOrNull(1)?.trim()?.takeIf { it.isNotEmpty() } + } + + private fun isTruthy(value: String?): Boolean { + if (value.isNullOrBlank()) return false + return when (value.trim().lowercase()) { + "1", "true", "yes" -> true + else -> false + } + } +} diff --git a/lib/core/di/cubit_registrations.dart b/lib/core/di/cubit_registrations.dart index 4e6ff7ee..499165e1 100644 --- a/lib/core/di/cubit_registrations.dart +++ b/lib/core/di/cubit_registrations.dart @@ -74,7 +74,10 @@ class CubitRegistrations { sl.registerLazySingleton(() => ChatsCubit(chatRepository: sl(), wsManager: sl(), profileRepository: sl())); sl.registerLazySingleton(() => InternetConnectionCubit()); sl.registerLazySingleton( - () => VideoCallManager(videoCallRepository: sl()), + () => VideoCallManager( + videoCallRepository: sl(), + profileRepository: sl(), + ), ); sl.registerLazySingleton( () => VideoCallSessionCubit( diff --git a/lib/core/notifications/push_navigation_handler.dart b/lib/core/notifications/push_navigation_handler.dart index 1c3b98a8..ad0286a1 100644 --- a/lib/core/notifications/push_navigation_handler.dart +++ b/lib/core/notifications/push_navigation_handler.dart @@ -3,6 +3,7 @@ import 'dart:developer' as developer; import 'dart:async'; import 'package:eatvio_app/core/app_router.dart'; +import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; @@ -68,6 +69,7 @@ class PushNavigationHandler { counterpartName: videoCallPayload.counterpartName, counterpartAvatarUrl: videoCallPayload.counterpartAvatarUrl, localParticipantName: videoCallPayload.localParticipantName, + entryMode: VideoCallEntryMode.incomingAccepted, ), ); return; diff --git a/lib/core/routing/other_routes.dart b/lib/core/routing/other_routes.dart index 42e29f10..cd139b77 100644 --- a/lib/core/routing/other_routes.dart +++ b/lib/core/routing/other_routes.dart @@ -15,6 +15,7 @@ import 'package:eatvio_app/features/chat/presentation/pages/empty_chat_screen.da import 'package:eatvio_app/features/chat/presentation/pages/payment_screen.dart'; import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart'; import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart'; +import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart'; import 'package:eatvio_app/features/consultant_rating/presentation/consultant_rating_screen.dart'; @@ -236,6 +237,7 @@ class OtherRoutes { counterpartName: extra['counterpartName'] as String?, counterpartAvatarUrl: extra['counterpartAvatarUrl'] as String?, localParticipantName: extra['localParticipantName'] as String?, + entryMode: _videoCallEntryModeFromRouteExtra(extra['entryMode']), onBackToChat: extra['onBackToChat'] as void Function()?, onHangUp: extra['onHangUp'] as void Function()?, ); @@ -330,3 +332,11 @@ class OtherRoutes { ]; } } + +VideoCallEntryMode _videoCallEntryModeFromRouteExtra(Object? raw) { + if (raw is VideoCallEntryMode) return raw; + if (raw == VideoCallEntryMode.incomingAccepted.name) { + return VideoCallEntryMode.incomingAccepted; + } + return VideoCallEntryMode.outgoing; +} diff --git a/lib/features/video_calls/managers/video_call_manager.dart b/lib/features/video_calls/managers/video_call_manager.dart index 92fade24..6c967381 100644 --- a/lib/features/video_calls/managers/video_call_manager.dart +++ b/lib/features/video_calls/managers/video_call_manager.dart @@ -1,10 +1,8 @@ import 'dart:async'; -import 'dart:io'; import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/widgets/show_error_dialog.dart'; -import 'package:audio_session/audio_session.dart'; import 'package:eatvio_app/features/video_calls/managers/video_call_manager_snapshot.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_audio_session_coordinator.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_foreground_coordinator.dart'; @@ -14,6 +12,7 @@ import 'package:eatvio_app/features/video_calls/services/video_call_task_handler import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_top_overlay.dart'; import 'package:eatvio_app/features/video_calls/repositories/video_call_repository.dart'; +import 'package:eatvio_app/features/profile/repositories/profile_repository.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_foreground_task/flutter_foreground_task.dart'; @@ -25,10 +24,12 @@ typedef VideoCallManagerListener = void Function(VideoCallManagerSnapshot snapsh class VideoCallManager { VideoCallManager({ required VideoCallRepository videoCallRepository, + ProfileRepository? profileRepository, VideoCallForegroundCoordinator? foregroundCoordinator, VideoCallLifecycleBinding? lifecycleBinding, VideoCallAudioSessionCoordinator? audioSessionCoordinator, }) : _videoCallRepository = videoCallRepository, + _profileRepository = profileRepository, _foreground = foregroundCoordinator ?? VideoCallForegroundCoordinator(), _lifecycleBinding = lifecycleBinding ?? VideoCallLifecycleBinding(), _audioSession = @@ -37,6 +38,7 @@ class VideoCallManager { } final VideoCallRepository _videoCallRepository; + final ProfileRepository? _profileRepository; final VideoCallForegroundCoordinator _foreground; final VideoCallLifecycleBinding _lifecycleBinding; final VideoCallAudioSessionCoordinator _audioSession; @@ -47,9 +49,6 @@ class VideoCallManager { static const Duration _mutedMicOverlayMinInterval = Duration( milliseconds: 2600, ); - static const Duration _localMicActivationInterruptionGrace = Duration( - milliseconds: 1500, - ); static const double _mutedMicAudioLevelThreshold = 0.008; static const double _mutedMicEnergyDelta = 1e-6; @@ -67,14 +66,13 @@ class VideoCallManager { bool _callEndedNotified = true; bool _callStageEntered = false; bool _joinInProgress = false; + bool _counterpartJoinedNotified = false; + Future? _roomConnectFuture; String? _preparedWsUrl; String? _preparedToken; bool _videoEnabledBeforeBackground = false; bool _videoPausedForBackground = false; - bool? _micEnabledBeforeSystemInterruption; - int _audioInterruptionHoldCount = 0; - DateTime? _ignoreAudioInterruptionUntil; - OverlayEntry? _systemInterruptionEntry; + Future? _myProfileNameFuture; VideoCallManagerSnapshot _snapshot = VideoCallManagerSnapshot.initial; @@ -128,11 +126,6 @@ class VideoCallManager { @visibleForTesting void setCallStageEnteredForTesting(bool value) => _callStageEntered = value; - @visibleForTesting - void handleAudioInterruptionForTesting(AudioInterruptionEvent event) { - unawaited(_onAudioInterruption(event)); - } - void dispose() { FlutterForegroundTask.removeTaskDataCallback(_onForegroundTaskData); _lifecycleBinding.detach(); @@ -228,11 +221,15 @@ class VideoCallManager { _startMutedMicPollTimer(); _ensureLifecycleAttached(); + final resolvedLocalName = await _resolveLocalParticipantName( + localParticipantName, + ); + _emitSnapshot( VideoCallManagerSnapshot( conversationUuid: conversationUuid, counterpartName: counterpartName, - localParticipantName: localParticipantName, + localParticipantName: resolvedLocalName, micEnabled: micEnabled, videoEnabled: videoEnabled, connectError: null, @@ -269,6 +266,10 @@ class VideoCallManager { localParticipantName ?? _snapshot.localParticipantName, ), ); + if (_room?.connectionState == lk.ConnectionState.connected) { + unawaited(_ensureLocalParticipantNamePublished()); + _syncRemoteParticipantNameFromRoom(); + } } } @@ -279,7 +280,7 @@ class VideoCallManager { _joinInProgress = true; try { - await _joinLiveKitRoom(); + await _ensureRoomConnected(); if (_liveKitDisposed) return false; _callStageEntered = true; @@ -301,6 +302,64 @@ class VideoCallManager { } } + /// Joins the LiveKit room without publishing local media so the outgoing + /// caller can detect when the callee enters. + Future ensureLobbyRoomJoined() async { + if (_callStageEntered || _joinInProgress || _liveKitDisposed) return; + if (_snapshot.connectPhase != VideoCallConnectPhase.connected) return; + + final room = _room; + if (room != null && room.connectionState == lk.ConnectionState.connected) { + _notifyCounterpartIfPresent(); + return; + } + + try { + await _ensureRoomConnected(); + if (!_liveKitDisposed) { + _notifyCounterpartIfPresent(); + } + } catch (_) {} + } + + Future _ensureRoomConnected() async { + final room = _room; + if (room != null && room.connectionState == lk.ConnectionState.connected) { + return; + } + + final pending = _roomConnectFuture; + if (pending != null) { + await pending; + return; + } + + final run = _joinLiveKitRoom(); + _roomConnectFuture = run; + try { + await run; + } finally { + if (identical(_roomConnectFuture, run)) { + _roomConnectFuture = null; + } + } + } + + void _notifyCounterpartIfPresent() { + if (_callStageEntered || _counterpartJoinedNotified || _liveKitDisposed) { + return; + } + final room = _room; + if (room == null || + room.connectionState != lk.ConnectionState.connected) { + return; + } + if (room.remoteParticipants.isEmpty) return; + + _counterpartJoinedNotified = true; + _emitSnapshot(_snapshot.copyWith(counterpartPresentInLobby: true)); + } + Future retryAfterConnectionLoss() async { if (_snapshot.conversationUuid.isEmpty) return; _disposeRoomEphemerals(); @@ -344,9 +403,8 @@ class VideoCallManager { _lifecycleBinding.detach(); _callStageEntered = false; _videoPausedForBackground = false; - _micEnabledBeforeSystemInterruption = null; - _audioInterruptionHoldCount = 0; _liveKitDisposed = true; + _myProfileNameFuture = null; _emitSnapshot(VideoCallManagerSnapshot.initial); } @@ -372,26 +430,7 @@ class VideoCallManager { /// [currentUiEnabled] reflects what the user sees on the mic control /// ([VideoCallSessionState.isMicActiveForUi]), not only stored intent. Future toggleMic(bool currentUiEnabled) async { - if (!currentUiEnabled && - _snapshot.systemAudioInterrupted && - _snapshot.micEnabled && - _isLocalMicrophonePublishing()) { - _clearSystemInterruptionState(); - _lastMutedMicTotalEnergy = null; - return; - } final next = !currentUiEnabled; - if (_snapshot.systemAudioInterrupted || _audioInterruptionHoldCount > 0) { - if (next) { - _micEnabledBeforeSystemInterruption = true; - } else { - _micEnabledBeforeSystemInterruption = false; - _emitSnapshot(_snapshot.copyWith(micEnabled: false)); - await _pauseLocalAudioForSystemInterruption(); - } - _lastMutedMicTotalEnergy = null; - return; - } if (!_callStageEntered) { _emitSnapshot(_snapshot.copyWith(micEnabled: next)); _lastMutedMicTotalEnergy = null; @@ -410,9 +449,6 @@ class VideoCallManager { } _emitSnapshot(_snapshot.copyWith(micEnabled: next)); _lastMutedMicTotalEnergy = null; - if (next) { - _suppressOwnMicActivationInterruptions(); - } await _room!.localParticipant?.setMicrophoneEnabled( next, audioCaptureOptions: _micCaptureOptions, @@ -499,30 +535,81 @@ class VideoCallManager { } String resolvedLocalDisplayName() { - final fromNav = _snapshot.localParticipantName?.trim(); - if (fromNav != null && fromNav.isNotEmpty) return fromNav; - final n = (_room?.localParticipant?.name ?? '').trim(); - if (n.isNotEmpty) return n; - final ctx = _navigatorContext; - if (ctx != null) { - return S.of(ctx).video_call_you; - } - return 'You'; + final fromSnapshot = _snapshot.localParticipantName?.trim(); + if (fromSnapshot != null && fromSnapshot.isNotEmpty) return fromSnapshot; + final fromRoom = (_room?.localParticipant?.name ?? '').trim(); + if (fromRoom.isNotEmpty) return fromRoom; + return ''; } - String resolvedRemoteDisplayName(String fallback) { - final c = _snapshot.counterpartName?.trim(); - if (c != null && c.isNotEmpty) return c; + String resolvedRemoteDisplayName([String fallback = '']) { + final fromSnapshot = _snapshot.counterpartName?.trim(); + if (fromSnapshot != null && fromSnapshot.isNotEmpty) return fromSnapshot; final room = _room; if (room != null) { - for (final p in room.remoteParticipants.values) { - final n = p.name.trim(); - if (n.isNotEmpty) return n; + for (final participant in room.remoteParticipants.values) { + final name = participant.name.trim(); + if (name.isNotEmpty) return name; } } return fallback; } + Future _resolveLocalParticipantName(String? hint) async { + final fromHint = hint?.trim(); + if (fromHint != null && fromHint.isNotEmpty) return fromHint; + final fromSnapshot = _snapshot.localParticipantName?.trim(); + if (fromSnapshot != null && fromSnapshot.isNotEmpty) return fromSnapshot; + return _fetchMyProfileName(); + } + + Future _fetchMyProfileName() async { + _myProfileNameFuture ??= _loadMyProfileName(); + return _myProfileNameFuture; + } + + Future _loadMyProfileName() async { + final repository = _profileRepository; + if (repository == null) return null; + try { + final name = (await repository.getMyProfile()).name.trim(); + return name.isEmpty ? null : name; + } catch (_) { + return null; + } + } + + Future _ensureLocalParticipantNamePublished() async { + var name = _snapshot.localParticipantName?.trim(); + if (name == null || name.isEmpty) { + name = await _resolveLocalParticipantName(null); + if (name != null && name.isNotEmpty) { + _emitSnapshot(_snapshot.copyWith(localParticipantName: name)); + } + } + _applyLocalParticipantNameToLiveKit(); + } + + void _applyLocalParticipantNameToLiveKit() { + final name = _snapshot.localParticipantName?.trim(); + if (name == null || name.isEmpty) return; + try { + _room?.localParticipant?.setName(name); + } catch (_) {} + } + + void _syncRemoteParticipantNameFromRoom() { + if (_snapshot.counterpartName?.trim().isNotEmpty == true) return; + final room = _room; + if (room == null) return; + for (final participant in room.remoteParticipants.values) { + final name = participant.name.trim(); + if (name.isEmpty) continue; + _emitSnapshot(_snapshot.copyWith(counterpartName: name)); + return; + } + } + void _ensureLifecycleAttached() { _lifecycleBinding.attach(_onAppLifecycleStateChanged); } @@ -622,13 +709,15 @@ class VideoCallManager { _lastMutedMicOverlayAt = null; _lastMutedMicTotalEnergy = null; _recordingOverlayScheduled = false; - _micEnabledBeforeSystemInterruption = null; - _audioInterruptionHoldCount = 0; - _ignoreAudioInterruptionUntil = null; _joinInProgress = false; + _counterpartJoinedNotified = false; + _roomConnectFuture = null; _clearPreparedConnectionCredentials(); - _syncSystemInterruptionOverlay(show: false); - _emitSnapshot(_snapshot.copyWith(systemAudioInterrupted: false)); + _emitSnapshot( + _snapshot.copyWith( + counterpartPresentInLobby: false, + ), + ); } void _clearPreparedConnectionCredentials() { @@ -657,10 +746,6 @@ class VideoCallManager { if (_liveKitDisposed || _mutedMicStatsInFlight) { return; } - if (_snapshot.systemAudioInterrupted && _snapshot.micEnabled) { - unawaited(_maybeClearStaleSystemInterruptionFromStats()); - return; - } if (_snapshot.micEnabled) { return; } @@ -706,6 +791,10 @@ class VideoCallManager { if (e.speakers.any((p) => p.sid == lp.sid)) { _maybeShowMutedMicOverlay(); } + }) + ..on((_) { + _notifyCounterpartIfPresent(); + _syncRemoteParticipantNameFromRoom(); }); } @@ -847,6 +936,9 @@ class VideoCallManager { await room.connect(wsUrl, token); if (_liveKitDisposed) return; + await _ensureLocalParticipantNamePublished(); + _syncRemoteParticipantNameFromRoom(); + _syncPoorConnectionOverlay( room.localParticipant?.connectionQuality ?? lk.ConnectionQuality.unknown, @@ -995,224 +1087,16 @@ class VideoCallManager { _lastMutedMicTotalEnergy = null; } - bool _isLocalMicrophonePublishing() { - final room = _room; - if (room == null || _liveKitDisposed) return false; - final lp = room.localParticipant; - if (lp == null) return false; - final pub = lp.getTrackPublicationBySource(lk.TrackSource.microphone); - if (pub == null || pub.muted) return false; - final track = pub.track; - if (track is lk.LocalAudioTrack) { - return !track.muted; - } - return track != null; - } - - Future _maybeClearStaleSystemInterruptionFromStats() async { - if (!_snapshot.systemAudioInterrupted) return; - if (!_snapshot.micEnabled || !_callStageEntered) return; - if (!_isLocalMicrophonePublishing()) return; - final room = _room; - if (room == null || _liveKitDisposed) return; - final lp = room.localParticipant; - if (lp == null) return; - final pub = lp.getTrackPublicationBySource(lk.TrackSource.microphone); - final track = pub?.track; - if (track is! lk.LocalAudioTrack) return; - try { - final stats = await track.getSenderStats(); - if (_liveKitDisposed || !_snapshot.systemAudioInterrupted) return; - final src = stats?.audioSourceStats; - if (src == null) return; - final level = src.audioLevel; - if (level != null && level >= _mutedMicAudioLevelThreshold) { - _clearSystemInterruptionState(); - return; - } - final energy = src.totalAudioEnergy; - if (energy == null || _lastMutedMicTotalEnergy == null) { - _lastMutedMicTotalEnergy = energy; - return; - } - if (energy - _lastMutedMicTotalEnergy! >= _mutedMicEnergyDelta) { - _clearSystemInterruptionState(); - } - _lastMutedMicTotalEnergy = energy; - } catch (_) {} - } - - void _clearSystemInterruptionState() { - if (!_snapshot.systemAudioInterrupted && _audioInterruptionHoldCount == 0) { - return; - } - _audioInterruptionHoldCount = 0; - _micEnabledBeforeSystemInterruption = null; - _emitSnapshot(_snapshot.copyWith(systemAudioInterrupted: false)); - _syncSystemInterruptionOverlay(show: false); - } - Future _startCallAudioSession() async { if (!_canHandleCallAudio) return; try { - await _audioSession.start(onInterruption: _onAudioInterruption); + await _audioSession.start(); } catch (_) {} } Future _stopCallAudioSession() async { - _syncSystemInterruptionOverlay(show: false); try { await _audioSession.stop(); } catch (_) {} } - - Future _onAudioInterruption(AudioInterruptionEvent event) async { - if (!_callStageEntered || - _liveKitDisposed || - _room == null || - _snapshot.connectPhase != VideoCallConnectPhase.connected) { - return; - } - if (_shouldIgnoreAudioInterruption(event)) { - return; - } - if (event.begin) { - _audioInterruptionHoldCount++; - if (_audioInterruptionHoldCount == 1) { - await _handleAudioInterruptionBegin(); - } else if (_snapshot.micEnabled) { - await _pauseLocalAudioForSystemInterruption(); - } - return; - } - if (_audioInterruptionHoldCount <= 0) { - if (_snapshot.micEnabled) { - await _restoreLocalAudioAfterSystemInterruption(); - } - return; - } - _audioInterruptionHoldCount--; - if (_audioInterruptionHoldCount == 0) { - await _handleAudioInterruptionEnd(); - } - } - - Future _handleAudioInterruptionBegin() async { - if (_snapshot.systemAudioInterrupted) return; - _micEnabledBeforeSystemInterruption ??= _snapshot.micEnabled; - _emitSnapshot(_snapshot.copyWith(systemAudioInterrupted: true)); - _syncSystemInterruptionOverlay(show: true); - await _pauseLocalAudioForSystemInterruption(); - } - - Future _pauseLocalAudioForSystemInterruption() async { - final room = _room; - if (room == null || _liveKitDisposed) return; - try { - await room.localParticipant?.setMicrophoneEnabled( - false, - audioCaptureOptions: _micCaptureOptions, - ); - } catch (_) {} - } - - Future _handleAudioInterruptionEnd() async { - if (!_snapshot.systemAudioInterrupted) return; - await _audioSession.reactivate(); - _emitSnapshot(_snapshot.copyWith(systemAudioInterrupted: false)); - _syncSystemInterruptionOverlay(show: false); - final restoreMic = - _micEnabledBeforeSystemInterruption ?? _snapshot.micEnabled; - _micEnabledBeforeSystemInterruption = null; - if (restoreMic) { - await _restoreLocalAudioAfterSystemInterruption(); - } - } - - Future _restoreLocalAudioAfterSystemInterruption() async { - if (!_canHandleCallAudio || !_snapshot.micEnabled) return; - final lp = _room!.localParticipant; - if (lp == null) return; - - try { - _suppressOwnMicActivationInterruptions(); - await lp.setMicrophoneEnabled( - true, - audioCaptureOptions: _micCaptureOptions, - ); - } catch (_) { - await _restartLocalAudioTrack(); - return; - } - - final pub = lp.getTrackPublicationBySource(lk.TrackSource.microphone); - final track = pub?.track; - if (track is lk.LocalAudioTrack && track.muted) { - await _restartLocalAudioTrack(); - } - } - - void _suppressOwnMicActivationInterruptions() { - if (!Platform.isAndroid) return; - _ignoreAudioInterruptionUntil = DateTime.now().add( - _localMicActivationInterruptionGrace, - ); - } - - bool _shouldIgnoreAudioInterruption(AudioInterruptionEvent event) { - if (!Platform.isAndroid || !event.begin) return false; - final until = _ignoreAudioInterruptionUntil; - if (until == null) return false; - if (DateTime.now().isBefore(until)) { - return true; - } - _ignoreAudioInterruptionUntil = null; - return false; - } - - Future _restartLocalAudioTrack() async { - if (!_canHandleCallAudio || !_snapshot.micEnabled) return; - final lp = _room!.localParticipant; - if (lp == null) return; - final pub = lp.getTrackPublicationBySource(lk.TrackSource.microphone); - final track = pub?.track; - if (track is lk.LocalAudioTrack) { - try { - _suppressOwnMicActivationInterruptions(); - await track.restartTrack(); - return; - } catch (_) {} - } - try { - await lp.setMicrophoneEnabled( - false, - audioCaptureOptions: _micCaptureOptions, - ); - _suppressOwnMicActivationInterruptions(); - await lp.setMicrophoneEnabled( - true, - audioCaptureOptions: _micCaptureOptions, - ); - } catch (_) {} - } - - void _syncSystemInterruptionOverlay({required bool show}) { - final ctx = _navigatorContext; - if (ctx == null) return; - if (show) { - if (_systemInterruptionEntry != null) return; - _systemInterruptionEntry = VideoCallTopOverlay.insertBanner( - ctx, - message: S.of(ctx).video_call_system_interruption, - backgroundColor: appTheme.videoCallNoticeBannerBackground, - svgAsset: VideoCallTopOverlay.warningSvg, - ); - return; - } - final entry = _systemInterruptionEntry; - _systemInterruptionEntry = null; - if (entry != null && entry.mounted) { - entry.remove(); - } - } } diff --git a/lib/features/video_calls/managers/video_call_manager_snapshot.dart b/lib/features/video_calls/managers/video_call_manager_snapshot.dart index c798c4f5..86a16070 100644 --- a/lib/features/video_calls/managers/video_call_manager_snapshot.dart +++ b/lib/features/video_calls/managers/video_call_manager_snapshot.dart @@ -10,7 +10,7 @@ class VideoCallManagerSnapshot { this.connectError, required this.connectPhase, required this.linkIssue, - this.systemAudioInterrupted = false, + this.counterpartPresentInLobby = false, }); final String conversationUuid; @@ -21,7 +21,7 @@ class VideoCallManagerSnapshot { final String? connectError; final VideoCallConnectPhase connectPhase; final VideoCallLinkIssue linkIssue; - final bool systemAudioInterrupted; + final bool counterpartPresentInLobby; static const initial = VideoCallManagerSnapshot( conversationUuid: '', @@ -40,7 +40,7 @@ class VideoCallManagerSnapshot { String? connectError, VideoCallConnectPhase? connectPhase, VideoCallLinkIssue? linkIssue, - bool? systemAudioInterrupted, + bool? counterpartPresentInLobby, bool clearConnectError = false, }) { return VideoCallManagerSnapshot( @@ -52,8 +52,8 @@ class VideoCallManagerSnapshot { connectError: clearConnectError ? null : (connectError ?? this.connectError), connectPhase: connectPhase ?? this.connectPhase, linkIssue: linkIssue ?? this.linkIssue, - systemAudioInterrupted: - systemAudioInterrupted ?? this.systemAudioInterrupted, + counterpartPresentInLobby: + counterpartPresentInLobby ?? this.counterpartPresentInLobby, ); } } diff --git a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart index a7c73fc8..332d4fcf 100644 --- a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart +++ b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart @@ -8,6 +8,8 @@ import 'package:eatvio_app/features/video_calls/services/video_call_system_pip_c import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:livekit_client/livekit_client.dart' as lk; +import 'package:meta/meta.dart'; +import 'package:permission_handler/permission_handler.dart'; class VideoCallSessionCubit extends BaseCubit { VideoCallSessionCubit({ @@ -24,6 +26,9 @@ class VideoCallSessionCubit extends BaseCubit { final VideoCallManager _videoCallManager; final VideoCallSystemPipCoordinator _systemPipCoordinator; bool _callScreenRouteVisible = false; + bool _autoEnterInFlight = false; + Future? _autoEnterFuture; + Future? _outgoingAutoEnterFuture; lk.Room? get liveKitRoom => _videoCallManager.room; bool get isCallScreenRouteVisible => _callScreenRouteVisible; @@ -52,7 +57,6 @@ class VideoCallSessionCubit extends BaseCubit { connectError: hideStaleConnectError ? null : snapshot.connectError, connectPhase: snapshot.connectPhase, linkIssue: snapshot.linkIssue, - systemAudioInterrupted: snapshot.systemAudioInterrupted, callStageEntered: resetCallStage ? false : state.callStageEntered, uiMode: _isCallSessionInactive(snapshot) @@ -64,16 +68,24 @@ class VideoCallSessionCubit extends BaseCubit { void _onManagerSnapshot(VideoCallManagerSnapshot snapshot) { if (isClosed) return; if (_isCallSessionInactive(snapshot)) { + if (_callScreenRouteVisible) { + // [VideoCallManager.startCall] briefly emits idle while replacing the room. + return; + } unawaited(_systemPipCoordinator.disarm()); emit(const VideoCallSessionState()); unawaited(_restoreSystemUiAfterCall()); return; } - emit(_sessionStateFromSnapshot(snapshot)); + final next = _sessionStateFromSnapshot(snapshot); + emit(next); + _scheduleAutoEnterIfNeeded(next); + _scheduleOutgoingAutoEnterIfNeeded(next, snapshot); } void _syncFromManager(VideoCallManagerSnapshot snapshot) { if (_isCallSessionInactive(snapshot)) { + if (_callScreenRouteVisible) return; emit(const VideoCallSessionState()); return; } @@ -111,12 +123,49 @@ class VideoCallSessionCubit extends BaseCubit { required String conversationUuid, String? counterpartName, String? localParticipantName, + VideoCallEntryMode entryMode = VideoCallEntryMode.outgoing, }) async { _callScreenRouteVisible = true; + + final effectiveEntryMode = + state.entryMode == VideoCallEntryMode.incomingAccepted || + entryMode == VideoCallEntryMode.incomingAccepted + ? VideoCallEntryMode.incomingAccepted + : entryMode; + final isIncomingAccepted = + effectiveEntryMode == VideoCallEntryMode.incomingAccepted; final isResumingActiveCall = state.conversationUuid == conversationUuid && state.callStageEntered && _videoCallManager.room != null; + final isSameCallConnecting = + state.conversationUuid == conversationUuid && + !state.callStageEntered && + state.connectPhase != VideoCallConnectPhase.idle; + + if (!isResumingActiveCall && !isSameCallConnecting) { + await _systemPipCoordinator.disarm(); + } + + if (isSameCallConnecting) { + emit( + state.copyWith( + counterpartName: counterpartName ?? state.counterpartName, + localParticipantName: + localParticipantName ?? state.localParticipantName, + entryMode: effectiveEntryMode, + ), + ); + if (isIncomingAccepted) { + await _tryAutoEnterCallStage(); + } + emit(state.copyWith(uiMode: VideoCallUiMode.fullscreen)); + await _ensureSystemPipArmedIfInCall(); + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_applyVideoCallSystemUi()); + }); + return; + } if (isResumingActiveCall) { await _videoCallManager.onCallScreenVisible( @@ -137,6 +186,8 @@ class VideoCallSessionCubit extends BaseCubit { callStageEntered: false, connectError: null, linkIssue: VideoCallLinkIssue.none, + connectPhase: VideoCallConnectPhase.connecting, + entryMode: effectiveEntryMode, ), ); await _videoCallManager.startCall( @@ -146,17 +197,137 @@ class VideoCallSessionCubit extends BaseCubit { micEnabled: false, videoEnabled: false, ); + emit(state.copyWith(entryMode: effectiveEntryMode)); + if (isIncomingAccepted) { + await _tryAutoEnterCallStage(); + } } emit(state.copyWith(uiMode: VideoCallUiMode.fullscreen)); + await _ensureSystemPipArmedIfInCall(); WidgetsBinding.instance.addPostFrameCallback((_) { unawaited(_applyVideoCallSystemUi()); }); } + Future _ensureSystemPipArmedIfInCall() async { + if (!state.callStageEntered) return; + if (state.connectPhase != VideoCallConnectPhase.connected) return; + if (state.linkIssue == VideoCallLinkIssue.lost) return; + await _systemPipCoordinator.armAutoPipOnLeave(); + } + + void _scheduleAutoEnterIfNeeded(VideoCallSessionState sessionState) { + if (sessionState.entryMode != VideoCallEntryMode.incomingAccepted) return; + if (sessionState.callStageEntered) return; + if (sessionState.connectPhase != VideoCallConnectPhase.connected) return; + unawaited(_tryAutoEnterCallStage()); + } + + void _scheduleOutgoingAutoEnterIfNeeded( + VideoCallSessionState sessionState, + VideoCallManagerSnapshot snapshot, + ) { + if (sessionState.entryMode != VideoCallEntryMode.outgoing) return; + if (sessionState.callStageEntered) return; + if (sessionState.connectPhase != VideoCallConnectPhase.connected) return; + + if (!snapshot.counterpartPresentInLobby) { + unawaited(_videoCallManager.ensureLobbyRoomJoined()); + return; + } + unawaited(_tryAutoEnterWhenCounterpartJoins()); + } + + Future _tryAutoEnterWhenCounterpartJoins() { + final pending = _outgoingAutoEnterFuture; + if (pending != null) return pending; + + final run = _runOutgoingAutoEnterCallStage(); + _outgoingAutoEnterFuture = run; + return run.whenComplete(() { + if (identical(_outgoingAutoEnterFuture, run)) { + _outgoingAutoEnterFuture = null; + } + }); + } + + Future _runOutgoingAutoEnterCallStage() async { + if (isClosed || _autoEnterInFlight) return; + if (state.entryMode != VideoCallEntryMode.outgoing) return; + if (state.callStageEntered) return; + if (state.connectPhase != VideoCallConnectPhase.connected) return; + + _autoEnterInFlight = true; + try { + await enterCallStage(); + } finally { + _autoEnterInFlight = false; + } + } + + Future _tryAutoEnterCallStage() { + final pending = _autoEnterFuture; + if (pending != null) return pending; + + final run = _runAutoEnterCallStage(); + _autoEnterFuture = run; + return run.whenComplete(() { + if (identical(_autoEnterFuture, run)) { + _autoEnterFuture = null; + } + }); + } + + Future _runAutoEnterCallStage() async { + if (isClosed || _autoEnterInFlight) return; + if (state.entryMode != VideoCallEntryMode.incomingAccepted) return; + if (state.callStageEntered) return; + if (state.connectPhase != VideoCallConnectPhase.connected) return; + + _autoEnterInFlight = true; + try { + await _requestMicBeforeIncomingEnter(); + await enterCallStage(); + } finally { + _autoEnterInFlight = false; + } + } + + /// After the loading/connect phase, request mic before joining the room. + Future _requestMicBeforeIncomingEnter() async { + if (state.entryMode != VideoCallEntryMode.incomingAccepted) return; + + final granted = await _requestMicPermissionForCall(); + if (!granted || isClosed) return; + + await _videoCallManager.toggleMic(state.isMicActiveForUi); + if (isClosed) return; + emit(state.copyWith(micEnabled: true)); + } + + @visibleForTesting + Future requestMicPermissionForCall() => _requestMicPermissionForCall(); + + Future _requestMicPermissionForCall() async { + try { + final status = await Permission.microphone.request(); + return status.isGranted; + } catch (_) { + return false; + } + } + void onCallScreenDisposed() { _callScreenRouteVisible = false; - if (state.uiMode != VideoCallUiMode.fullscreen) return; + if (state.uiMode != VideoCallUiMode.fullscreen) { + if (state.uiMode == VideoCallUiMode.pip && state.callStageEntered) { + // [isCallScreenRouteVisible] is not in bloc state; refresh the global + // PiP overlay after the call route is removed. + emit(state.copyWith()); + } + return; + } if (state.connectPhase == VideoCallConnectPhase.connected && state.linkIssue != VideoCallLinkIssue.lost) { if (state.callStageEntered) { @@ -179,15 +350,29 @@ class VideoCallSessionCubit extends BaseCubit { Future _applyPipUiState() async { emit(state.copyWith(uiMode: VideoCallUiMode.pip)); await _restoreSystemUiAfterCall(); + await _ensureSystemPipArmedIfInCall(); } /// Called when Android enters system PiP (e.g. Home gesture). Future syncPipUiStateFromSystem() async { if (!_canMinimizeToPip) return; if (state.uiMode == VideoCallUiMode.pip) return; + // Call screen already renders the in-call mini preview; enabling the global + // overlay here would duplicate PiP after returning from system PiP. + if (_callScreenRouteVisible) return; await _applyPipUiState(); } + /// Re-applies immersive UI after Android system PiP while the call screen + /// route is still on the stack. + Future onSystemPipExitedWithCallScreenVisible() async { + if (!_callScreenRouteVisible) return; + if (state.uiMode != VideoCallUiMode.fullscreen) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_applyVideoCallSystemUi()); + }); + } + Future minimizeToPip() async { if (!_canMinimizeToPip) return; await _applyPipUiState(); @@ -206,6 +391,7 @@ class VideoCallSessionCubit extends BaseCubit { if (state.connectPhase == VideoCallConnectPhase.connected && state.linkIssue != VideoCallLinkIssue.lost) { if (state.callStageEntered) { + _callScreenRouteVisible = false; await minimizeToPip(); } else { await endCall(); @@ -223,7 +409,13 @@ class VideoCallSessionCubit extends BaseCubit { final entered = await _videoCallManager.onEnterCallStage(); if (!entered || isClosed) return; - emit(state.copyWith(callStageEntered: true)); + emit( + state.copyWith( + callStageEntered: true, + splitParticipantView: false, + mainStageShowsLocalVideo: false, + ), + ); unawaited(_systemPipCoordinator.armAutoPipOnLeave()); WidgetsBinding.instance.addPostFrameCallback((_) { if (isClosed) return; @@ -232,8 +424,9 @@ class VideoCallSessionCubit extends BaseCubit { } Future retryAfterConnectionLoss() async { + final entryMode = state.entryMode; await _videoCallManager.retryAfterConnectionLoss(); - emit(state.copyWith(callStageEntered: false)); + emit(state.copyWith(callStageEntered: false, entryMode: entryMode)); } Future endCall() async { @@ -309,7 +502,7 @@ class VideoCallSessionCubit extends BaseCubit { String resolvedLocalDisplayName() => _videoCallManager.resolvedLocalDisplayName(); - String resolvedRemoteDisplayName(String fallback) => + String resolvedRemoteDisplayName([String fallback = '']) => _videoCallManager.resolvedRemoteDisplayName(fallback); @override diff --git a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart index a7c38545..588a01ff 100644 --- a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart +++ b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart @@ -12,6 +12,15 @@ enum VideoCallConnectPhase { idle, connecting, connected } enum VideoCallLinkIssue { none, reconnecting, lost } +/// How the user reached [VideoCallScreen]. +enum VideoCallEntryMode { + /// Outgoing call or push tap — lobby with manual enter. + outgoing, + + /// Accepted incoming CallKit / VoIP / notification — loader then auto-enter. + incomingAccepted, +} + @freezed abstract class VideoCallSessionState with _$VideoCallSessionState { const VideoCallSessionState._(); @@ -22,7 +31,7 @@ abstract class VideoCallSessionState with _$VideoCallSessionState { String? localParticipantName, @Default(false) bool micEnabled, @Default(false) bool videoEnabled, - @Default(true) bool splitParticipantView, + @Default(false) bool splitParticipantView, @Default(false) bool mainStageShowsLocalVideo, @Default(true) bool useFrontCamera, String? connectError, @@ -30,8 +39,8 @@ abstract class VideoCallSessionState with _$VideoCallSessionState { @Default(VideoCallConnectPhase.idle) VideoCallConnectPhase connectPhase, @Default(VideoCallLinkIssue.none) VideoCallLinkIssue linkIssue, @Default(false) bool callStageEntered, - @Default(false) bool systemAudioInterrupted, + @Default(VideoCallEntryMode.outgoing) VideoCallEntryMode entryMode, }) = _VideoCallSessionState; - bool get isMicActiveForUi => micEnabled && !systemAudioInterrupted; + bool get isMicActiveForUi => micEnabled; } diff --git a/lib/features/video_calls/presentation/pages/video_call_screen.dart b/lib/features/video_calls/presentation/pages/video_call_screen.dart index e225cf87..b8aefe95 100644 --- a/lib/features/video_calls/presentation/pages/video_call_screen.dart +++ b/lib/features/video_calls/presentation/pages/video_call_screen.dart @@ -8,6 +8,7 @@ import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_ses import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_connecting_entrance_screen.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_flow_placeholder_screens.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_stage_layout.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -21,6 +22,7 @@ class VideoCallScreen extends StatefulWidget { this.counterpartName, this.counterpartAvatarUrl, this.localParticipantName, + this.entryMode = VideoCallEntryMode.outgoing, this.onBackToChat, this.onHangUp, }); @@ -32,6 +34,7 @@ class VideoCallScreen extends StatefulWidget { final String? counterpartName; final String? counterpartAvatarUrl; final String? localParticipantName; + final VideoCallEntryMode entryMode; final VoidCallback? onBackToChat; final VoidCallback? onHangUp; @@ -62,6 +65,7 @@ class _VideoCallScreenState extends State { conversationUuid: widget.conversationUuid, counterpartName: widget.counterpartName, localParticipantName: widget.localParticipantName, + entryMode: widget.entryMode, ), ); }); @@ -94,7 +98,6 @@ class _VideoCallScreenState extends State { final topInset = viewPadding.top > 0 ? viewPadding.top : 16.0; final bottomInset = viewPadding.bottom; - final counterpartFallback = s.video_call_counterpart_default; final controlLabelStyle = TextStyles.textSMedium.copyWith( color: appTheme.whiteA700, height: 20 / 14, @@ -115,13 +118,14 @@ class _VideoCallScreenState extends State { a.connectError != b.connectError || a.linkIssue != b.linkIssue || a.counterpartName != b.counterpartName || + a.localParticipantName != b.localParticipantName || a.callStageEntered != b.callStageEntered || a.micEnabled != b.micEnabled || - a.systemAudioInterrupted != b.systemAudioInterrupted || a.videoEnabled != b.videoEnabled || a.splitParticipantView != b.splitParticipantView || a.mainStageShowsLocalVideo != b.mainStageShowsLocalVideo || - a.useFrontCamera != b.useFrontCamera, + a.useFrontCamera != b.useFrontCamera || + a.entryMode != b.entryMode, builder: (context, sessionState) { final room = _cubit.liveKitRoom; @@ -159,14 +163,31 @@ class _VideoCallScreenState extends State { } final showEntranceLobby = + sessionState.entryMode == VideoCallEntryMode.outgoing && !sessionState.callStageEntered && sessionState.linkIssue == VideoCallLinkIssue.none && sessionState.connectPhase != VideoCallConnectPhase.idle; - if (showEntranceLobby) { - final remoteName = _cubit.resolvedRemoteDisplayName( - counterpartFallback, + final showAutoEnterLoading = + sessionState.entryMode == VideoCallEntryMode.incomingAccepted && + !sessionState.callStageEntered && + sessionState.linkIssue == VideoCallLinkIssue.none && + sessionState.connectPhase != VideoCallConnectPhase.idle; + + if (showAutoEnterLoading) { + return AnnotatedRegion( + value: _videoCallOverlayStyle, + child: Scaffold( + backgroundColor: appTheme.whiteA700, + body: VideoCallLoadingScreen( + onClose: () => unawaited(_handleHangUp()), + ), + ), ); + } + + if (showEntranceLobby) { + final remoteName = _resolveRemoteDisplayName(sessionState); final canEnterCall = sessionState.connectPhase == VideoCallConnectPhase.connected; return AnnotatedRegion( @@ -228,18 +249,15 @@ class _VideoCallScreenState extends State { child: Scaffold( backgroundColor: appTheme.whiteA700, body: ListenableBuilder( - listenable: activeRoom, + listenable: videoCallRoomTracksListenable(activeRoom), builder: (context, _) { return VideoCallStageLayout( room: activeRoom, connectError: sessionState.connectError, - localDisplayName: _cubit.resolvedLocalDisplayName(), - remoteDisplayName: _cubit.resolvedRemoteDisplayName( - counterpartFallback, - ), - micEnabled: sessionState.isMicActiveForUi, + localDisplayName: _resolveLocalDisplayName(sessionState), + remoteDisplayName: _resolveRemoteDisplayName(sessionState), + micEnabled: sessionState.micEnabled, videoEnabled: sessionState.videoEnabled, - systemAudioInterrupted: sessionState.systemAudioInterrupted, splitParticipantView: sessionState.splitParticipantView, mainStageShowsLocalVideo: sessionState.mainStageShowsLocalVideo, @@ -272,4 +290,20 @@ class _VideoCallScreenState extends State { ), ); } + + String _resolveLocalDisplayName(VideoCallSessionState sessionState) { + final resolved = _cubit.resolvedLocalDisplayName().trim(); + if (resolved.isNotEmpty) return resolved; + return sessionState.localParticipantName?.trim() ?? + widget.localParticipantName?.trim() ?? + ''; + } + + String _resolveRemoteDisplayName(VideoCallSessionState sessionState) { + final resolved = _cubit.resolvedRemoteDisplayName().trim(); + if (resolved.isNotEmpty) return resolved; + return sessionState.counterpartName?.trim() ?? + widget.counterpartName?.trim() ?? + ''; + } } diff --git a/lib/features/video_calls/presentation/widgets/video_call_app_shell.dart b/lib/features/video_calls/presentation/widgets/video_call_app_shell.dart index 5a972711..e7fdd6fd 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_app_shell.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_app_shell.dart @@ -1,10 +1,14 @@ import 'dart:io'; +import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; +import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_system_pip_content.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart'; import 'package:floating/floating.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; /// Root overlay shell: system PiP on Android, in-app PiP fallback on iOS. class VideoCallAppShell extends StatelessWidget { @@ -25,9 +29,25 @@ class VideoCallAppShell extends StatelessWidget { if (!Platform.isAndroid) return shell; - return PiPSwitcher( - childWhenDisabled: shell, - childWhenEnabled: const VideoCallSystemPipContent(), + return BlocBuilder( + bloc: sl(), + buildWhen: (previous, current) => + previous.callStageEntered != current.callStageEntered || + previous.connectPhase != current.connectPhase || + previous.linkIssue != current.linkIssue, + builder: (context, state) { + final allowSystemPipShell = + state.callStageEntered && + state.connectPhase == VideoCallConnectPhase.connected && + state.linkIssue != VideoCallLinkIssue.lost; + + if (!allowSystemPipShell) return shell; + + return PiPSwitcher( + childWhenDisabled: shell, + childWhenEnabled: const VideoCallSystemPipContent(), + ); + }, ); } } diff --git a/lib/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart b/lib/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart index e9c9b281..9a1e4e55 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'dart:math' as math; import 'package:eatvio_app/core/app_router.dart'; @@ -7,6 +8,7 @@ import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_ses import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_pip_video_preview.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -68,6 +70,9 @@ class _VideoCallGlobalPipLayerState extends State { return const SizedBox.shrink(); } final cubit = context.read(); + if (Platform.isAndroid && cubit.isCallScreenRouteVisible) { + return const SizedBox.shrink(); + } final room = cubit.liveKitRoom; if (room == null) return const SizedBox.shrink(); @@ -94,7 +99,7 @@ class _VideoCallGlobalPipLayerState extends State { return Material( type: MaterialType.transparency, child: ListenableBuilder( - listenable: room, + listenable: videoCallRoomTracksListenable(room), builder: (context, _) { return Stack( clipBehavior: Clip.none, diff --git a/lib/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart b/lib/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart index 547a9320..18b1acba 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart @@ -8,10 +8,8 @@ abstract final class VideoCallLiveKitTrackUtils { static lk.VideoTrack? visibleVideoFromPublication(lk.TrackPublication? pub) { if (pub == null || pub.muted) return null; - if (pub is lk.RemoteTrackPublication) { - if (!pub.subscribed || pub.streamState != lk.StreamState.active) { - return null; - } + if (pub is lk.RemoteTrackPublication && !pub.subscribed) { + return null; } final v = asVideoTrack(pub.track); if (v == null || v.muted) return null; @@ -42,4 +40,6 @@ abstract final class VideoCallLiveKitTrackUtils { room.localParticipant?.getTrackPublicationBySource(lk.TrackSource.camera), ); } + + static bool isSoloInRoom(lk.Room room) => room.remoteParticipants.isEmpty; } diff --git a/lib/features/video_calls/presentation/widgets/video_call_pip_video_preview.dart b/lib/features/video_calls/presentation/widgets/video_call_pip_video_preview.dart index 0653f7e2..c3baad4c 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_pip_video_preview.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_pip_video_preview.dart @@ -1,6 +1,8 @@ import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_no_video_background.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_remote_video_stage.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart'; import 'package:flutter/material.dart'; import 'package:livekit_client/livekit_client.dart' as lk; @@ -15,26 +17,55 @@ class VideoCallPipVideoPreview extends StatelessWidget { final VideoCallSessionState sessionState; final lk.Room room; + static const double _pipNoVideoLogoSize = 56; + @override Widget build(BuildContext context) { - final remote = VideoCallLiveKitTrackUtils.remoteCameraVideoTrack(room); - if (remote != null) { - return IgnorePointer( - child: lk.VideoTrackRenderer(remote, fit: lk.VideoViewFit.cover), - ); + return ListenableBuilder( + listenable: videoCallRoomTracksListenable(room), + builder: (context, _) { + if (VideoCallLiveKitTrackUtils.isSoloInRoom(room)) { + return _VideoCallPipLocalPreview( + room: room, + videoEnabled: sessionState.videoEnabled, + ); + } + + return VideoCallRemoteVideoStage( + room: room, + connectError: null, + noVideoLogoSize: _pipNoVideoLogoSize, + ); + }, + ); + } +} + +class _VideoCallPipLocalPreview extends StatelessWidget { + const _VideoCallPipLocalPreview({ + required this.room, + required this.videoEnabled, + }); + + final lk.Room room; + final bool videoEnabled; + + @override + Widget build(BuildContext context) { + if (!videoEnabled) { + return const VideoCallNoVideoBackground(logoSize: 56); } - if (sessionState.videoEnabled) { - final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room); - if (local != null) { - return IgnorePointer( - child: lk.VideoTrackRenderer( - local, - fit: lk.VideoViewFit.cover, - mirrorMode: lk.VideoViewMirrorMode.auto, - ), - ); - } + final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room); + if (local != null) { + return IgnorePointer( + child: lk.VideoTrackRenderer( + local, + key: ValueKey(local.sid), + fit: lk.VideoViewFit.cover, + mirrorMode: lk.VideoViewMirrorMode.auto, + ), + ); } return const VideoCallNoVideoBackground(logoSize: 56); diff --git a/lib/features/video_calls/presentation/widgets/video_call_remote_video_stage.dart b/lib/features/video_calls/presentation/widgets/video_call_remote_video_stage.dart index 86c6333f..3f35cd61 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_remote_video_stage.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_remote_video_stage.dart @@ -2,6 +2,7 @@ import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_no_video_background.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart'; import 'package:flutter/material.dart'; import 'package:livekit_client/livekit_client.dart' as lk; @@ -10,19 +11,25 @@ class VideoCallRemoteVideoStage extends StatelessWidget { super.key, required this.room, required this.connectError, + this.noVideoLogoSize = 96, }); final lk.Room room; final String? connectError; + final double noVideoLogoSize; @override Widget build(BuildContext context) { return ListenableBuilder( - listenable: room, + listenable: videoCallRoomTracksListenable(room), builder: (context, _) { final remote = VideoCallLiveKitTrackUtils.remoteCameraVideoTrack(room); if (remote != null) { - return lk.VideoTrackRenderer(remote, fit: lk.VideoViewFit.cover); + return lk.VideoTrackRenderer( + remote, + key: ValueKey(remote.sid), + fit: lk.VideoViewFit.cover, + ); } final showConnectError = connectError != null && @@ -30,7 +37,7 @@ class VideoCallRemoteVideoStage extends StatelessWidget { return Stack( fit: StackFit.expand, children: [ - const VideoCallNoVideoBackground(), + VideoCallNoVideoBackground(logoSize: noVideoLogoSize), if (showConnectError) Center( child: Padding( diff --git a/lib/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart b/lib/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart new file mode 100644 index 00000000..c36801d7 --- /dev/null +++ b/lib/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart @@ -0,0 +1,11 @@ +import 'package:flutter/foundation.dart'; +import 'package:livekit_client/livekit_client.dart' as lk; + + +Listenable videoCallRoomTracksListenable(lk.Room room) { + return Listenable.merge([ + room, + ...room.remoteParticipants.values, + if (room.localParticipant != null) room.localParticipant!, + ]); +} diff --git a/lib/features/video_calls/presentation/widgets/video_call_stage_layout.dart b/lib/features/video_calls/presentation/widgets/video_call_stage_layout.dart index c70f3407..feac9376 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_stage_layout.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_stage_layout.dart @@ -1,13 +1,11 @@ import 'dart:math' as math; -import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_controls.dart'; -import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_top_overlay.dart'; -import 'package:eatvio_app/generated/l10n.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_name_chips.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_no_video_background.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_remote_video_stage.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:livekit_client/livekit_client.dart' as lk; @@ -28,7 +26,6 @@ class VideoCallStageLayout extends StatefulWidget { required this.localDisplayName, required this.remoteDisplayName, required this.micEnabled, - this.systemAudioInterrupted = false, required this.videoEnabled, required this.splitParticipantView, required this.mainStageShowsLocalVideo, @@ -53,7 +50,6 @@ class VideoCallStageLayout extends StatefulWidget { final String localDisplayName; final String remoteDisplayName; final bool micEnabled; - final bool systemAudioInterrupted; final bool videoEnabled; final bool splitParticipantView; final bool mainStageShowsLocalVideo; @@ -88,6 +84,8 @@ class _VideoCallStageLayoutState extends State { static const double _pipInitialExtraDown = 16; static const double _bottomGradientHeight = 140; static const double _splitExpandButtonTop = 8 + _shrinkExpandExtraTop; + static const int _splitRemoteFlex = 7; + static const int _splitLocalFlex = 3; double? _pipLeft; double? _pipTop; @@ -95,13 +93,6 @@ class _VideoCallStageLayoutState extends State { double? _pipPanOriginLeft; double? _pipPanOriginTop; - bool _pipHasVisibleVideo() { - if (widget.mainStageShowsLocalVideo) { - return VideoCallLiveKitTrackUtils.remoteCameraVideoTrack(widget.room) != null; - } - return widget.videoEnabled && VideoCallLiveKitTrackUtils.localCameraVideoTrack(widget.room) != null; - } - @override Widget build(BuildContext context) { return LayoutBuilder( @@ -115,15 +106,17 @@ class _VideoCallStageLayoutState extends State { } Widget _buildSplitLayout(BoxConstraints constraints) { - return Stack( - fit: StackFit.expand, - children: [ - Positioned.fill( - child: Padding( - padding: EdgeInsets.fromLTRB(_edgeInset, widget.topInset + _edgeInset, _edgeInset, _edgeInset), - child: Column( - children: [ - Expanded( + return ListenableBuilder( + listenable: videoCallRoomTracksListenable(widget.room), + builder: (context, _) { + final soloInRoom = VideoCallLiveKitTrackUtils.isSoloInRoom(widget.room); + if (soloInRoom) { + return Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: Padding( + padding: EdgeInsets.fromLTRB(_edgeInset, widget.topInset + _edgeInset, _edgeInset, _edgeInset), child: _SplitParticipantPane( room: widget.room, displayName: widget.localDisplayName, @@ -133,54 +126,73 @@ class _VideoCallStageLayoutState extends State { useFrontCamera: widget.useFrontCamera, onExpand: widget.onExpandLocal, onSwitchCamera: widget.onSwitchCamera, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(_mainStageCornerRadius), - topRight: Radius.circular(_mainStageCornerRadius), - bottomLeft: Radius.zero, - bottomRight: Radius.zero, - ), + borderRadius: BorderRadius.circular(_mainStageCornerRadius), ), ), - const SizedBox(height: _edgeInset), - Expanded( - child: _SplitParticipantPane( - room: widget.room, - displayName: widget.remoteDisplayName, - isLocal: false, - connectError: widget.connectError, - videoEnabled: widget.videoEnabled, - useFrontCamera: widget.useFrontCamera, - onExpand: widget.onExpandRemote, - onSwitchCamera: widget.onSwitchCamera, - borderRadius: const BorderRadius.only( - topLeft: Radius.zero, - topRight: Radius.zero, - bottomLeft: Radius.circular(_mainStageCornerRadius), - bottomRight: Radius.circular(_mainStageCornerRadius), - ), - ), - ), - ], - ), - ), - ), - ..._bottomBarGradient(), - ..._bottomControlsRow(), - ..._systemInterruptionBanner(), - ], - ); - } + ), + ..._bottomBarGradient(), + ..._bottomControlsRow(), + ], + ); + } - List _systemInterruptionBanner() { - if (!widget.systemAudioInterrupted) return const []; - return [ - Positioned( - left: 16, - right: 16, - top: widget.topInset + 8, - child: const _VideoCallSystemInterruptionBanner(), - ), - ]; + return Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: Padding( + padding: EdgeInsets.fromLTRB(_edgeInset, widget.topInset + _edgeInset, _edgeInset, _edgeInset), + child: Column( + children: [ + Expanded( + flex: _splitRemoteFlex, + child: _SplitParticipantPane( + room: widget.room, + displayName: widget.remoteDisplayName, + isLocal: false, + connectError: widget.connectError, + videoEnabled: widget.videoEnabled, + useFrontCamera: widget.useFrontCamera, + onExpand: widget.onExpandRemote, + onSwitchCamera: widget.onSwitchCamera, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(_mainStageCornerRadius), + topRight: Radius.circular(_mainStageCornerRadius), + bottomLeft: Radius.zero, + bottomRight: Radius.zero, + ), + ), + ), + const SizedBox(height: _edgeInset), + Expanded( + flex: _splitLocalFlex, + child: _SplitParticipantPane( + room: widget.room, + displayName: widget.localDisplayName, + isLocal: true, + connectError: null, + videoEnabled: widget.videoEnabled, + useFrontCamera: widget.useFrontCamera, + onExpand: widget.onExpandLocal, + onSwitchCamera: widget.onSwitchCamera, + borderRadius: const BorderRadius.only( + topLeft: Radius.zero, + topRight: Radius.zero, + bottomLeft: Radius.circular(_mainStageCornerRadius), + bottomRight: Radius.circular(_mainStageCornerRadius), + ), + ), + ), + ], + ), + ), + ), + ..._bottomBarGradient(), + ..._bottomControlsRow(), + ], + ); + }, + ); } Widget _buildFullscreenLayout(BoxConstraints constraints) { @@ -194,100 +206,105 @@ class _VideoCallStageLayoutState extends State { final pipLeft = ((_pipLeft ?? defaultPipLeft).clamp(_edgeInset, maxPipLeft) as num).toDouble(); final pipTop = ((_pipTop ?? defaultPipTop).clamp(minPipTop, maxPipTop) as num).toDouble(); - return Stack( - fit: StackFit.expand, - children: [ - Positioned.fill( - child: Padding( - padding: EdgeInsets.fromLTRB(_edgeInset, widget.topInset + _edgeInset, _edgeInset, _edgeInset), - child: Material( - type: MaterialType.transparency, - clipBehavior: Clip.antiAlias, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(_mainStageCornerRadius)), - child: Stack( - fit: StackFit.expand, - children: [ - _VideoCallMainStage( - room: widget.room, - connectError: widget.connectError, - mainShowsLocal: widget.mainStageShowsLocalVideo, - videoEnabled: widget.videoEnabled, - ), - VideoCallParticipantNameLabel(name: widget.mainStageShowsLocalVideo ? widget.localDisplayName : widget.remoteDisplayName), - ], - ), - ), - ), - ), - ..._bottomBarGradient(), - ..._bottomControlsRow(), - ..._systemInterruptionBanner(), - Positioned( - top: widget.topInset + _edgeInset + _shrinkExpandExtraTop, - left: _controlsHorizontalInset, - child: Padding( - padding: _kExpandShrinkOuterPadding, - child: VideoCallPipStyleIconButton( - onPressed: widget.onShrinkFromFullscreen, - child: SvgPicture.asset(_VideoCallStageAssets.shrink, width: 20, height: 20, fit: BoxFit.contain), - ), - ), - ), - ListenableBuilder( - listenable: widget.room, - builder: (context, _) { - if (!_pipHasVisibleVideo()) { - return const SizedBox.shrink(); - } - return Positioned( - left: pipLeft, - top: pipTop, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: widget.onToggleStageSwap, - onPanStart: (details) { - _pipPanPointerGlobal = details.globalPosition; - _pipPanOriginLeft = pipLeft; - _pipPanOriginTop = pipTop; - }, - onPanUpdate: (details) { - if (_pipPanPointerGlobal == null || _pipPanOriginLeft == null || _pipPanOriginTop == null) { - return; - } - final delta = details.globalPosition - _pipPanPointerGlobal!; - setState(() { - final nx = (_pipPanOriginLeft! + delta.dx).clamp(_edgeInset, maxPipLeft); - final ny = (_pipPanOriginTop! + delta.dy).clamp(minPipTop, maxPipTop); - _pipLeft = (nx as num).toDouble(); - _pipTop = (ny as num).toDouble(); - }); - }, - onPanEnd: (_) { - _resetPipPanState(); - }, - onPanCancel: _resetPipPanState, - child: AnimatedContainer( - duration: const Duration(milliseconds: 220), - curve: Curves.easeOutCubic, - width: _pipW, - height: _pipH, - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: widget.mainStageShowsLocalVideo - ? _VideoCallPipRemoteContent(room: widget.room) - : _VideoCallPipLocalContent( - room: widget.room, - videoEnabled: widget.videoEnabled, - useFrontCamera: widget.useFrontCamera, - onSwitchCamera: widget.onSwitchCamera, - ), + return ListenableBuilder( + listenable: videoCallRoomTracksListenable(widget.room), + builder: (context, _) { + final soloInRoom = VideoCallLiveKitTrackUtils.isSoloInRoom(widget.room); + final mainShowsLocal = + soloInRoom || widget.mainStageShowsLocalVideo; + + return Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: Padding( + padding: EdgeInsets.fromLTRB(_edgeInset, widget.topInset + _edgeInset, _edgeInset, _edgeInset), + child: Material( + type: MaterialType.transparency, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(_mainStageCornerRadius)), + child: Stack( + fit: StackFit.expand, + children: [ + _VideoCallMainStage( + room: widget.room, + connectError: widget.connectError, + mainShowsLocal: mainShowsLocal, + videoEnabled: widget.videoEnabled, + ), + VideoCallParticipantNameLabel( + name: mainShowsLocal + ? widget.localDisplayName + : widget.remoteDisplayName, + ), + ], ), ), ), - ); - }, - ), - ], + ), + ..._bottomBarGradient(), + ..._bottomControlsRow(), + Positioned( + top: widget.topInset + _edgeInset + _shrinkExpandExtraTop, + left: _controlsHorizontalInset, + child: Padding( + padding: _kExpandShrinkOuterPadding, + child: VideoCallPipStyleIconButton( + onPressed: widget.onShrinkFromFullscreen, + child: SvgPicture.asset(_VideoCallStageAssets.shrink, width: 20, height: 20, fit: BoxFit.contain), + ), + ), + ), + if (!soloInRoom) + Positioned( + left: pipLeft, + top: pipTop, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onToggleStageSwap, + onPanStart: (details) { + _pipPanPointerGlobal = details.globalPosition; + _pipPanOriginLeft = pipLeft; + _pipPanOriginTop = pipTop; + }, + onPanUpdate: (details) { + if (_pipPanPointerGlobal == null || _pipPanOriginLeft == null || _pipPanOriginTop == null) { + return; + } + final delta = details.globalPosition - _pipPanPointerGlobal!; + setState(() { + final nx = (_pipPanOriginLeft! + delta.dx).clamp(_edgeInset, maxPipLeft); + final ny = (_pipPanOriginTop! + delta.dy).clamp(minPipTop, maxPipTop); + _pipLeft = (nx as num).toDouble(); + _pipTop = (ny as num).toDouble(); + }); + }, + onPanEnd: (_) { + _resetPipPanState(); + }, + onPanCancel: _resetPipPanState, + child: AnimatedContainer( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + width: _pipW, + height: _pipH, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: mainShowsLocal + ? _VideoCallPipRemoteContent(room: widget.room) + : _VideoCallPipLocalContent( + room: widget.room, + videoEnabled: widget.videoEnabled, + useFrontCamera: widget.useFrontCamera, + onSwitchCamera: widget.onSwitchCamera, + ), + ), + ), + ), + ), + ], + ); + }, ); } @@ -381,7 +398,7 @@ class _SplitParticipantPane extends StatelessWidget { children: [ Positioned.fill( child: ListenableBuilder( - listenable: room, + listenable: videoCallRoomTracksListenable(room), builder: (context, _) { if (isLocal) { if (!videoEnabled) { @@ -390,7 +407,12 @@ class _SplitParticipantPane extends StatelessWidget { final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room); if (local != null) { return IgnorePointer( - child: lk.VideoTrackRenderer(local, fit: lk.VideoViewFit.cover, mirrorMode: lk.VideoViewMirrorMode.auto), + child: lk.VideoTrackRenderer( + local, + key: ValueKey(local.sid), + fit: lk.VideoViewFit.cover, + mirrorMode: lk.VideoViewMirrorMode.auto, + ), ); } return const VideoCallNoVideoBackground(); @@ -440,7 +462,7 @@ class _VideoCallMainStage extends StatelessWidget { @override Widget build(BuildContext context) { return ListenableBuilder( - listenable: room, + listenable: videoCallRoomTracksListenable(room), builder: (context, _) { if (mainShowsLocal) { if (!videoEnabled) { @@ -449,7 +471,12 @@ class _VideoCallMainStage extends StatelessWidget { final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room); if (local != null) { return IgnorePointer( - child: lk.VideoTrackRenderer(local, fit: lk.VideoViewFit.cover, mirrorMode: lk.VideoViewMirrorMode.auto), + child: lk.VideoTrackRenderer( + local, + key: ValueKey(local.sid), + fit: lk.VideoViewFit.cover, + mirrorMode: lk.VideoViewMirrorMode.auto, + ), ); } return const VideoCallNoVideoBackground(); @@ -467,7 +494,13 @@ class _VideoCallPipRemoteContent extends StatelessWidget { @override Widget build(BuildContext context) { - return IgnorePointer(child: VideoCallRemoteVideoStage(room: room, connectError: null)); + return IgnorePointer( + child: VideoCallRemoteVideoStage( + room: room, + connectError: null, + noVideoLogoSize: 56, + ), + ); } } @@ -489,7 +522,12 @@ class _VideoCallPipLocalContent extends StatelessWidget { const VideoCallNoVideoBackground(logoSize: 56) else if (local != null) IgnorePointer( - child: lk.VideoTrackRenderer(local, fit: lk.VideoViewFit.cover, mirrorMode: lk.VideoViewMirrorMode.auto), + child: lk.VideoTrackRenderer( + local, + key: ValueKey(local.sid), + fit: lk.VideoViewFit.cover, + mirrorMode: lk.VideoViewMirrorMode.auto, + ), ) else const VideoCallNoVideoBackground(logoSize: 56), @@ -506,57 +544,3 @@ class _VideoCallPipLocalContent extends StatelessWidget { ); } } - -class _VideoCallSystemInterruptionBanner extends StatelessWidget { - const _VideoCallSystemInterruptionBanner(); - - @override - Widget build(BuildContext context) { - final s = S.of(context); - return Material( - color: appTheme.videoCallNoticeBannerBackground, - borderRadius: BorderRadius.circular(12), - elevation: 6, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SvgPicture.asset( - VideoCallTopOverlay.warningSvg, - width: 22, - height: 22, - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - s.video_call_mic_muted_phone_call, - style: const TextStyle( - color: Colors.white, - fontSize: 14, - height: 1.25, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 4), - Text( - s.video_call_system_interruption, - style: const TextStyle( - color: Colors.white, - fontSize: 13, - height: 1.25, - fontWeight: FontWeight.w400, - ), - ), - ], - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/features/video_calls/presentation/widgets/video_call_system_pip_content.dart b/lib/features/video_calls/presentation/widgets/video_call_system_pip_content.dart index da20656f..fbcc16fd 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_system_pip_content.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_system_pip_content.dart @@ -1,7 +1,9 @@ import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_pip_video_preview.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -22,6 +24,23 @@ class VideoCallSystemPipContent extends StatelessWidget { if (state.connectPhase != VideoCallConnectPhase.connected || state.linkIssue == VideoCallLinkIssue.lost || !state.callStageEntered) { + if (state.conversationUuid.isNotEmpty && + state.connectPhase != VideoCallConnectPhase.idle && + state.linkIssue != VideoCallLinkIssue.lost) { + return ColoredBox( + color: appTheme.gray100, + child: Center( + child: SizedBox( + width: 36, + height: 36, + child: CircularProgressIndicator( + strokeWidth: 3, + color: appTheme.blueA400, + ), + ), + ), + ); + } return const ColoredBox(color: Colors.black); } @@ -34,7 +53,7 @@ class VideoCallSystemPipContent extends StatelessWidget { return Material( color: Colors.black, child: ListenableBuilder( - listenable: room, + listenable: videoCallRoomTracksListenable(room), builder: (context, _) => VideoCallPipVideoPreview( sessionState: state, room: room, diff --git a/lib/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart b/lib/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart index 7760e27f..4731d98f 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart @@ -55,6 +55,12 @@ class _VideoCallSystemPipListenerState extends State } if (!wasEnabled || status != PiPStatus.disabled) return; + + if (cubit.isCallScreenRouteVisible) { + await cubit.onSystemPipExitedWithCallScreenVisible(); + return; + } + if (cubit.state.uiMode != VideoCallUiMode.pip) return; await cubit.restoreFullscreenFromPip(); diff --git a/lib/features/video_calls/services/video_call_audio_session_coordinator.dart b/lib/features/video_calls/services/video_call_audio_session_coordinator.dart index fd976f08..55c2477d 100644 --- a/lib/features/video_calls/services/video_call_audio_session_coordinator.dart +++ b/lib/features/video_calls/services/video_call_audio_session_coordinator.dart @@ -32,15 +32,12 @@ class VideoCallAudioSessionCoordinator { androidWillPauseWhenDucked: true, ); - StreamSubscription? _interruptionSubscription; AudioSession? _session; bool _configured = false; bool get isActive => _session != null; - Future start({ - required void Function(AudioInterruptionEvent event) onInterruption, - }) async { + Future start() async { final session = await _audioSessionFactory(); _session = session; if (!_configured) { @@ -48,10 +45,6 @@ class VideoCallAudioSessionCoordinator { _configured = true; } await session.setActive(true); - await _interruptionSubscription?.cancel(); - _interruptionSubscription = session.interruptionEventStream.listen( - onInterruption, - ); } Future reactivate() async { @@ -65,8 +58,6 @@ class VideoCallAudioSessionCoordinator { } Future stop() async { - await _interruptionSubscription?.cancel(); - _interruptionSubscription = null; final session = _session; _session = null; if (session == null) return; 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 b25e0468..036103ba 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -496,6 +496,7 @@ class VideoCallCallKitCoordinator { counterpartName: counterpartName, counterpartAvatarUrl: counterpartAvatarUrl, localParticipantName: localParticipantName, + entryMode: VideoCallEntryMode.incomingAccepted, ); } diff --git a/lib/features/video_calls/services/video_call_fcm_handler.dart b/lib/features/video_calls/services/video_call_fcm_handler.dart index 6f058c5b..9d582d66 100644 --- a/lib/features/video_calls/services/video_call_fcm_handler.dart +++ b/lib/features/video_calls/services/video_call_fcm_handler.dart @@ -57,6 +57,14 @@ abstract final class VideoCallFcmHandler { return; } + if (source == 'background') { + log( + 'VideoCallFcmHandler: skip background video call, handled by native FCM service, conversation=${payload.conversationUuid}', + name: 'VideoCallFcm', + ); + return; + } + log( 'VideoCallFcmHandler: show incoming call UI, source=$source, conversation=${payload.conversationUuid}', name: 'VideoCallFcm', diff --git a/lib/features/video_calls/services/video_call_incoming_push_presenter.dart b/lib/features/video_calls/services/video_call_incoming_push_presenter.dart index c3325be9..4fb6bf2e 100644 --- a/lib/features/video_calls/services/video_call_incoming_push_presenter.dart +++ b/lib/features/video_calls/services/video_call_incoming_push_presenter.dart @@ -28,6 +28,7 @@ abstract final class VideoCallIncomingPushPresenter { if (payload.localParticipantName != null) 'localParticipantName': payload.localParticipantName, }, + callingNotification: const NotificationParams(showNotification: false), android: const AndroidParams( isCustomNotification: true, isShowLogo: false, diff --git a/lib/features/video_calls/services/video_call_notification_navigator.dart b/lib/features/video_calls/services/video_call_notification_navigator.dart index 5ffbd983..612e032f 100644 --- a/lib/features/video_calls/services/video_call_notification_navigator.dart +++ b/lib/features/video_calls/services/video_call_notification_navigator.dart @@ -3,6 +3,8 @@ import 'dart:async'; import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart'; +import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; +import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart'; import 'package:go_router/go_router.dart'; @@ -30,22 +32,34 @@ class VideoCallNotificationNavigator { String? counterpartName, String? counterpartAvatarUrl, String? localParticipantName, + VideoCallEntryMode entryMode = VideoCallEntryMode.outgoing, }) async { if (conversationUuid.isEmpty) return; - await _pushCallScreen( + final pushed = await _pushCallScreen( conversationUuid: conversationUuid, counterpartName: counterpartName, counterpartAvatarUrl: counterpartAvatarUrl, localParticipantName: localParticipantName, + entryMode: entryMode, ); + + if (!pushed) { + await sl().onCallScreenVisible( + conversationUuid: conversationUuid, + counterpartName: counterpartName, + localParticipantName: localParticipantName, + entryMode: entryMode, + ); + } } - static Future _pushCallScreen({ + static Future _pushCallScreen({ required String conversationUuid, String? counterpartName, String? counterpartAvatarUrl, String? localParticipantName, + VideoCallEntryMode entryMode = VideoCallEntryMode.outgoing, }) async { for (var attempt = 0; attempt < 100; attempt++) { final ctx = AppRouter.navigatorKey.currentContext; @@ -63,12 +77,15 @@ class VideoCallNotificationNavigator { 'counterpartName': counterpartName, 'counterpartAvatarUrl': counterpartAvatarUrl, 'localParticipantName': localParticipantName, + 'entryMode': entryMode.name, }, ); + return true; } - return; + return false; } await Future.delayed(const Duration(milliseconds: 100)); } + return false; } } diff --git a/test/features/video_calls/presentation/video_call_manager_audio_interruption_test.dart b/test/features/video_calls/presentation/video_call_manager_audio_interruption_test.dart deleted file mode 100644 index 73ef657d..00000000 --- a/test/features/video_calls/presentation/video_call_manager_audio_interruption_test.dart +++ /dev/null @@ -1,173 +0,0 @@ -import 'package:audio_session/audio_session.dart'; -import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart'; -import 'package:eatvio_app/features/video_calls/services/video_call_audio_session_coordinator.dart'; -import 'package:eatvio_app/features/video_calls/services/video_call_foreground_coordinator.dart'; -import 'package:eatvio_app/features/video_calls/services/video_call_lifecycle_binding.dart'; -import 'package:eatvio_app/features/video_calls/repositories/video_call_repository.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:livekit_client/livekit_client.dart' as lk; -import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart'; - -import 'video_call_manager_audio_interruption_test.mocks.dart'; - -class _FakeAudioSessionCoordinator extends VideoCallAudioSessionCoordinator { - _FakeAudioSessionCoordinator(this.onStart); - - final void Function(void Function(AudioInterruptionEvent event) onInterruption) onStart; - bool reactivateCalled = false; - - @override - Future start({required void Function(AudioInterruptionEvent event) onInterruption}) async { - onStart(onInterruption); - } - - @override - Future reactivate() async { - reactivateCalled = true; - return true; - } - - @override - Future stop() async {} -} - -@GenerateNiceMocks([MockSpec(), MockSpec(), MockSpec()]) -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - group('VideoCallManager audio interruption', () { - late MockVideoCallRepository mockRepository; - late MockRoom mockRoom; - late MockLocalParticipant mockLocalParticipant; - late _FakeAudioSessionCoordinator audioCoordinator; - late VideoCallManager manager; - - setUp(() { - mockRepository = MockVideoCallRepository(); - mockRoom = MockRoom(); - mockLocalParticipant = MockLocalParticipant(); - audioCoordinator = _FakeAudioSessionCoordinator((_) {}); - manager = VideoCallManager( - videoCallRepository: mockRepository, - foregroundCoordinator: VideoCallForegroundCoordinator(), - lifecycleBinding: VideoCallLifecycleBinding(), - audioSessionCoordinator: audioCoordinator, - ); - when(mockRoom.connectionState).thenReturn(lk.ConnectionState.connected); - when(mockRoom.localParticipant).thenReturn(mockLocalParticipant); - when(mockLocalParticipant.setMicrophoneEnabled(any, audioCaptureOptions: anyNamed('audioCaptureOptions'))).thenAnswer((_) async { - return null; - }); - when(mockLocalParticipant.getTrackPublicationBySource(lk.TrackSource.microphone)).thenReturn(null); - }); - - tearDown(() { - manager.dispose(); - }); - - test('duck interruption mutes mic and sets interrupted UI state', () async { - manager.markActiveCallForTesting(mockRoom); - - manager.handleAudioInterruptionForTesting( - AudioInterruptionEvent(true, AudioInterruptionType.duck), - ); - await Future.delayed(Duration.zero); - - expect(manager.snapshot.systemAudioInterrupted, isTrue); - verify( - mockLocalParticipant.setMicrophoneEnabled( - false, - audioCaptureOptions: anyNamed('audioCaptureOptions'), - ), - ).called(1); - }); - - test('interruption begin mutes mic and sets interrupted state', () async { - manager.markActiveCallForTesting(mockRoom); - - manager.handleAudioInterruptionForTesting(AudioInterruptionEvent(true, AudioInterruptionType.pause)); - await Future.delayed(Duration.zero); - - expect(manager.snapshot.systemAudioInterrupted, isTrue); - expect(manager.snapshot.micEnabled, isTrue); - verify(mockLocalParticipant.setMicrophoneEnabled(false, audioCaptureOptions: anyNamed('audioCaptureOptions'))).called(1); - verifyNever(mockRepository.notifyCallEnded(any)); - }); - - test('interruption end reactivates session and restores mic', () async { - manager.markActiveCallForTesting(mockRoom); - manager.handleAudioInterruptionForTesting(AudioInterruptionEvent(true, AudioInterruptionType.pause)); - await Future.delayed(Duration.zero); - - manager.handleAudioInterruptionForTesting(AudioInterruptionEvent(false, AudioInterruptionType.pause)); - await Future.delayed(Duration.zero); - - expect(manager.snapshot.systemAudioInterrupted, isFalse); - expect(audioCoordinator.reactivateCalled, isTrue); - verify(mockLocalParticipant.setMicrophoneEnabled(true, audioCaptureOptions: anyNamed('audioCaptureOptions'))).called(1); - }); - - test('toggleMic before call stage updates intent without livekit', () async { - manager.markActiveCallForTesting(mockRoom); - manager.setCallStageEnteredForTesting(false); - - await manager.toggleMic(false); - - expect(manager.snapshot.micEnabled, isTrue); - verifyNever( - mockLocalParticipant.setMicrophoneEnabled( - any, - audioCaptureOptions: anyNamed('audioCaptureOptions'), - ), - ); - }); - - test( - 'toggleMic from UI off while interrupted keeps interrupted and does not unmute', - () async { - manager.markActiveCallForTesting(mockRoom); - manager.handleAudioInterruptionForTesting( - AudioInterruptionEvent(true, AudioInterruptionType.pause), - ); - await Future.delayed(Duration.zero); - clearInteractions(mockLocalParticipant); - - await manager.toggleMic(false); - - expect(manager.snapshot.systemAudioInterrupted, isTrue); - expect(manager.snapshot.micEnabled, isTrue); - verifyNever( - mockLocalParticipant.setMicrophoneEnabled( - true, - audioCaptureOptions: anyNamed('audioCaptureOptions'), - ), - ); - }, - ); - - test('toggleMic during interruption disables intent and keeps mic paused', () async { - manager.markActiveCallForTesting(mockRoom); - manager.handleAudioInterruptionForTesting(AudioInterruptionEvent(true, AudioInterruptionType.pause)); - await Future.delayed(Duration.zero); - clearInteractions(mockLocalParticipant); - - await manager.toggleMic(true); - - expect(manager.snapshot.micEnabled, isFalse); - expect(manager.snapshot.systemAudioInterrupted, isTrue); - verify( - mockLocalParticipant.setMicrophoneEnabled( - false, - audioCaptureOptions: anyNamed('audioCaptureOptions'), - ), - ).called(1); - verifyNever( - mockLocalParticipant.setMicrophoneEnabled( - true, - audioCaptureOptions: anyNamed('audioCaptureOptions'), - ), - ); - }); - }); -} diff --git a/test/features/video_calls/presentation/video_call_session_cubit_test.dart b/test/features/video_calls/presentation/video_call_session_cubit_test.dart index d9c67cd9..b18404ff 100644 --- a/test/features/video_calls/presentation/video_call_session_cubit_test.dart +++ b/test/features/video_calls/presentation/video_call_session_cubit_test.dart @@ -3,18 +3,19 @@ import 'package:eatvio_app/features/video_calls/managers/video_call_manager_snap import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:livekit_client/livekit_client.dart' as lk; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'video_call_session_cubit_test.mocks.dart'; -@GenerateNiceMocks([MockSpec()]) +@GenerateNiceMocks([MockSpec(), MockSpec()]) void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('VideoCallSessionCubit', () { late MockVideoCallManager mockManager; - late VideoCallSessionCubit cubit; + late _TestVideoCallSessionCubit cubit; late VideoCallManagerListener managerListener; @@ -26,7 +27,7 @@ void main() { managerListener = invocation.positionalArguments[0] as VideoCallManagerListener; }); - cubit = VideoCallSessionCubit(videoCallManager: mockManager); + cubit = _TestVideoCallSessionCubit(videoCallManager: mockManager); }); tearDown(() async { @@ -120,12 +121,9 @@ void main() { }, ); - test('isMicActiveForUi is false when system audio interrupted', () { - const state = VideoCallSessionState( - micEnabled: true, - systemAudioInterrupted: true, - ); - expect(state.isMicActiveForUi, isFalse); + test('isMicActiveForUi mirrors micEnabled', () { + const state = VideoCallSessionState(micEnabled: true); + expect(state.isMicActiveForUi, isTrue); }); test('onCallEndedExternally resets session state', () async { @@ -143,6 +141,213 @@ void main() { expect(cubit.state, const VideoCallSessionState()); }); + test('syncPipUiStateFromSystem skips overlay when call screen is visible', () async { + cubit.emit( + const VideoCallSessionState( + conversationUuid: 'uuid', + connectPhase: VideoCallConnectPhase.connected, + callStageEntered: true, + uiMode: VideoCallUiMode.fullscreen, + ), + ); + + await cubit.onCallScreenVisible(conversationUuid: 'uuid'); + await cubit.syncPipUiStateFromSystem(); + + expect(cubit.state.uiMode, VideoCallUiMode.fullscreen); + }); + + test('manager idle snapshot ignored while call screen is visible', () async { + when( + mockManager.startCall( + conversationUuid: anyNamed('conversationUuid'), + counterpartName: anyNamed('counterpartName'), + localParticipantName: anyNamed('localParticipantName'), + micEnabled: anyNamed('micEnabled'), + videoEnabled: anyNamed('videoEnabled'), + ), + ).thenAnswer((_) async {}); + + await cubit.onCallScreenVisible( + conversationUuid: 'uuid', + entryMode: VideoCallEntryMode.incomingAccepted, + ); + + managerListener(VideoCallManagerSnapshot.initial); + + expect(cubit.state.entryMode, VideoCallEntryMode.incomingAccepted); + expect(cubit.state.conversationUuid, 'uuid'); + }); + + test('incoming accept auto-enters call stage after connect', () async { + when( + mockManager.startCall( + conversationUuid: anyNamed('conversationUuid'), + counterpartName: anyNamed('counterpartName'), + localParticipantName: anyNamed('localParticipantName'), + micEnabled: anyNamed('micEnabled'), + videoEnabled: anyNamed('videoEnabled'), + ), + ).thenAnswer((_) async { + managerListener( + const VideoCallManagerSnapshot( + conversationUuid: 'uuid', + micEnabled: false, + videoEnabled: false, + connectPhase: VideoCallConnectPhase.connected, + linkIssue: VideoCallLinkIssue.none, + ), + ); + }); + when(mockManager.onEnterCallStage()).thenAnswer((_) async => true); + + await cubit.onCallScreenVisible( + conversationUuid: 'uuid', + entryMode: VideoCallEntryMode.incomingAccepted, + ); + + verify( + mockManager.startCall( + conversationUuid: 'uuid', + counterpartName: anyNamed('counterpartName'), + localParticipantName: anyNamed('localParticipantName'), + micEnabled: false, + videoEnabled: false, + ), + ).called(1); + verify(mockManager.onEnterCallStage()).called(1); + expect(cubit.state.entryMode, VideoCallEntryMode.incomingAccepted); + expect(cubit.state.callStageEntered, isTrue); + }); + + test('outgoing call keeps lobby until callee joins', () async { + when( + mockManager.startCall( + conversationUuid: anyNamed('conversationUuid'), + counterpartName: anyNamed('counterpartName'), + localParticipantName: anyNamed('localParticipantName'), + micEnabled: anyNamed('micEnabled'), + videoEnabled: anyNamed('videoEnabled'), + ), + ).thenAnswer((_) async {}); + when(mockManager.ensureLobbyRoomJoined()).thenAnswer((_) async {}); + when(mockManager.onEnterCallStage()).thenAnswer((_) async => true); + + await cubit.onCallScreenVisible(conversationUuid: 'uuid'); + + verifyNever(mockManager.onEnterCallStage()); + expect(cubit.state.entryMode, VideoCallEntryMode.outgoing); + expect(cubit.state.callStageEntered, isFalse); + expect(cubit.state.micEnabled, isFalse); + + managerListener( + const VideoCallManagerSnapshot( + conversationUuid: 'uuid', + micEnabled: false, + videoEnabled: false, + connectPhase: VideoCallConnectPhase.connected, + linkIssue: VideoCallLinkIssue.none, + ), + ); + await Future.delayed(Duration.zero); + + verify(mockManager.ensureLobbyRoomJoined()).called(1); + verifyNever(mockManager.onEnterCallStage()); + }); + + test('outgoing call auto-enters when callee joins lobby room', () async { + when( + mockManager.startCall( + conversationUuid: anyNamed('conversationUuid'), + counterpartName: anyNamed('counterpartName'), + localParticipantName: anyNamed('localParticipantName'), + micEnabled: anyNamed('micEnabled'), + videoEnabled: anyNamed('videoEnabled'), + ), + ).thenAnswer((_) async {}); + when(mockManager.ensureLobbyRoomJoined()).thenAnswer((_) async {}); + when(mockManager.onEnterCallStage()).thenAnswer((_) async => true); + + await cubit.onCallScreenVisible(conversationUuid: 'uuid'); + + managerListener( + const VideoCallManagerSnapshot( + conversationUuid: 'uuid', + micEnabled: false, + videoEnabled: false, + connectPhase: VideoCallConnectPhase.connected, + linkIssue: VideoCallLinkIssue.none, + counterpartPresentInLobby: true, + ), + ); + await Future.delayed(Duration.zero); + + verify(mockManager.onEnterCallStage()).called(1); + expect(cubit.state.callStageEntered, isTrue); + }); + + test('duplicate onCallScreenVisible does not restart connecting call', () async { + when( + mockManager.startCall( + conversationUuid: anyNamed('conversationUuid'), + counterpartName: anyNamed('counterpartName'), + localParticipantName: anyNamed('localParticipantName'), + micEnabled: anyNamed('micEnabled'), + videoEnabled: anyNamed('videoEnabled'), + ), + ).thenAnswer((_) async {}); + + await cubit.onCallScreenVisible( + conversationUuid: 'uuid', + entryMode: VideoCallEntryMode.incomingAccepted, + ); + await cubit.onCallScreenVisible( + conversationUuid: 'uuid', + entryMode: VideoCallEntryMode.outgoing, + ); + + verify( + mockManager.startCall( + conversationUuid: 'uuid', + counterpartName: anyNamed('counterpartName'), + localParticipantName: anyNamed('localParticipantName'), + micEnabled: false, + videoEnabled: false, + ), + ).called(1); + expect(cubit.state.entryMode, VideoCallEntryMode.incomingAccepted); + }); + + test('leaveCallRoute minimizes to pip and marks call screen not visible', () async { + final mockRoom = MockRoom(); + when(mockManager.room).thenReturn(mockRoom); + when( + mockManager.onCallScreenVisible( + conversationUuid: anyNamed('conversationUuid'), + counterpartName: anyNamed('counterpartName'), + localParticipantName: anyNamed('localParticipantName'), + micEnabled: anyNamed('micEnabled'), + videoEnabled: anyNamed('videoEnabled'), + ), + ).thenAnswer((_) async {}); + + cubit.emit( + const VideoCallSessionState( + conversationUuid: 'uuid', + connectPhase: VideoCallConnectPhase.connected, + callStageEntered: true, + uiMode: VideoCallUiMode.fullscreen, + ), + ); + await cubit.onCallScreenVisible(conversationUuid: 'uuid'); + expect(cubit.isCallScreenRouteVisible, isTrue); + + await cubit.leaveCallRoute(); + + expect(cubit.state.uiMode, VideoCallUiMode.pip); + expect(cubit.isCallScreenRouteVisible, isFalse); + }); + test('tracks whether call screen route is visible', () async { when( mockManager.startCall( @@ -171,3 +376,12 @@ void main() { }); }); } + +class _TestVideoCallSessionCubit extends VideoCallSessionCubit { + _TestVideoCallSessionCubit({required super.videoCallManager}); + + bool grantMicOnRequest = false; + + @override + Future requestMicPermissionForCall() async => grantMicOnRequest; +} diff --git a/test/features/video_calls/widgets/video_call_livekit_track_utils_test.dart b/test/features/video_calls/widgets/video_call_livekit_track_utils_test.dart index 2749415a..d8aff74a 100644 --- a/test/features/video_calls/widgets/video_call_livekit_track_utils_test.dart +++ b/test/features/video_calls/widgets/video_call_livekit_track_utils_test.dart @@ -12,6 +12,8 @@ import 'video_call_livekit_track_utils_test.mocks.dart'; MockSpec(), MockSpec(), MockSpec(), + MockSpec(), + MockSpec(), MockSpec(), MockSpec(), ]) @@ -67,6 +69,33 @@ void main() { expect(VideoCallLiveKitTrackUtils.visibleVideoFromPublication(pub), isNull); }); + test('visibleVideoFromPublication возвращает remote track при paused stream', () { + final pub = MockRemoteTrackPublication(); + final track = MockRemoteVideoTrack(); + when(pub.muted).thenReturn(false); + when(pub.subscribed).thenReturn(true); + when(pub.streamState).thenReturn(lk.StreamState.paused); + when(pub.track).thenReturn(track); + when(track.muted).thenReturn(false); + + expect(VideoCallLiveKitTrackUtils.visibleVideoFromPublication(pub), equals(track)); + }); + + test('visibleVideoFromPublication возвращает null для unsubscribed remote', () { + final pub = MockRemoteTrackPublication(); + when(pub.muted).thenReturn(false); + when(pub.subscribed).thenReturn(false); + + expect(VideoCallLiveKitTrackUtils.visibleVideoFromPublication(pub), isNull); + }); + + test('isSoloInRoom is true when remote participants are empty', () { + final room = MockRoom(); + when(room.remoteParticipants).thenReturn(UnmodifiableMapView({})); + + expect(VideoCallLiveKitTrackUtils.isSoloInRoom(room), isTrue); + }); + test('localCameraVideoTrack возвращает null если localParticipant отсутствует', () { final room = MockRoom(); when(room.localParticipant).thenReturn(null); diff --git a/test/features/video_calls/widgets/video_call_stage_layout_test.dart b/test/features/video_calls/widgets/video_call_stage_layout_test.dart index 078f43c6..ba3afd89 100644 --- a/test/features/video_calls/widgets/video_call_stage_layout_test.dart +++ b/test/features/video_calls/widgets/video_call_stage_layout_test.dart @@ -9,7 +9,7 @@ import 'package:mockito/mockito.dart'; import 'video_call_stage_layout_test.mocks.dart'; -@GenerateNiceMocks([MockSpec()]) +@GenerateNiceMocks([MockSpec(), MockSpec()]) void main() { Widget wrap(Widget child) { return MaterialApp( @@ -27,8 +27,19 @@ void main() { return room; } + MockRoom buildRoomWithRemote() { + final room = MockRoom(); + final remote = MockRemoteParticipant(); + when(room.remoteParticipants).thenReturn( + UnmodifiableMapView({'remote': remote}), + ); + when(room.localParticipant).thenReturn(null); + when(room.connectionState).thenReturn(lk.ConnectionState.connected); + return room; + } + testWidgets('renders split mode participant names and controls', (tester) async { - final room = buildRoom(); + final room = buildRoomWithRemote(); var localExpanded = 0; var remoteExpanded = 0; @@ -114,7 +125,7 @@ void main() { }); testWidgets('split layout hides connectError on remote pane when connected', (tester) async { - final room = buildRoom(); + final room = buildRoomWithRemote(); await tester.pumpWidget( wrap( @@ -150,7 +161,7 @@ void main() { }); testWidgets('fullscreen layout shows remote name when main stage is remote', (tester) async { - final room = buildRoom(); + final room = buildRoomWithRemote(); await tester.pumpWidget( wrap( @@ -186,7 +197,7 @@ void main() { }); testWidgets('split layout with video on shows camera switch on local pane', (tester) async { - final room = buildRoom(); + final room = buildRoomWithRemote(); await tester.pumpWidget( wrap( @@ -222,7 +233,7 @@ void main() { }); testWidgets('split layout with rear camera and remote main stage variant', (tester) async { - final room = buildRoom(); + final room = buildRoomWithRemote(); var stageSwapped = 0; await tester.pumpWidget( @@ -299,8 +310,131 @@ void main() { expect(find.text('Leave'), findsOneWidget); }); - testWidgets('fullscreen layout with connect error shows it on stage', (tester) async { + testWidgets('fullscreen layout shows local PIP when camera is off', (tester) async { + final room = buildRoomWithRemote(); + + await tester.pumpWidget( + wrap( + VideoCallStageLayout( + room: room, + connectError: null, + localDisplayName: 'You', + remoteDisplayName: 'Doctor', + micEnabled: true, + videoEnabled: false, + splitParticipantView: false, + mainStageShowsLocalVideo: false, + useFrontCamera: true, + onExpandLocal: () {}, + onExpandRemote: () {}, + onShrinkFromFullscreen: () {}, + onToggleStageSwap: () {}, + onChat: () {}, + onToggleMic: () {}, + onToggleVideo: () {}, + onHangUp: () {}, + onSwitchCamera: () {}, + controlLabelStyle: const TextStyle(fontSize: 14), + labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'), + topInset: 0, + bottomInset: 0, + ), + ), + ); + + await tester.pump(); + expect(find.byType(AnimatedContainer), findsOneWidget); + }); + + testWidgets('fullscreen layout shows only local participant when solo in room', (tester) async { final room = buildRoom(); + var stageSwapped = 0; + + await tester.pumpWidget( + wrap( + VideoCallStageLayout( + room: room, + connectError: null, + localDisplayName: 'You', + remoteDisplayName: 'Doctor', + micEnabled: true, + videoEnabled: false, + splitParticipantView: false, + mainStageShowsLocalVideo: false, + useFrontCamera: true, + onExpandLocal: () {}, + onExpandRemote: () {}, + onShrinkFromFullscreen: () {}, + onToggleStageSwap: () => stageSwapped++, + onChat: () {}, + onToggleMic: () {}, + onToggleVideo: () {}, + onHangUp: () {}, + onSwitchCamera: () {}, + controlLabelStyle: const TextStyle(fontSize: 14), + labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'), + topInset: 8, + bottomInset: 32, + ), + ), + ); + + await tester.pump(); + expect(find.text('You'), findsOneWidget); + expect(find.text('Doctor'), findsNothing); + expect(find.byType(AnimatedContainer), findsNothing); + expect(stageSwapped, 0); + }); + + testWidgets('split layout places remote above local with 70/30 height ratio', (tester) async { + final room = buildRoomWithRemote(); + + await tester.pumpWidget( + wrap( + VideoCallStageLayout( + room: room, + connectError: null, + localDisplayName: 'You', + remoteDisplayName: 'Doctor', + micEnabled: true, + videoEnabled: false, + splitParticipantView: true, + mainStageShowsLocalVideo: false, + useFrontCamera: true, + onExpandLocal: () {}, + onExpandRemote: () {}, + onShrinkFromFullscreen: () {}, + onToggleStageSwap: () {}, + onChat: () {}, + onToggleMic: () {}, + onToggleVideo: () {}, + onHangUp: () {}, + onSwitchCamera: () {}, + controlLabelStyle: const TextStyle(fontSize: 14), + labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'), + topInset: 0, + bottomInset: 0, + ), + ), + ); + + await tester.pump(); + + final remoteTop = tester.getTopLeft(find.text('Doctor')).dy; + final localTop = tester.getTopLeft(find.text('You')).dy; + expect(remoteTop, lessThan(localTop)); + + final remotePane = tester.getSize( + find.ancestor(of: find.text('Doctor'), matching: find.byType(Material)).first, + ); + final localPane = tester.getSize( + find.ancestor(of: find.text('You'), matching: find.byType(Material)).first, + ); + expect(remotePane.height / localPane.height, closeTo(7 / 3, 0.05)); + }); + + testWidgets('split layout with connect error shows it on stage', (tester) async { + final room = buildRoomWithRemote(); when(room.connectionState).thenReturn(lk.ConnectionState.disconnected); await tester.pumpWidget( -- 2.49.1 From 1e29cda65ccff82834c7277e9b58a366447c5ec3 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 23 Jun 2026 08:43:40 +0300 Subject: [PATCH 41/88] Version changed --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 29eee75c..56444d8a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.27+116 +version: 1.3.28+117 publish_to: 'none' environment: -- 2.49.1 From 8ff826fa3e8de636a62d5723f2065b5583c2d9f2 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 23 Jun 2026 09:08:04 +0300 Subject: [PATCH 42/88] fix: analyzer fix --- .../bloc/video_call_session/video_call_session_cubit.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart index 332d4fcf..d4888673 100644 --- a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart +++ b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart @@ -8,7 +8,6 @@ import 'package:eatvio_app/features/video_calls/services/video_call_system_pip_c import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:livekit_client/livekit_client.dart' as lk; -import 'package:meta/meta.dart'; import 'package:permission_handler/permission_handler.dart'; class VideoCallSessionCubit extends BaseCubit { -- 2.49.1 From c5bf5acfe7cb76bb28e27cc8f6b3f741a6e4fa51 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 24 Jun 2026 08:26:16 +0300 Subject: [PATCH 43/88] feat(video_calls): fixed online indicator and in app notifications for video call --- lib/core/const.dart | 1 + lib/core/routing/other_routes.dart | 3 + lib/core/websockets/ws_manager.dart | 132 ++++++++++++++++++ .../models/chat_room_pagination_model.dart | 2 + lib/features/chat/models/chat_user_model.dart | 2 + .../bloc/chat_room_cubit/chat_room_cubit.dart | 123 ++++++++++++++++ .../chat/presentation/pages/chat_screen.dart | 21 ++- .../widgets/chat_user_online_status_line.dart | 70 ++++++++++ .../chat/utils/chat_counterpart_utils.dart | 15 ++ .../managers/video_call_manager.dart | 17 ++- .../video_call_session_cubit.dart | 20 ++- .../video_call_session_state.dart | 2 +- .../presentation/pages/video_call_screen.dart | 91 ++++++++++++ .../widgets/video_call_app_shell.dart | 11 +- .../widgets/video_call_banner_overlay.dart | 10 ++ ...video_call_connecting_entrance_screen.dart | 58 ++++---- .../video_call_system_pip_listener.dart | 20 ++- .../widgets/video_call_top_overlay.dart | 11 +- pubspec.yaml | 2 +- test/core/const_test.dart | 1 + .../chat/models/chat_user_model_test.dart | 6 +- .../video_call_session_cubit_test.dart | 2 +- 22 files changed, 569 insertions(+), 51 deletions(-) create mode 100644 lib/features/chat/presentation/widgets/chat_user_online_status_line.dart create mode 100644 lib/features/chat/utils/chat_counterpart_utils.dart create mode 100644 lib/features/video_calls/presentation/widgets/video_call_banner_overlay.dart diff --git a/lib/core/const.dart b/lib/core/const.dart index 5aab4fd4..6811b710 100644 --- a/lib/core/const.dart +++ b/lib/core/const.dart @@ -9,4 +9,5 @@ const List planTypeValues = ['fit', 'slim', 'wellness', 'behavior']; const newMessagesChannelName = 'private-new-chat-messages'; const newNotificationsChannelName = 'private-message-events'; const directChatChannelName = 'private-direct-chat'; +const presenceDirectChatChannelName = 'presence-direct-chat'; const thresholdForPagination = 150; diff --git a/lib/core/routing/other_routes.dart b/lib/core/routing/other_routes.dart index cd139b77..d650445d 100644 --- a/lib/core/routing/other_routes.dart +++ b/lib/core/routing/other_routes.dart @@ -236,6 +236,9 @@ class OtherRoutes { conversationUuid: extra['conversationUuid'] as String, counterpartName: extra['counterpartName'] as String?, counterpartAvatarUrl: extra['counterpartAvatarUrl'] as String?, + counterpartUserId: extra['counterpartUserId'] as int?, + counterpartIsOnline: extra['counterpartIsOnline'] as bool?, + counterpartLastSeenText: extra['counterpartLastSeenText'] as String?, localParticipantName: extra['localParticipantName'] as String?, entryMode: _videoCallEntryModeFromRouteExtra(extra['entryMode']), onBackToChat: extra['onBackToChat'] as void Function()?, diff --git a/lib/core/websockets/ws_manager.dart b/lib/core/websockets/ws_manager.dart index cb90f4f8..3631e0ac 100644 --- a/lib/core/websockets/ws_manager.dart +++ b/lib/core/websockets/ws_manager.dart @@ -26,6 +26,13 @@ class WsManager { PrivateChannel? _privateNotificationChannel; PrivateChannel? _privateMessagesChannel; PrivateChannel? _privateDirectChatChannel; + PresenceChannel? _presenceDirectChatChannel; + + StreamSubscription? _presenceMemberAddedSubscription; + StreamSubscription? _presenceMemberRemovedSubscription; + StreamSubscription? _presenceSubscriptionSucceededSubscription; + final List<_PresenceDirectChatListener> _presenceDirectChatListeners = []; + bool _presenceChannelEventsBound = false; StreamSubscription? _lifecycleSubscription; StreamSubscription? _eventSubscription; @@ -175,6 +182,107 @@ class WsManager { ); } + Future initPresenceDirectChatChannel(String chatId) async { + final token = await TokenStorage.getTokenFromStorage(); + _presenceDirectChatChannel = client?.presenceChannel( + '$presenceDirectChatChannelName.$chatId', + authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPresenceChannel( + authorizationEndpoint: Uri.parse('${sl().baseUrl}/broadcasting/auth'), + headers: { + 'Authorization': 'Bearer $token', + }, + ), + ); + } + + void addPresenceDirectChatListener({ + required int counterpartUserId, + required void Function(bool isOnline) onCounterpartOnlineChanged, + }) { + final channel = _presenceDirectChatChannel; + if (channel == null) return; + + _presenceDirectChatListeners.add( + _PresenceDirectChatListener( + counterpartUserId: counterpartUserId, + onCounterpartOnlineChanged: onCounterpartOnlineChanged, + ), + ); + + _bindPresenceChannelEventsIfNeeded(); + channel.subscribe(); + _emitCounterpartPresence(counterpartUserId); + } + + void removePresenceDirectChatListener( + void Function(bool isOnline) onCounterpartOnlineChanged, + ) { + _presenceDirectChatListeners.removeWhere( + (listener) => listener.onCounterpartOnlineChanged == onCounterpartOnlineChanged, + ); + if (_presenceDirectChatListeners.isEmpty) { + unsubscribePresenceDirectChatChannel(); + } + } + + void _bindPresenceChannelEventsIfNeeded() { + if (_presenceChannelEventsBound) return; + final channel = _presenceDirectChatChannel; + if (channel == null) return; + + _presenceChannelEventsBound = true; + + _presenceSubscriptionSucceededSubscription = + channel.whenSubscriptionSucceeded().listen((_) { + final counterpartIds = _presenceDirectChatListeners + .map((listener) => listener.counterpartUserId) + .toSet(); + for (final counterpartUserId in counterpartIds) { + _emitCounterpartPresence(counterpartUserId); + } + }); + _presenceMemberAddedSubscription = channel.whenMemberAdded().listen((event) { + final memberUserId = _parsePresenceMemberUserId(event.data); + if (memberUserId == null) return; + _notifyPresenceListeners(memberUserId, isOnline: true); + }); + _presenceMemberRemovedSubscription = channel.whenMemberRemoved().listen((event) { + final memberUserId = _parsePresenceMemberUserId(event.data); + if (memberUserId == null) return; + _notifyPresenceListeners(memberUserId, isOnline: false); + }); + } + + void _emitCounterpartPresence(int counterpartUserId) { + final channel = _presenceDirectChatChannel; + if (channel == null) return; + final members = channel.state?.members; + if (members == null) return; + final isOnline = members.getMemberInfo(counterpartUserId.toString()) != null; + _notifyPresenceListeners(counterpartUserId, isOnline: isOnline); + } + + void _notifyPresenceListeners(int counterpartUserId, {required bool isOnline}) { + for (final listener in _presenceDirectChatListeners) { + if (listener.counterpartUserId == counterpartUserId) { + listener.onCounterpartOnlineChanged(isOnline); + } + } + } + + int? _parsePresenceMemberUserId(String? data) { + if (data == null || data.isEmpty) return null; + try { + final decoded = jsonDecode(data); + if (decoded is! Map) return null; + final rawId = decoded['user_id'] ?? decoded['id']; + if (rawId == null) return null; + return int.tryParse(rawId.toString()); + } catch (_) { + return null; + } + } + void _subscribe() { _privateMessagesChannel?.subscribe(); _privateNotificationChannel?.subscribe(); @@ -193,6 +301,19 @@ class WsManager { _privateDirectChatChannel?.unsubscribe(); } + void unsubscribePresenceDirectChatChannel() { + _presenceSubscriptionSucceededSubscription?.cancel(); + _presenceMemberAddedSubscription?.cancel(); + _presenceMemberRemovedSubscription?.cancel(); + _presenceSubscriptionSucceededSubscription = null; + _presenceMemberAddedSubscription = null; + _presenceMemberRemovedSubscription = null; + _presenceDirectChatListeners.clear(); + _presenceChannelEventsBound = false; + _presenceDirectChatChannel?.unsubscribe(); + _presenceDirectChatChannel = null; + } + Future clear() async { await client?.disconnect(); await _lifecycleSubscription?.cancel(); @@ -204,6 +325,7 @@ class WsManager { client = null; userId = null; _privateDirectChatChannel?.unsubscribe(); + unsubscribePresenceDirectChatChannel(); _privateMessagesChannel?.unsubscribe(); _privateNotificationChannel?.unsubscribe(); _privateDirectChatChannel = null; @@ -211,3 +333,13 @@ class WsManager { _privateNotificationChannel = null; } } + +class _PresenceDirectChatListener { + const _PresenceDirectChatListener({ + required this.counterpartUserId, + required this.onCounterpartOnlineChanged, + }); + + final int counterpartUserId; + final void Function(bool isOnline) onCounterpartOnlineChanged; +} diff --git a/lib/features/chat/models/chat_room_pagination_model.dart b/lib/features/chat/models/chat_room_pagination_model.dart index 52cb9f7c..fa62adc3 100644 --- a/lib/features/chat/models/chat_room_pagination_model.dart +++ b/lib/features/chat/models/chat_room_pagination_model.dart @@ -54,6 +54,8 @@ abstract class ChatUser with _$ChatUser { @JsonKey(name: "name") required String name, @JsonKey(name: "profile_photo_path") required String? profilePhotoPath, @JsonKey(name: "profile_photo_url") required String profilePhotoUrl, + @Default(false) @JsonKey(name: 'is_online') bool isOnline, + @Default('') @JsonKey(name: 'last_seen_text') String lastSeenText, }) = _ChatUser; factory ChatUser.fromJson(Map json) => _$ChatUserFromJson(json); diff --git a/lib/features/chat/models/chat_user_model.dart b/lib/features/chat/models/chat_user_model.dart index ad876727..184c7889 100644 --- a/lib/features/chat/models/chat_user_model.dart +++ b/lib/features/chat/models/chat_user_model.dart @@ -10,6 +10,8 @@ abstract class ChatUserModel with _$ChatUserModel { required String name, required String slug, @JsonKey(name: 'profile_photo_url') String? profilePhotoUrl, + @Default(false) @JsonKey(name: 'is_online') bool isOnline, + @Default('') @JsonKey(name: 'last_seen_text') String lastSeenText, }) = _ChatUserModel; factory ChatUserModel.fromJson(Map json) => _$ChatUserModelFromJson(json); diff --git a/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart b/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart index dc56a0c1..dac801f7 100644 --- a/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart +++ b/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart @@ -16,6 +16,7 @@ import 'package:eatvio_app/features/chat/models/plan_complain_model.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_state.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart'; import 'package:eatvio_app/features/chat/repositories/chat_repository.dart'; +import 'package:eatvio_app/features/chat/utils/chat_counterpart_utils.dart'; import 'package:eatvio_app/features/my_finance/models/card_model.dart'; import 'package:eatvio_app/features/my_finance/repositories/cards_repository.dart'; import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart'; @@ -34,6 +35,8 @@ class ChatRoomCubit extends Cubit { late int lastPage; bool isLoading = false; Timer? _timer; + String? _activeChatUuid; + void Function(bool isOnline)? _presenceOnlineListener; var uuid = const Uuid(); ChatRoomCubit({required this.chatRepository, required this.wsManager, required this.cardsRepository}) : super(const ChatRoomState.loading()); @@ -170,8 +173,10 @@ class ChatRoomCubit extends Cubit { Future _subscribeToChatChannel({required String uuid, required String chatId}) async { final client = wsManager.client; if (client == null) return; + _activeChatUuid = uuid; await wsManager.initPrivateDirectChatChannel(chatId); wsManager.subscribeDirectChatChannel(); + await _subscribeToPresenceChannel(chatId: chatId); messagesSubscription = wsManager.directChatStream?.listen((event) { AudioPlayer().play((AssetSource('audio/recive_message.wav'))); final message = ChatMessageModel.fromJson(jsonDecode(event.data)); @@ -197,6 +202,118 @@ class ChatRoomCubit extends Cubit { }); } + Future _subscribeToPresenceChannel({required String chatId}) async { + final currentState = state; + if (currentState is! ChatRoomStateLoaded) return; + + final counterpart = resolveChatCounterpart( + users: currentState.chatRoom.users, + currentUserId: wsManager.userId, + ); + + await wsManager.initPresenceDirectChatChannel(chatId); + _presenceOnlineListener = (isOnline) { + unawaited(_handleCounterpartOnlineChanged( + counterpartField: counterpart.id == currentState.chatRoom.users.receiver.id + ? _ChatCounterpartField.receiver + : _ChatCounterpartField.sender, + isOnline: isOnline, + )); + }; + wsManager.addPresenceDirectChatListener( + counterpartUserId: counterpart.id, + onCounterpartOnlineChanged: _presenceOnlineListener!, + ); + } + + Future _handleCounterpartOnlineChanged({ + required _ChatCounterpartField counterpartField, + required bool isOnline, + }) async { + final currentState = state; + if (currentState is! ChatRoomStateLoaded) return; + + final counterpart = counterpartField == _ChatCounterpartField.receiver + ? currentState.chatRoom.users.receiver + : currentState.chatRoom.users.sender; + + if (counterpart.isOnline == isOnline && (isOnline || counterpart.lastSeenText.isNotEmpty)) { + return; + } + + if (!isOnline) { + await _refreshCounterpartOnlineStatus(counterpartField: counterpartField); + return; + } + + _emitCounterpartOnlineStatus( + counterpartField: counterpartField, + isOnline: true, + lastSeenText: '', + ); + } + + Future _refreshCounterpartOnlineStatus({ + required _ChatCounterpartField counterpartField, + }) async { + final chatUuid = _activeChatUuid; + if (chatUuid == null) return; + + try { + final chatRoom = await chatRepository.getChatRoom(chatUuid); + final currentState = state; + if (currentState is! ChatRoomStateLoaded) return; + + final counterpart = resolveChatCounterpart( + users: chatRoom.users, + currentUserId: wsManager.userId, + ); + final field = counterpart.id == currentState.chatRoom.users.receiver.id + ? _ChatCounterpartField.receiver + : _ChatCounterpartField.sender; + + if (field != counterpartField) return; + + _emitCounterpartOnlineStatus( + counterpartField: counterpartField, + isOnline: counterpart.isOnline, + lastSeenText: counterpart.lastSeenText, + ); + } catch (e, st) { + await reportFrom(e, st, this); + } + } + + void _emitCounterpartOnlineStatus({ + required _ChatCounterpartField counterpartField, + required bool isOnline, + required String lastSeenText, + }) { + final currentState = state; + if (currentState is! ChatRoomStateLoaded) return; + + final users = currentState.chatRoom.users; + final updatedUsers = counterpartField == _ChatCounterpartField.receiver + ? users.copyWith( + receiver: users.receiver.copyWith( + isOnline: isOnline, + lastSeenText: lastSeenText, + ), + ) + : users.copyWith( + sender: users.sender.copyWith( + isOnline: isOnline, + lastSeenText: lastSeenText, + ), + ); + + emit( + currentState.copyWith( + chatRoom: currentState.chatRoom.copyWith(users: updatedUsers), + ), + ); + } + Future sendMessage({String? data, List? files, required String userSlug}) async { try { final messageUuid = uuid.v4(); @@ -337,6 +454,10 @@ class ChatRoomCubit extends Cubit { @override Future close() async { + if (_presenceOnlineListener != null) { + wsManager.removePresenceDirectChatListener(_presenceOnlineListener!); + _presenceOnlineListener = null; + } wsManager.unsubscribeDirectChatChannel(); _timer?.cancel(); messagesSubscription?.cancel(); @@ -435,3 +556,5 @@ class ChatRoomCubit extends Cubit { } } } + +enum _ChatCounterpartField { sender, receiver } diff --git a/lib/features/chat/presentation/pages/chat_screen.dart b/lib/features/chat/presentation/pages/chat_screen.dart index 8d02a68a..5746beae 100644 --- a/lib/features/chat/presentation/pages/chat_screen.dart +++ b/lib/features/chat/presentation/pages/chat_screen.dart @@ -15,11 +15,14 @@ import 'package:eatvio_app/features/chat/presentation/widgets/about_order_button import 'package:eatvio_app/features/chat/presentation/widgets/chat_bubble_widget.dart'; import 'package:eatvio_app/features/chat/presentation/widgets/plan_order_message_widget.dart'; import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/websockets/ws_manager.dart'; import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart'; import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_state.dart'; import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/chat/presentation/widgets/chat_user_online_status_line.dart'; import 'package:eatvio_app/features/chat/presentation/widgets/notification_banner.dart'; +import 'package:eatvio_app/features/chat/utils/chat_counterpart_utils.dart'; import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'; import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart'; import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart'; @@ -141,6 +144,10 @@ class _ChatScreenState extends State { } Scaffold _buildChat(BuildContext context, ChatRoomStateLoaded state) { + final chatCounterpart = resolveChatCounterpart( + users: state.chatRoom.users, + currentUserId: sl().userId, + ); final chosenPlan = state.chatRoom.chosenPlan; final amIConsultant = chosenPlan?.consultantId == state.chatRoom.users.sender.id; final showUserDetailsButton = [ @@ -236,7 +243,12 @@ class _ChatScreenState extends State { ), firstCurve: Curves.easeIn, secondCurve: Curves.easeOut, - secondChild: const SizedBox.shrink(), + secondChild: ChatUserOnlineStatusLine( + isOnline: chatCounterpart.isOnline, + lastSeenText: chatCounterpart.lastSeenText, + style: CustomTextStyles.bodySmallMulishPrimary, + showOnlineDot: false, + ), crossFadeState: state.isTyping ? CrossFadeState.showFirst : CrossFadeState.showSecond, duration: const Duration(milliseconds: 150), ), @@ -255,8 +267,11 @@ class _ChatScreenState extends State { VideoCallScreen.name, extra: { 'conversationUuid': widget.uuid, - 'counterpartName': state.chatRoom.users.receiver.name, - 'counterpartAvatarUrl': state.chatRoom.users.receiver.profilePhotoUrl, + 'counterpartName': chatCounterpart.name, + 'counterpartAvatarUrl': chatCounterpart.profilePhotoUrl, + 'counterpartUserId': chatCounterpart.id, + 'counterpartIsOnline': chatCounterpart.isOnline, + 'counterpartLastSeenText': chatCounterpart.lastSeenText, 'localParticipantName': state.chatRoom.users.sender.name, }, ); diff --git a/lib/features/chat/presentation/widgets/chat_user_online_status_line.dart b/lib/features/chat/presentation/widgets/chat_user_online_status_line.dart new file mode 100644 index 00000000..8f851ab2 --- /dev/null +++ b/lib/features/chat/presentation/widgets/chat_user_online_status_line.dart @@ -0,0 +1,70 @@ +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/generated/l10n.dart'; +import 'package:flutter/material.dart'; + +class ChatUserOnlineStatusLine extends StatelessWidget { + const ChatUserOnlineStatusLine({ + super.key, + required this.isOnline, + required this.lastSeenText, + this.style, + this.showOnlineDot = true, + }); + + final bool isOnline; + final String lastSeenText; + final TextStyle? style; + final bool showOnlineDot; + + @override + Widget build(BuildContext context) { + final textStyle = style ?? + CustomTextStyles.bodySmallMulishBlack900.copyWith(height: 14 / 12); + + if (isOnline) { + if (!showOnlineDot) { + return Text( + S.of(context).video_call_status_online, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textStyle, + ); + } + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: appTheme.greenA700, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + S.of(context).video_call_status_online, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textStyle, + ), + ), + ], + ); + } + + final trimmedLastSeen = lastSeenText.trim(); + if (trimmedLastSeen.isEmpty) { + return const SizedBox.shrink(); + } + + return Text( + trimmedLastSeen, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textStyle, + ); + } +} diff --git a/lib/features/chat/utils/chat_counterpart_utils.dart b/lib/features/chat/utils/chat_counterpart_utils.dart new file mode 100644 index 00000000..1fe562c5 --- /dev/null +++ b/lib/features/chat/utils/chat_counterpart_utils.dart @@ -0,0 +1,15 @@ +import 'package:eatvio_app/features/chat/models/chat_room_model.dart'; +import 'package:eatvio_app/features/chat/models/chat_user_model.dart'; + +ChatUserModel resolveChatCounterpart({ + required Users users, + required int? currentUserId, +}) { + if (currentUserId != null && users.sender.id == currentUserId) { + return users.receiver; + } + if (currentUserId != null && users.receiver.id == currentUserId) { + return users.sender; + } + return users.receiver; +} diff --git a/lib/features/video_calls/managers/video_call_manager.dart b/lib/features/video_calls/managers/video_call_manager.dart index 6c967381..64834455 100644 --- a/lib/features/video_calls/managers/video_call_manager.dart +++ b/lib/features/video_calls/managers/video_call_manager.dart @@ -524,14 +524,16 @@ class VideoCallManager { if (_recordingOverlayScheduled) return; final ctx = _navigatorContext; if (ctx == null) return; - _recordingOverlayScheduled = true; - VideoCallTopOverlay.insertBanner( + final shown = VideoCallTopOverlay.insertBanner( ctx, message: S.of(ctx).video_call_recording_notice, backgroundColor: appTheme.videoCallNoticeBannerBackground, svgAsset: VideoCallTopOverlay.warningSvg, autoRemoveAfter: const Duration(seconds: 3), - ); + ).mounted; + if (shown) { + _recordingOverlayScheduled = true; + } } String resolvedLocalDisplayName() { @@ -804,13 +806,18 @@ class VideoCallManager { final bad = q == lk.ConnectionQuality.poor || q == lk.ConnectionQuality.lost; if (bad) { - if (_poorConnectionEntry != null) return; - _poorConnectionEntry = VideoCallTopOverlay.insertBanner( + final existing = _poorConnectionEntry; + if (existing != null && existing.mounted) return; + _poorConnectionEntry = null; + final entry = VideoCallTopOverlay.insertBanner( ctx, message: S.of(ctx).video_call_poor_connection, backgroundColor: appTheme.strongRed, svgAsset: VideoCallTopOverlay.poorConnectionSvg, ); + if (entry.mounted) { + _poorConnectionEntry = entry; + } } else { final e = _poorConnectionEntry; if (e != null && e.mounted) { diff --git a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart index d4888673..e1a37e71 100644 --- a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart +++ b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart @@ -67,11 +67,11 @@ class VideoCallSessionCubit extends BaseCubit { void _onManagerSnapshot(VideoCallManagerSnapshot snapshot) { if (isClosed) return; if (_isCallSessionInactive(snapshot)) { + unawaited(_systemPipCoordinator.disarm()); if (_callScreenRouteVisible) { // [VideoCallManager.startCall] briefly emits idle while replacing the room. return; } - unawaited(_systemPipCoordinator.disarm()); emit(const VideoCallSessionState()); unawaited(_restoreSystemUiAfterCall()); return; @@ -84,6 +84,7 @@ class VideoCallSessionCubit extends BaseCubit { void _syncFromManager(VideoCallManagerSnapshot snapshot) { if (_isCallSessionInactive(snapshot)) { + unawaited(_systemPipCoordinator.disarm()); if (_callScreenRouteVisible) return; emit(const VideoCallSessionState()); return; @@ -210,9 +211,10 @@ class VideoCallSessionCubit extends BaseCubit { } Future _ensureSystemPipArmedIfInCall() async { - if (!state.callStageEntered) return; - if (state.connectPhase != VideoCallConnectPhase.connected) return; - if (state.linkIssue == VideoCallLinkIssue.lost) return; + if (!_videoCallManager.isActiveCall) { + await _systemPipCoordinator.disarm(); + return; + } await _systemPipCoordinator.armAutoPipOnLeave(); } @@ -227,6 +229,7 @@ class VideoCallSessionCubit extends BaseCubit { VideoCallSessionState sessionState, VideoCallManagerSnapshot snapshot, ) { + if (!_callScreenRouteVisible) return; if (sessionState.entryMode != VideoCallEntryMode.outgoing) return; if (sessionState.callStageEntered) return; if (sessionState.connectPhase != VideoCallConnectPhase.connected) return; @@ -253,6 +256,7 @@ class VideoCallSessionCubit extends BaseCubit { Future _runOutgoingAutoEnterCallStage() async { if (isClosed || _autoEnterInFlight) return; + if (!_callScreenRouteVisible) return; if (state.entryMode != VideoCallEntryMode.outgoing) return; if (state.callStageEntered) return; if (state.connectPhase != VideoCallConnectPhase.connected) return; @@ -380,7 +384,7 @@ class VideoCallSessionCubit extends BaseCubit { Future restoreFullscreenFromPip() async { if (state.uiMode != VideoCallUiMode.pip) return; emit(state.copyWith(uiMode: VideoCallUiMode.fullscreen)); - unawaited(_systemPipCoordinator.armAutoPipOnLeave()); + unawaited(_ensureSystemPipArmedIfInCall()); WidgetsBinding.instance.addPostFrameCallback((_) { unawaited(_applyVideoCallSystemUi()); }); @@ -415,10 +419,14 @@ class VideoCallSessionCubit extends BaseCubit { mainStageShowsLocalVideo: false, ), ); - unawaited(_systemPipCoordinator.armAutoPipOnLeave()); + await _ensureSystemPipArmedIfInCall(); WidgetsBinding.instance.addPostFrameCallback((_) { if (isClosed) return; _videoCallManager.scheduleRecordingNoticeOnce(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (isClosed) return; + _videoCallManager.scheduleRecordingNoticeOnce(); + }); }); } diff --git a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart index 588a01ff..cc204991 100644 --- a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart +++ b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart @@ -14,7 +14,7 @@ enum VideoCallLinkIssue { none, reconnecting, lost } /// How the user reached [VideoCallScreen]. enum VideoCallEntryMode { - /// Outgoing call or push tap — lobby with manual enter. + /// Outgoing call — connecting lobby; manual enter or auto-enter when callee joins. outgoing, /// Accepted incoming CallKit / VoIP / notification — loader then auto-enter. diff --git a/lib/features/video_calls/presentation/pages/video_call_screen.dart b/lib/features/video_calls/presentation/pages/video_call_screen.dart index b8aefe95..459b85c2 100644 --- a/lib/features/video_calls/presentation/pages/video_call_screen.dart +++ b/lib/features/video_calls/presentation/pages/video_call_screen.dart @@ -3,6 +3,9 @@ import 'dart:async'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/websockets/ws_manager.dart'; +import 'package:eatvio_app/features/chat/repositories/chat_repository.dart'; +import 'package:eatvio_app/features/chat/utils/chat_counterpart_utils.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_connecting_entrance_screen.dart'; @@ -21,6 +24,9 @@ class VideoCallScreen extends StatefulWidget { required this.conversationUuid, this.counterpartName, this.counterpartAvatarUrl, + this.counterpartUserId, + this.counterpartIsOnline, + this.counterpartLastSeenText, this.localParticipantName, this.entryMode = VideoCallEntryMode.outgoing, this.onBackToChat, @@ -33,6 +39,9 @@ class VideoCallScreen extends StatefulWidget { final String conversationUuid; final String? counterpartName; final String? counterpartAvatarUrl; + final int? counterpartUserId; + final bool? counterpartIsOnline; + final String? counterpartLastSeenText; final String? localParticipantName; final VideoCallEntryMode entryMode; final VoidCallback? onBackToChat; @@ -54,12 +63,20 @@ class _VideoCallScreenState extends State { ); VideoCallSessionCubit get _cubit => sl(); + WsManager get _wsManager => sl(); + + bool _counterpartIsOnline = false; + String _counterpartLastSeenText = ''; + void Function(bool isOnline)? _presenceOnlineListener; @override void initState() { super.initState(); + _counterpartIsOnline = widget.counterpartIsOnline ?? false; + _counterpartLastSeenText = widget.counterpartLastSeenText ?? ''; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; + unawaited(_initCounterpartOnlineStatus()); unawaited( _cubit.onCallScreenVisible( conversationUuid: widget.conversationUuid, @@ -71,8 +88,80 @@ class _VideoCallScreenState extends State { }); } + Future _initCounterpartOnlineStatus() async { + var isOnline = widget.counterpartIsOnline ?? false; + var lastSeenText = widget.counterpartLastSeenText ?? ''; + var counterpartUserId = widget.counterpartUserId; + + final shouldFetchFromApi = + counterpartUserId == null || + widget.counterpartIsOnline == null || + widget.counterpartLastSeenText == null; + + if (shouldFetchFromApi) { + try { + final chatRoom = await sl().getChatRoom( + widget.conversationUuid, + ); + final counterpart = resolveChatCounterpart( + users: chatRoom.users, + currentUserId: _wsManager.userId, + ); + isOnline = counterpart.isOnline; + lastSeenText = counterpart.lastSeenText; + counterpartUserId = counterpart.id; + } catch (_) {} + } + + if (!mounted) return; + setState(() { + _counterpartIsOnline = isOnline; + _counterpartLastSeenText = lastSeenText; + }); + + if (counterpartUserId == null) return; + + _presenceOnlineListener = (isOnline) { + if (!mounted) return; + if (isOnline) { + setState(() { + _counterpartIsOnline = true; + _counterpartLastSeenText = ''; + }); + return; + } + unawaited(_refreshCounterpartOnlineStatus()); + }; + await _wsManager.initPresenceDirectChatChannel(widget.conversationUuid); + _wsManager.addPresenceDirectChatListener( + counterpartUserId: counterpartUserId, + onCounterpartOnlineChanged: _presenceOnlineListener!, + ); + } + + Future _refreshCounterpartOnlineStatus() async { + try { + final chatRoom = await sl().getChatRoom( + widget.conversationUuid, + ); + final counterpart = resolveChatCounterpart( + users: chatRoom.users, + currentUserId: _wsManager.userId, + ); + if (!mounted) return; + setState(() { + _counterpartIsOnline = counterpart.isOnline; + _counterpartLastSeenText = counterpart.lastSeenText; + }); + } catch (_) {} + } + @override void dispose() { + if (_presenceOnlineListener != null) { + _wsManager.removePresenceDirectChatListener(_presenceOnlineListener!); + _presenceOnlineListener = null; + } _cubit.onCallScreenDisposed(); super.dispose(); } @@ -197,6 +286,8 @@ class _VideoCallScreenState extends State { body: VideoCallConnectingEntranceScreen( counterpartDisplayName: remoteName, counterpartAvatarUrl: widget.counterpartAvatarUrl, + counterpartIsOnline: _counterpartIsOnline, + counterpartLastSeenText: _counterpartLastSeenText, micEnabled: sessionState.isMicActiveForUi, videoEnabled: sessionState.videoEnabled, canEnterCall: canEnterCall, diff --git a/lib/features/video_calls/presentation/widgets/video_call_app_shell.dart b/lib/features/video_calls/presentation/widgets/video_call_app_shell.dart index e7fdd6fd..bf9d774a 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_app_shell.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_app_shell.dart @@ -1,8 +1,10 @@ import 'dart:io'; import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_banner_overlay.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_system_pip_content.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart'; @@ -22,6 +24,10 @@ class VideoCallAppShell extends StatelessWidget { clipBehavior: Clip.none, children: [ child ?? const SizedBox.shrink(), + Overlay( + key: VideoCallBannerOverlay.overlayKey, + initialEntries: const [], + ), const VideoCallGlobalPipLayer(), const VideoCallSystemPipListener(), ], @@ -36,10 +42,7 @@ class VideoCallAppShell extends StatelessWidget { previous.connectPhase != current.connectPhase || previous.linkIssue != current.linkIssue, builder: (context, state) { - final allowSystemPipShell = - state.callStageEntered && - state.connectPhase == VideoCallConnectPhase.connected && - state.linkIssue != VideoCallLinkIssue.lost; + final allowSystemPipShell = sl().isActiveCall; if (!allowSystemPipShell) return shell; diff --git a/lib/features/video_calls/presentation/widgets/video_call_banner_overlay.dart b/lib/features/video_calls/presentation/widgets/video_call_banner_overlay.dart new file mode 100644 index 00000000..9bdb2f40 --- /dev/null +++ b/lib/features/video_calls/presentation/widgets/video_call_banner_overlay.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +/// Dedicated overlay layer above the app navigator for in-call banners. +abstract final class VideoCallBannerOverlay { + static final GlobalKey overlayKey = GlobalKey( + debugLabel: 'videoCallBannerOverlay', + ); + + static OverlayState? get overlayState => overlayKey.currentState; +} diff --git a/lib/features/video_calls/presentation/widgets/video_call_connecting_entrance_screen.dart b/lib/features/video_calls/presentation/widgets/video_call_connecting_entrance_screen.dart index ff37c96a..7870eac8 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_connecting_entrance_screen.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_connecting_entrance_screen.dart @@ -1,5 +1,6 @@ import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/chat/presentation/widgets/chat_user_online_status_line.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_controls.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; @@ -10,6 +11,8 @@ class VideoCallConnectingEntranceScreen extends StatelessWidget { super.key, required this.counterpartDisplayName, this.counterpartAvatarUrl, + required this.counterpartIsOnline, + required this.counterpartLastSeenText, required this.micEnabled, required this.videoEnabled, required this.canEnterCall, @@ -24,6 +27,8 @@ class VideoCallConnectingEntranceScreen extends StatelessWidget { final String counterpartDisplayName; final String? counterpartAvatarUrl; + final bool counterpartIsOnline; + final String counterpartLastSeenText; final bool micEnabled; final bool videoEnabled; final bool canEnterCall; @@ -140,33 +145,7 @@ class VideoCallConnectingEntranceScreen extends StatelessWidget { const SizedBox(height: _topContentSpacing), _avatar(context, counterpartDisplayName, counterpartAvatarUrl), const SizedBox(height: 16), - if (canEnterCall) - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: appTheme.greenA700, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 8), - Text( - s.video_call_status_online, - style: CustomTextStyles.bodySmallMulishBlack900 - .copyWith(height: 14 / 12), - ), - ], - ) - else - Text( - s.video_call_screen_connecting_subtitle, - textAlign: TextAlign.center, - style: CustomTextStyles.bodySmallMulishBlack900 - .copyWith(height: 14 / 12), - ), + _buildStatusLine(context, s), SizedBox(height: canEnterCall ? 20 : 16), Text( s.video_call_screen_connecting_title, @@ -345,4 +324,29 @@ class VideoCallConnectingEntranceScreen extends StatelessWidget { ), ); } + + Widget _buildStatusLine(BuildContext context, S s) { + final statusStyle = CustomTextStyles.bodySmallMulishBlack900.copyWith( + height: 14 / 12, + ); + final onlineStatus = ChatUserOnlineStatusLine( + isOnline: counterpartIsOnline, + lastSeenText: counterpartLastSeenText, + style: statusStyle, + ); + + if (counterpartIsOnline || counterpartLastSeenText.trim().isNotEmpty) { + return Center(child: onlineStatus); + } + + if (!canEnterCall) { + return Text( + s.video_call_screen_connecting_subtitle, + textAlign: TextAlign.center, + style: statusStyle, + ); + } + + return const SizedBox.shrink(); + } } diff --git a/lib/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart b/lib/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart index 4731d98f..7a0f8957 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart'; @@ -21,7 +22,8 @@ class VideoCallSystemPipListener extends StatefulWidget { _VideoCallSystemPipListenerState(); } -class _VideoCallSystemPipListenerState extends State { +class _VideoCallSystemPipListenerState extends State + with WidgetsBindingObserver { static const MethodChannel _userLeaveHintChannel = MethodChannel( 'com.eatvioapp/video_call_pip', ); @@ -34,9 +36,24 @@ class _VideoCallSystemPipListenerState extends State super.initState(); if (!Platform.isAndroid) return; + WidgetsBinding.instance.addObserver(this); _userLeaveHintChannel.setMethodCallHandler(_onNativePipMethodCall); final coordinator = sl(); _subscription = coordinator.statusStream.listen(_onPipStatus); + unawaited(_disarmSystemPipIfNoActiveCall()); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + unawaited(_disarmSystemPipIfNoActiveCall()); + } + } + + Future _disarmSystemPipIfNoActiveCall() async { + if (!sl().isActiveCall) { + await sl().disarm(); + } } Future _onNativePipMethodCall(MethodCall call) async { @@ -88,6 +105,7 @@ class _VideoCallSystemPipListenerState extends State @override void dispose() { if (Platform.isAndroid) { + WidgetsBinding.instance.removeObserver(this); _userLeaveHintChannel.setMethodCallHandler(null); } unawaited(_subscription?.cancel()); diff --git a/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart b/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart index 85f2dd0e..8732f24c 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart @@ -1,4 +1,6 @@ +import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_banner_overlay.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; @@ -7,6 +9,13 @@ abstract final class VideoCallTopOverlay { 'assets/icons/video_call/poor_connection.svg'; static const String warningSvg = 'assets/icons/video_call/warning.svg'; + static OverlayState? _resolveOverlayState(BuildContext context) { + return VideoCallBannerOverlay.overlayState ?? + AppRouter.navigatorKey.currentState?.overlay ?? + Overlay.maybeOf(context, rootOverlay: true); + } + + static OverlayEntry insertBanner( BuildContext context, { required String message, @@ -14,7 +23,7 @@ abstract final class VideoCallTopOverlay { required String svgAsset, Duration? autoRemoveAfter, }) { - final overlayState = Overlay.maybeOf(context, rootOverlay: true); + final overlayState = _resolveOverlayState(context); late OverlayEntry entry; entry = OverlayEntry( builder: (ctx) => Positioned( diff --git a/pubspec.yaml b/pubspec.yaml index 56444d8a..e8cecc5e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.28+117 +version: 1.3.29+118 publish_to: 'none' environment: diff --git a/test/core/const_test.dart b/test/core/const_test.dart index 1adf6b1b..415dfb37 100644 --- a/test/core/const_test.dart +++ b/test/core/const_test.dart @@ -33,6 +33,7 @@ void main() { expect(newMessagesChannelName, 'private-new-chat-messages'); expect(newNotificationsChannelName, 'private-message-events'); expect(directChatChannelName, 'private-direct-chat'); + expect(presenceDirectChatChannelName, 'presence-direct-chat'); }); test('thresholdForPagination has correct value', () { diff --git a/test/features/chat/models/chat_user_model_test.dart b/test/features/chat/models/chat_user_model_test.dart index fb825d10..bc913e9b 100644 --- a/test/features/chat/models/chat_user_model_test.dart +++ b/test/features/chat/models/chat_user_model_test.dart @@ -28,12 +28,14 @@ void main() { expect(model.profilePhotoUrl, isNull); }); - test('fromJson создает ChatUserModel из JSON', () { + test('fromJson creates ChatUserModel from JSON', () { final json = { 'id': 1, 'name': 'User Name', 'slug': 'user-slug', 'profile_photo_url': 'https://example.com/photo.jpg', + 'is_online': true, + 'last_seen_text': 'был(а) недавно', }; final model = ChatUserModel.fromJson(json); @@ -42,6 +44,8 @@ void main() { expect(model.name, 'User Name'); expect(model.slug, 'user-slug'); expect(model.profilePhotoUrl, 'https://example.com/photo.jpg'); + expect(model.isOnline, isTrue); + expect(model.lastSeenText, 'был(а) недавно'); }); test('fromJson создает ChatUserModel из JSON без фото', () { diff --git a/test/features/video_calls/presentation/video_call_session_cubit_test.dart b/test/features/video_calls/presentation/video_call_session_cubit_test.dart index b18404ff..d6df22d4 100644 --- a/test/features/video_calls/presentation/video_call_session_cubit_test.dart +++ b/test/features/video_calls/presentation/video_call_session_cubit_test.dart @@ -220,7 +220,7 @@ void main() { expect(cubit.state.callStageEntered, isTrue); }); - test('outgoing call keeps lobby until callee joins', () async { + test('outgoing call stays on connecting lobby until callee joins', () async { when( mockManager.startCall( conversationUuid: anyNamed('conversationUuid'), -- 2.49.1 From 59f42c9b1e2e6ccda07bef26c8d92f1a52f6af04 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 24 Jun 2026 09:35:04 +0300 Subject: [PATCH 44/88] fix: test fixed --- .../presentation/pages/chat_screen_loaded_smoke_test.dart | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/features/chat/presentation/pages/chat_screen_loaded_smoke_test.dart b/test/features/chat/presentation/pages/chat_screen_loaded_smoke_test.dart index c36e6893..e4bbd786 100644 --- a/test/features/chat/presentation/pages/chat_screen_loaded_smoke_test.dart +++ b/test/features/chat/presentation/pages/chat_screen_loaded_smoke_test.dart @@ -2,6 +2,7 @@ import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_initializer.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/websockets/ws_manager.dart'; import 'package:eatvio_app/features/chat/models/chat_message_model.dart'; import 'package:eatvio_app/features/chat/models/chat_room_model.dart'; import 'package:eatvio_app/features/chat/models/chat_room_settings_model.dart'; @@ -37,6 +38,7 @@ void main() { setUp(() async { await sl.reset(); sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); + sl.registerLazySingleton(() => _FakeWsManager()); }); const fakeSvg = ''' @@ -174,6 +176,11 @@ void main() { }); } +class _FakeWsManager extends Fake implements WsManager { + @override + int? userId = 1; +} + class _FakeAssetBundle extends CachingAssetBundle { _FakeAssetBundle(this.assets); final Map assets; -- 2.49.1 From bde4eea7d125901f3810f61fb9ceb4754c3779cd Mon Sep 17 00:00:00 2001 From: misha Date: Fri, 26 Jun 2026 08:56:57 +0300 Subject: [PATCH 45/88] feat(video_calls): fixed recinnecting logic, added consultant bage --- .../com/eatvioapp/app/VideoCallPushBridge.kt | 9 +++ ios/Runner/AppDelegate.swift | 2 +- .../push_navigation_handler.dart | 1 + lib/core/routing/other_routes.dart | 1 + .../managers/video_call_manager.dart | 31 +++++++-- .../video_call_session_cubit.dart | 29 ++++++-- .../video_call_session_state.dart | 7 ++ .../presentation/pages/video_call_screen.dart | 48 +++++++------ .../widgets/video_call_name_chips.dart | 68 ++++++++++++++----- .../widgets/video_call_stage_layout.dart | 11 ++- .../video_call_system_pip_content.dart | 4 +- .../video_call_callkit_coordinator.dart | 41 +++++++++++ .../video_call_incoming_push_presenter.dart | 2 + .../video_call_notification_navigator.dart | 6 ++ .../services/video_call_push_payload.dart | 17 +++++ lib/generated/intl/messages_en.dart | 2 +- lib/generated/intl/messages_ru.dart | 2 +- lib/generated/l10n.dart | 4 +- lib/l10n/intl_en.arb | 2 +- lib/l10n/intl_ru.arb | 2 +- .../video_call_push_payload_test.dart | 12 ++++ .../widgets/video_call_name_label_test.dart | 28 ++++++++ 22 files changed, 272 insertions(+), 57 deletions(-) diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt index b4272684..358f0540 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt @@ -92,6 +92,12 @@ object VideoCallPushBridge { ?: "" val localParticipantName = data["localParticipantName"] ?: data["local_participant_name"] + val counterpartIsConsultant = isTruthy( + data["counterpartIsConsultant"] + ?: data["counterpart_is_consultant"] + ?: data["is_consultant"] + ?: data["isConsultant"] + ) val extra = HashMap() extra["conversationUuid"] = conversationUuid @@ -102,6 +108,9 @@ object VideoCallPushBridge { if (!localParticipantName.isNullOrBlank()) { extra["localParticipantName"] = localParticipantName } + if (counterpartIsConsultant) { + extra["counterpartIsConsultant"] = true + } return Bundle().apply { putString(CallkitConstants.EXTRA_CALLKIT_ID, conversationUuid) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index e1b521eb..15dfb671 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -129,7 +129,7 @@ import flutter_foreground_task } let queryItems = components.queryItems ?? [] - for key in ["uuid", "conversationUuid", "conversation_uuid"] { + for key: String in ["uuid", "conversationUuid", "conversation_uuid"] { if let value = queryItems.first(where: { $0.name == key })?.value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { diff --git a/lib/core/notifications/push_navigation_handler.dart b/lib/core/notifications/push_navigation_handler.dart index ad0286a1..e44c32ab 100644 --- a/lib/core/notifications/push_navigation_handler.dart +++ b/lib/core/notifications/push_navigation_handler.dart @@ -69,6 +69,7 @@ class PushNavigationHandler { counterpartName: videoCallPayload.counterpartName, counterpartAvatarUrl: videoCallPayload.counterpartAvatarUrl, localParticipantName: videoCallPayload.localParticipantName, + counterpartIsConsultant: videoCallPayload.counterpartIsConsultant, entryMode: VideoCallEntryMode.incomingAccepted, ), ); diff --git a/lib/core/routing/other_routes.dart b/lib/core/routing/other_routes.dart index d650445d..db8db78b 100644 --- a/lib/core/routing/other_routes.dart +++ b/lib/core/routing/other_routes.dart @@ -239,6 +239,7 @@ class OtherRoutes { counterpartUserId: extra['counterpartUserId'] as int?, counterpartIsOnline: extra['counterpartIsOnline'] as bool?, counterpartLastSeenText: extra['counterpartLastSeenText'] as String?, + counterpartIsConsultant: extra['counterpartIsConsultant'] as bool?, localParticipantName: extra['localParticipantName'] as String?, entryMode: _videoCallEntryModeFromRouteExtra(extra['entryMode']), onBackToChat: extra['onBackToChat'] as void Function()?, diff --git a/lib/features/video_calls/managers/video_call_manager.dart b/lib/features/video_calls/managers/video_call_manager.dart index 64834455..6bd2294b 100644 --- a/lib/features/video_calls/managers/video_call_manager.dart +++ b/lib/features/video_calls/managers/video_call_manager.dart @@ -72,6 +72,7 @@ class VideoCallManager { String? _preparedToken; bool _videoEnabledBeforeBackground = false; bool _videoPausedForBackground = false; + bool _abortingLiveKitAutoReconnect = false; Future? _myProfileNameFuture; VideoCallManagerSnapshot _snapshot = VideoCallManagerSnapshot.initial; @@ -86,7 +87,7 @@ class VideoCallManager { bool get isActiveCall => _snapshot.connectPhase == VideoCallConnectPhase.connected && _callStageEntered && - _snapshot.linkIssue != VideoCallLinkIssue.lost && + !_snapshot.linkIssue.needsManualReconnect && !_liveKitDisposed && _room != null; @@ -764,17 +765,37 @@ class VideoCallManager { }); } + /// Stops LiveKit's built-in auto-reconnect and surfaces [VideoCallLinkIssue.lost] + /// so the user can retry manually from [VideoCallConnectionLostScreen]. + Future _abortLiveKitAutoReconnect() async { + if (_liveKitDisposed || _abortingLiveKitAutoReconnect) return; + if (_snapshot.connectPhase != VideoCallConnectPhase.connected) return; + if (_snapshot.linkIssue == VideoCallLinkIssue.lost) return; + + _abortingLiveKitAutoReconnect = true; + try { + _emitSnapshot(_snapshot.copyWith(linkIssue: VideoCallLinkIssue.lost)); + final room = _room; + if (room != null && + room.connectionState != lk.ConnectionState.disconnected) { + try { + await room.disconnect(); + } catch (_) {} + } + } finally { + _abortingLiveKitAutoReconnect = false; + } + } + void _attachRoomEventListeners() { final room = _room; if (room == null) return; _roomEventsListener = room.createListener() ..on((_) { - if (_snapshot.connectPhase != VideoCallConnectPhase.connected) return; - _emitSnapshot( - _snapshot.copyWith(linkIssue: VideoCallLinkIssue.reconnecting), - ); + unawaited(_abortLiveKitAutoReconnect()); }) ..on((_) { + if (_snapshot.linkIssue.needsManualReconnect) return; _emitSnapshot(_snapshot.copyWith(linkIssue: VideoCallLinkIssue.none)); }) ..on((_) { diff --git a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart index e1a37e71..2ad362d1 100644 --- a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart +++ b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart @@ -42,7 +42,7 @@ class VideoCallSessionCubit extends BaseCubit { ) { final hideStaleConnectError = snapshot.connectPhase == VideoCallConnectPhase.connected && - snapshot.linkIssue != VideoCallLinkIssue.lost; + !snapshot.linkIssue.needsManualReconnect; final resetCallStage = _isCallSessionInactive(snapshot) || snapshot.connectPhase == VideoCallConnectPhase.connecting; @@ -123,6 +123,7 @@ class VideoCallSessionCubit extends BaseCubit { required String conversationUuid, String? counterpartName, String? localParticipantName, + bool? counterpartIsConsultant, VideoCallEntryMode entryMode = VideoCallEntryMode.outgoing, }) async { _callScreenRouteVisible = true; @@ -153,6 +154,8 @@ class VideoCallSessionCubit extends BaseCubit { counterpartName: counterpartName ?? state.counterpartName, localParticipantName: localParticipantName ?? state.localParticipantName, + counterpartIsConsultant: + counterpartIsConsultant ?? state.counterpartIsConsultant, entryMode: effectiveEntryMode, ), ); @@ -175,12 +178,16 @@ class VideoCallSessionCubit extends BaseCubit { micEnabled: state.micEnabled, videoEnabled: state.videoEnabled, ); + if (counterpartIsConsultant != null) { + emit(state.copyWith(counterpartIsConsultant: counterpartIsConsultant)); + } } else { emit( state.copyWith( conversationUuid: conversationUuid, counterpartName: counterpartName, localParticipantName: localParticipantName, + counterpartIsConsultant: counterpartIsConsultant ?? false, micEnabled: false, videoEnabled: false, callStageEntered: false, @@ -332,7 +339,7 @@ class VideoCallSessionCubit extends BaseCubit { return; } if (state.connectPhase == VideoCallConnectPhase.connected && - state.linkIssue != VideoCallLinkIssue.lost) { + !state.linkIssue.needsManualReconnect) { if (state.callStageEntered) { unawaited(minimizeToPip()); } else { @@ -340,14 +347,14 @@ class VideoCallSessionCubit extends BaseCubit { } } else if (state.connectPhase == VideoCallConnectPhase.connecting) { unawaited(endCall()); - } else if (state.linkIssue == VideoCallLinkIssue.lost) { + } else if (state.linkIssue.needsManualReconnect) { unawaited(endCall()); } } bool get _canMinimizeToPip => state.connectPhase == VideoCallConnectPhase.connected && - state.linkIssue != VideoCallLinkIssue.lost && + !state.linkIssue.needsManualReconnect && state.callStageEntered; Future _applyPipUiState() async { @@ -392,7 +399,7 @@ class VideoCallSessionCubit extends BaseCubit { Future leaveCallRoute() async { if (state.connectPhase == VideoCallConnectPhase.connected && - state.linkIssue != VideoCallLinkIssue.lost) { + !state.linkIssue.needsManualReconnect) { if (state.callStageEntered) { _callScreenRouteVisible = false; await minimizeToPip(); @@ -404,6 +411,11 @@ class VideoCallSessionCubit extends BaseCubit { } } + void updateCounterpartIsConsultant(bool isConsultant) { + if (isClosed || state.counterpartIsConsultant == isConsultant) return; + emit(state.copyWith(counterpartIsConsultant: isConsultant)); + } + Future enterCallStage() async { if (isClosed) return; if (state.connectPhase != VideoCallConnectPhase.connected) return; @@ -432,8 +444,15 @@ class VideoCallSessionCubit extends BaseCubit { Future retryAfterConnectionLoss() async { final entryMode = state.entryMode; + final wasInCallStage = state.callStageEntered; await _videoCallManager.retryAfterConnectionLoss(); + if (isClosed) return; emit(state.copyWith(callStageEntered: false, entryMode: entryMode)); + if (wasInCallStage && + state.connectPhase == VideoCallConnectPhase.connected && + !state.linkIssue.needsManualReconnect) { + await enterCallStage(); + } } Future endCall() async { diff --git a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart index cc204991..28b7fabc 100644 --- a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart +++ b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart @@ -12,6 +12,12 @@ enum VideoCallConnectPhase { idle, connecting, connected } enum VideoCallLinkIssue { none, reconnecting, lost } +extension VideoCallLinkIssueX on VideoCallLinkIssue { + /// True when the user must manually retry (lost link or LiveKit auto-reconnect). + bool get needsManualReconnect => + this == VideoCallLinkIssue.lost || this == VideoCallLinkIssue.reconnecting; +} + /// How the user reached [VideoCallScreen]. enum VideoCallEntryMode { /// Outgoing call — connecting lobby; manual enter or auto-enter when callee joins. @@ -28,6 +34,7 @@ abstract class VideoCallSessionState with _$VideoCallSessionState { const factory VideoCallSessionState({ @Default('') String conversationUuid, String? counterpartName, + @Default(false) bool counterpartIsConsultant, String? localParticipantName, @Default(false) bool micEnabled, @Default(false) bool videoEnabled, diff --git a/lib/features/video_calls/presentation/pages/video_call_screen.dart b/lib/features/video_calls/presentation/pages/video_call_screen.dart index 459b85c2..76b3934d 100644 --- a/lib/features/video_calls/presentation/pages/video_call_screen.dart +++ b/lib/features/video_calls/presentation/pages/video_call_screen.dart @@ -6,6 +6,7 @@ import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/websockets/ws_manager.dart'; import 'package:eatvio_app/features/chat/repositories/chat_repository.dart'; import 'package:eatvio_app/features/chat/utils/chat_counterpart_utils.dart'; +import 'package:eatvio_app/features/profile/repositories/profile_repository.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_connecting_entrance_screen.dart'; @@ -27,6 +28,7 @@ class VideoCallScreen extends StatefulWidget { this.counterpartUserId, this.counterpartIsOnline, this.counterpartLastSeenText, + this.counterpartIsConsultant, this.localParticipantName, this.entryMode = VideoCallEntryMode.outgoing, this.onBackToChat, @@ -42,6 +44,7 @@ class VideoCallScreen extends StatefulWidget { final int? counterpartUserId; final bool? counterpartIsOnline; final String? counterpartLastSeenText; + final bool? counterpartIsConsultant; final String? localParticipantName; final VideoCallEntryMode entryMode; final VoidCallback? onBackToChat; @@ -76,29 +79,32 @@ class _VideoCallScreenState extends State { _counterpartLastSeenText = widget.counterpartLastSeenText ?? ''; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - unawaited(_initCounterpartOnlineStatus()); + unawaited(_initCounterpartMetadata()); unawaited( _cubit.onCallScreenVisible( conversationUuid: widget.conversationUuid, counterpartName: widget.counterpartName, localParticipantName: widget.localParticipantName, + counterpartIsConsultant: widget.counterpartIsConsultant, entryMode: widget.entryMode, ), ); }); } - Future _initCounterpartOnlineStatus() async { + Future _initCounterpartMetadata() async { var isOnline = widget.counterpartIsOnline ?? false; var lastSeenText = widget.counterpartLastSeenText ?? ''; var counterpartUserId = widget.counterpartUserId; + var counterpartIsConsultant = widget.counterpartIsConsultant; - final shouldFetchFromApi = + final shouldFetchChatRoom = counterpartUserId == null || widget.counterpartIsOnline == null || - widget.counterpartLastSeenText == null; + widget.counterpartLastSeenText == null || + counterpartIsConsultant == null; - if (shouldFetchFromApi) { + if (shouldFetchChatRoom) { try { final chatRoom = await sl().getChatRoom( widget.conversationUuid, @@ -110,6 +116,17 @@ class _VideoCallScreenState extends State { isOnline = counterpart.isOnline; lastSeenText = counterpart.lastSeenText; counterpartUserId = counterpart.id; + + if (counterpartIsConsultant == null) { + try { + final profile = await sl().getProfileBySlug( + counterpart.slug, + ); + counterpartIsConsultant = profile.roles.contains('consultant'); + } catch (_) { + counterpartIsConsultant = false; + } + } } catch (_) {} } @@ -118,6 +135,9 @@ class _VideoCallScreenState extends State { _counterpartIsOnline = isOnline; _counterpartLastSeenText = lastSeenText; }); + if (counterpartIsConsultant != null) { + _cubit.updateCounterpartIsConsultant(counterpartIsConsultant); + } if (counterpartUserId == null) return; @@ -207,6 +227,7 @@ class _VideoCallScreenState extends State { a.connectError != b.connectError || a.linkIssue != b.linkIssue || a.counterpartName != b.counterpartName || + a.counterpartIsConsultant != b.counterpartIsConsultant || a.localParticipantName != b.localParticipantName || a.callStageEntered != b.callStageEntered || a.micEnabled != b.micEnabled || @@ -219,7 +240,7 @@ class _VideoCallScreenState extends State { final room = _cubit.liveKitRoom; final showLost = - sessionState.linkIssue == VideoCallLinkIssue.lost || + sessionState.linkIssue.needsManualReconnect || (sessionState.connectPhase == VideoCallConnectPhase.idle && sessionState.connectError != null); @@ -237,20 +258,6 @@ class _VideoCallScreenState extends State { ); } - final showReconnecting = - sessionState.connectPhase == VideoCallConnectPhase.connected && - sessionState.linkIssue == VideoCallLinkIssue.reconnecting; - - if (showReconnecting) { - return AnnotatedRegion( - value: _videoCallOverlayStyle, - child: Scaffold( - backgroundColor: appTheme.whiteA700, - body: const VideoCallConnectingScreen(isReconnecting: true), - ), - ); - } - final showEntranceLobby = sessionState.entryMode == VideoCallEntryMode.outgoing && !sessionState.callStageEntered && @@ -347,6 +354,7 @@ class _VideoCallScreenState extends State { connectError: sessionState.connectError, localDisplayName: _resolveLocalDisplayName(sessionState), remoteDisplayName: _resolveRemoteDisplayName(sessionState), + remoteIsConsultant: sessionState.counterpartIsConsultant, micEnabled: sessionState.micEnabled, videoEnabled: sessionState.videoEnabled, splitParticipantView: sessionState.splitParticipantView, diff --git a/lib/features/video_calls/presentation/widgets/video_call_name_chips.dart b/lib/features/video_calls/presentation/widgets/video_call_name_chips.dart index ff4829eb..389af26e 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_name_chips.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_name_chips.dart @@ -2,40 +2,74 @@ import 'dart:ui'; import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; class VideoCallParticipantNameLabel extends StatelessWidget { - const VideoCallParticipantNameLabel({super.key, required this.name}); + const VideoCallParticipantNameLabel({ + super.key, + required this.name, + this.isConsultant = false, + }); final String name; + final bool isConsultant; @override Widget build(BuildContext context) { + final roleLabel = isConsultant ? S.of(context).video_call_role_consultant : null; + return Positioned( left: 12, bottom: 12, right: 12, child: Align( alignment: Alignment.bottomLeft, - child: ClipRRect( - borderRadius: BorderRadius.circular(60), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 5.5, sigmaY: 5.5), - child: Container( - constraints: const BoxConstraints(maxWidth: 240), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - decoration: BoxDecoration( - color: appTheme.transparentBlack, - borderRadius: BorderRadius.circular(60), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (roleLabel != null) ...[ + Container( + constraints: const BoxConstraints(maxWidth: 240), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + decoration: BoxDecoration( + color: appTheme.whiteA700, + borderRadius: BorderRadius.circular(60), + ), + child: Text( + roleLabel, + style: TextStyles.textXSSemibold.copyWith( + color: appTheme.blueA400, + height: 14 / 12, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), - child: Text( - name, - style: TextStyles.textMMedium.copyWith(color: appTheme.whiteA700), - maxLines: 1, - overflow: TextOverflow.ellipsis, + const SizedBox(height: 16), + ], + ClipRRect( + borderRadius: BorderRadius.circular(60), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 5.5, sigmaY: 5.5), + child: Container( + constraints: const BoxConstraints(maxWidth: 240), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + decoration: BoxDecoration( + color: appTheme.transparentBlack, + borderRadius: BorderRadius.circular(60), + ), + child: Text( + name, + style: TextStyles.textMMedium.copyWith(color: appTheme.whiteA700), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), ), ), - ), + ], ), ), ); diff --git a/lib/features/video_calls/presentation/widgets/video_call_stage_layout.dart b/lib/features/video_calls/presentation/widgets/video_call_stage_layout.dart index feac9376..131c7a55 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_stage_layout.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_stage_layout.dart @@ -25,6 +25,7 @@ class VideoCallStageLayout extends StatefulWidget { required this.connectError, required this.localDisplayName, required this.remoteDisplayName, + this.remoteIsConsultant = false, required this.micEnabled, required this.videoEnabled, required this.splitParticipantView, @@ -49,6 +50,7 @@ class VideoCallStageLayout extends StatefulWidget { final String? connectError; final String localDisplayName; final String remoteDisplayName; + final bool remoteIsConsultant; final bool micEnabled; final bool videoEnabled; final bool splitParticipantView; @@ -149,6 +151,7 @@ class _VideoCallStageLayoutState extends State { child: _SplitParticipantPane( room: widget.room, displayName: widget.remoteDisplayName, + isConsultant: widget.remoteIsConsultant, isLocal: false, connectError: widget.connectError, videoEnabled: widget.videoEnabled, @@ -236,6 +239,7 @@ class _VideoCallStageLayoutState extends State { name: mainShowsLocal ? widget.localDisplayName : widget.remoteDisplayName, + isConsultant: !mainShowsLocal && widget.remoteIsConsultant, ), ], ), @@ -368,6 +372,7 @@ class _SplitParticipantPane extends StatelessWidget { const _SplitParticipantPane({ required this.room, required this.displayName, + this.isConsultant = false, required this.isLocal, required this.connectError, required this.videoEnabled, @@ -379,6 +384,7 @@ class _SplitParticipantPane extends StatelessWidget { final lk.Room room; final String displayName; + final bool isConsultant; final bool isLocal; final String? connectError; final bool videoEnabled; @@ -421,7 +427,10 @@ class _SplitParticipantPane extends StatelessWidget { }, ), ), - VideoCallParticipantNameLabel(name: displayName), + VideoCallParticipantNameLabel( + name: displayName, + isConsultant: !isLocal && isConsultant, + ), Positioned( left: 0, top: _VideoCallStageLayoutState._splitExpandButtonTop, diff --git a/lib/features/video_calls/presentation/widgets/video_call_system_pip_content.dart b/lib/features/video_calls/presentation/widgets/video_call_system_pip_content.dart index fbcc16fd..3abcb415 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_system_pip_content.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_system_pip_content.dart @@ -22,11 +22,11 @@ class VideoCallSystemPipContent extends StatelessWidget { a.videoEnabled != b.videoEnabled, builder: (context, state) { if (state.connectPhase != VideoCallConnectPhase.connected || - state.linkIssue == VideoCallLinkIssue.lost || + state.linkIssue.needsManualReconnect || !state.callStageEntered) { if (state.conversationUuid.isNotEmpty && state.connectPhase != VideoCallConnectPhase.idle && - state.linkIssue != VideoCallLinkIssue.lost) { + !state.linkIssue.needsManualReconnect) { return ColoredBox( color: appTheme.gray100, child: Center( 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 036103ba..3c5b1ad0 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -340,6 +340,12 @@ class VideoCallCallKitCoordinator { 'localParticipantName', 'local_participant_name', ]), + counterpartIsConsultant: _parseBool(extra, const [ + 'counterpartIsConsultant', + 'counterpart_is_consultant', + 'is_consultant', + 'isConsultant', + ]), ); } @@ -391,6 +397,12 @@ class VideoCallCallKitCoordinator { 'localParticipantName', 'local_participant_name', ]), + counterpartIsConsultant: _parseBool(extra, const [ + 'counterpartIsConsultant', + 'counterpart_is_consultant', + 'is_consultant', + 'isConsultant', + ]), ); } @@ -435,6 +447,12 @@ class VideoCallCallKitCoordinator { 'localParticipantName', 'local_participant_name', ]), + counterpartIsConsultant: _parseBool(extra, const [ + 'counterpartIsConsultant', + 'counterpart_is_consultant', + 'is_consultant', + 'isConsultant', + ]), ); } @@ -444,6 +462,7 @@ class VideoCallCallKitCoordinator { String? counterpartName, String? counterpartAvatarUrl, String? localParticipantName, + bool? counterpartIsConsultant, }) async { if (_acceptHandledCallKitIds.contains(callKitId)) return; _acceptHandledCallKitIds.add(callKitId); @@ -463,6 +482,7 @@ class VideoCallCallKitCoordinator { counterpartName: counterpartName, counterpartAvatarUrl: counterpartAvatarUrl, localParticipantName: localParticipantName, + counterpartIsConsultant: counterpartIsConsultant, ).whenComplete(completer.complete), ); }); @@ -486,6 +506,7 @@ class VideoCallCallKitCoordinator { String? counterpartName, String? counterpartAvatarUrl, String? localParticipantName, + bool? counterpartIsConsultant, }) async { if (Platform.isIOS) { FlutterNativeSplash.remove(); @@ -496,6 +517,7 @@ class VideoCallCallKitCoordinator { counterpartName: counterpartName, counterpartAvatarUrl: counterpartAvatarUrl, localParticipantName: localParticipantName, + counterpartIsConsultant: counterpartIsConsultant, entryMode: VideoCallEntryMode.incomingAccepted, ); } @@ -571,6 +593,25 @@ class VideoCallCallKitCoordinator { return null; } + static bool? _parseBool(Map map, List keys) { + for (final key in keys) { + if (!map.containsKey(key)) continue; + final value = map[key]; + if (value is bool) return value; + if (value is num) return value != 0; + if (value is String) { + final normalized = value.trim().toLowerCase(); + if (normalized == '1' || normalized == 'true' || normalized == 'yes') { + return true; + } + if (normalized == '0' || normalized == 'false' || normalized == 'no') { + return false; + } + } + } + return null; + } + static Map _stringKeyedMap(Object? value) { if (value is! Map) return {}; return value.map((key, value) => MapEntry(key.toString(), value)); diff --git a/lib/features/video_calls/services/video_call_incoming_push_presenter.dart b/lib/features/video_calls/services/video_call_incoming_push_presenter.dart index 4fb6bf2e..9136a26d 100644 --- a/lib/features/video_calls/services/video_call_incoming_push_presenter.dart +++ b/lib/features/video_calls/services/video_call_incoming_push_presenter.dart @@ -27,6 +27,8 @@ abstract final class VideoCallIncomingPushPresenter { 'counterpartAvatarUrl': payload.counterpartAvatarUrl, if (payload.localParticipantName != null) 'localParticipantName': payload.localParticipantName, + if (payload.counterpartIsConsultant != null) + 'counterpartIsConsultant': payload.counterpartIsConsultant, }, callingNotification: const NotificationParams(showNotification: false), android: const AndroidParams( diff --git a/lib/features/video_calls/services/video_call_notification_navigator.dart b/lib/features/video_calls/services/video_call_notification_navigator.dart index 612e032f..a9efde95 100644 --- a/lib/features/video_calls/services/video_call_notification_navigator.dart +++ b/lib/features/video_calls/services/video_call_notification_navigator.dart @@ -32,6 +32,7 @@ class VideoCallNotificationNavigator { String? counterpartName, String? counterpartAvatarUrl, String? localParticipantName, + bool? counterpartIsConsultant, VideoCallEntryMode entryMode = VideoCallEntryMode.outgoing, }) async { if (conversationUuid.isEmpty) return; @@ -41,6 +42,7 @@ class VideoCallNotificationNavigator { counterpartName: counterpartName, counterpartAvatarUrl: counterpartAvatarUrl, localParticipantName: localParticipantName, + counterpartIsConsultant: counterpartIsConsultant, entryMode: entryMode, ); @@ -49,6 +51,7 @@ class VideoCallNotificationNavigator { conversationUuid: conversationUuid, counterpartName: counterpartName, localParticipantName: localParticipantName, + counterpartIsConsultant: counterpartIsConsultant, entryMode: entryMode, ); } @@ -59,6 +62,7 @@ class VideoCallNotificationNavigator { String? counterpartName, String? counterpartAvatarUrl, String? localParticipantName, + bool? counterpartIsConsultant, VideoCallEntryMode entryMode = VideoCallEntryMode.outgoing, }) async { for (var attempt = 0; attempt < 100; attempt++) { @@ -77,6 +81,8 @@ class VideoCallNotificationNavigator { 'counterpartName': counterpartName, 'counterpartAvatarUrl': counterpartAvatarUrl, 'localParticipantName': localParticipantName, + if (counterpartIsConsultant != null) + 'counterpartIsConsultant': counterpartIsConsultant, 'entryMode': entryMode.name, }, ); diff --git a/lib/features/video_calls/services/video_call_push_payload.dart b/lib/features/video_calls/services/video_call_push_payload.dart index 3b0decac..ceb0767a 100644 --- a/lib/features/video_calls/services/video_call_push_payload.dart +++ b/lib/features/video_calls/services/video_call_push_payload.dart @@ -4,12 +4,14 @@ class VideoCallPushPayload { this.counterpartName, this.counterpartAvatarUrl, this.localParticipantName, + this.counterpartIsConsultant, }); final String conversationUuid; final String? counterpartName; final String? counterpartAvatarUrl; final String? localParticipantName; + final bool? counterpartIsConsultant; static VideoCallPushPayload? tryParse(Map data) { if (!isIncomingVideoCall(data)) return null; @@ -41,6 +43,12 @@ class VideoCallPushPayload { 'localParticipantName', 'local_participant_name', ]), + counterpartIsConsultant: _bool(data, const [ + 'counterpartIsConsultant', + 'counterpart_is_consultant', + 'is_consultant', + 'isConsultant', + ]), ); } @@ -126,9 +134,18 @@ class VideoCallPushPayload { return null; } + static bool? _bool(Map map, List keys) { + for (final key in keys) { + if (!map.containsKey(key)) continue; + return _isTruthy(map[key]); + } + return null; + } + static bool _isTruthy(Object? value) { if (value == null) return false; if (value is bool) return value; + if (value is num) return value != 0; final normalized = value.toString().trim().toLowerCase(); return normalized == '1' || normalized == 'true' || diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index aca4b29f..37ffb25c 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -877,7 +877,7 @@ class MessageLookup extends MessageLookupByLibrary { "video_call_chat": MessageLookupByLibrary.simpleMessage("Chat"), "video_call_close": MessageLookupByLibrary.simpleMessage("Close"), "video_call_connection_lost_message": MessageLookupByLibrary.simpleMessage( - "It looks like there is an internet issue. We\'re trying to restore the connection...", + "It looks like there is an internet issue. Tap Retry to reconnect.", ), "video_call_connection_lost_title": MessageLookupByLibrary.simpleMessage( "Connection lost", diff --git a/lib/generated/intl/messages_ru.dart b/lib/generated/intl/messages_ru.dart index a0e9f849..0f59a0dc 100644 --- a/lib/generated/intl/messages_ru.dart +++ b/lib/generated/intl/messages_ru.dart @@ -357,7 +357,7 @@ class MessageLookup extends MessageLookupByLibrary { "video_call_chat": MessageLookupByLibrary.simpleMessage("Чат"), "video_call_close": MessageLookupByLibrary.simpleMessage("Закрыть"), "video_call_connection_lost_message": MessageLookupByLibrary.simpleMessage( - "Похоже, возникли проблемы с интернетом. Мы пытаемся восстановить соединение...", + "Похоже, возникли проблемы с интернетом. Нажмите «Повторить», чтобы переподключиться.", ), "video_call_connection_lost_title": MessageLookupByLibrary.simpleMessage( "Связь прервалась", diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index acaa66ec..5fdc1ea4 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -4239,10 +4239,10 @@ class S { ); } - /// `It looks like there is an internet issue. We're trying to restore the connection...` + /// `It looks like there is an internet issue. Tap Retry to reconnect.` String get video_call_connection_lost_message { return Intl.message( - 'It looks like there is an internet issue. We\'re trying to restore the connection...', + 'It looks like there is an internet issue. Tap Retry to reconnect.', name: 'video_call_connection_lost_message', desc: '', args: [], diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 276785c5..8dba037c 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -498,7 +498,7 @@ "video_call_screen_reconnecting_title": "Reconnecting", "video_call_screen_reconnecting_subtitle": "Check your internet and wait", "video_call_connection_lost_title": "Connection lost", - "video_call_connection_lost_message": "It looks like there is an internet issue. We're trying to restore the connection...", + "video_call_connection_lost_message": "It looks like there is an internet issue. Tap Retry to reconnect.", "video_call_try_again": "Retry", "video_call_enter": "Enter", "video_call_close": "Close", diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index f854b851..9b4fefd4 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -243,7 +243,7 @@ "video_call_screen_reconnecting_title": "Восстанавливаем соединение", "video_call_screen_reconnecting_subtitle": "Проверьте интернет и подождите", "video_call_connection_lost_title": "Связь прервалась", - "video_call_connection_lost_message": "Похоже, возникли проблемы с интернетом. Мы пытаемся восстановить соединение...", + "video_call_connection_lost_message": "Похоже, возникли проблемы с интернетом. Нажмите «Повторить», чтобы переподключиться.", "video_call_try_again": "Повторить", "video_call_enter": "Войти", "video_call_close": "Закрыть", diff --git a/test/features/video_calls/services/video_call_push_payload_test.dart b/test/features/video_calls/services/video_call_push_payload_test.dart index 9ddae36e..91b3e8d6 100644 --- a/test/features/video_calls/services/video_call_push_payload_test.dart +++ b/test/features/video_calls/services/video_call_push_payload_test.dart @@ -17,6 +17,18 @@ void main() { expect(payload.counterpartAvatarUrl, 'https://cdn.example/avatar.jpg'); }); + test('parses is_consultant flag from push payload', () { + final payload = VideoCallPushPayload.tryParse({ + 'type': 'video_call_incoming', + 'conversation_uuid': 'abc-123', + 'caller_name': 'Anna', + 'is_consultant': 'true', + }); + + expect(payload, isNotNull); + expect(payload!.counterpartIsConsultant, isTrue); + }); + test('parses action_url with video-call path', () { final payload = VideoCallPushPayload.tryParse({ 'action_url': 'https://eatvio.ru/video-call-screen?uuid=room-42', diff --git a/test/features/video_calls/widgets/video_call_name_label_test.dart b/test/features/video_calls/widgets/video_call_name_label_test.dart index bf759ad6..6332785e 100644 --- a/test/features/video_calls/widgets/video_call_name_label_test.dart +++ b/test/features/video_calls/widgets/video_call_name_label_test.dart @@ -1,5 +1,7 @@ import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_name_chips.dart'; +import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -18,4 +20,30 @@ void main() { expect(find.text('Alex'), findsOneWidget); }); + + testWidgets('VideoCallParticipantNameLabel renders consultant role above name', (tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: const [ + S.delegate, + ...GlobalMaterialLocalizations.delegates, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + home: const Scaffold( + body: Stack( + children: [ + VideoCallParticipantNameLabel( + name: 'Alex', + isConsultant: true, + ), + ], + ), + ), + ), + ); + + expect(find.text('Alex'), findsOneWidget); + expect(find.text('Eatvio consultant'), findsOneWidget); + }); } -- 2.49.1 From 10b36117a6e0414e4ae9f645461ad7131931138b Mon Sep 17 00:00:00 2001 From: misha Date: Fri, 26 Jun 2026 08:57:31 +0300 Subject: [PATCH 46/88] version changed --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index e8cecc5e..ab312c89 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.29+118 +version: 1.3.30+119 publish_to: 'none' environment: -- 2.49.1 From 2100e5ca028cb82431712f0487d6dc77325b4ddb Mon Sep 17 00:00:00 2001 From: misha Date: Fri, 26 Jun 2026 11:30:49 +0300 Subject: [PATCH 47/88] fix: analyzer fix --- .../services/video_call_notification_navigator.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/features/video_calls/services/video_call_notification_navigator.dart b/lib/features/video_calls/services/video_call_notification_navigator.dart index a9efde95..5ed4bc16 100644 --- a/lib/features/video_calls/services/video_call_notification_navigator.dart +++ b/lib/features/video_calls/services/video_call_notification_navigator.dart @@ -81,8 +81,7 @@ class VideoCallNotificationNavigator { 'counterpartName': counterpartName, 'counterpartAvatarUrl': counterpartAvatarUrl, 'localParticipantName': localParticipantName, - if (counterpartIsConsultant != null) - 'counterpartIsConsultant': counterpartIsConsultant, + 'counterpartIsConsultant': ?counterpartIsConsultant, 'entryMode': entryMode.name, }, ); -- 2.49.1 From f913ae60fa403072151b730220cb84dd2f01eb83 Mon Sep 17 00:00:00 2001 From: misha Date: Mon, 29 Jun 2026 12:12:53 +0300 Subject: [PATCH 48/88] feat(vide_calls): added tokens clearing --- lib/core/firebase_api.dart | 57 +++++++++++++++++++ .../menu/presentation/pages/menu_screen.dart | 7 +++ 2 files changed, 64 insertions(+) diff --git a/lib/core/firebase_api.dart b/lib/core/firebase_api.dart index 089ba23f..a371faa3 100644 --- a/lib/core/firebase_api.dart +++ b/lib/core/firebase_api.dart @@ -99,6 +99,43 @@ class FirebaseApi { } } + + Future unregisterPushTokens() async { + if (!await TokenStorage.haveTokenInStorage()) return; + try { + await unregisterPushToken(); + } catch (_) {} + if (Platform.isIOS) { + try { + await _unregisterIosVoipPushToken(); + } catch (_) {} + } + } + + Future unregisterPushToken() async { + if (!await TokenStorage.haveTokenInStorage()) return; + try { + final isHuawei = Platform.isAndroid ? await _isHuaweiDevice() : false; + final platform = Platform.isIOS ? 'ios' : (isHuawei ? 'huawei' : 'android'); + final token = (await getToken()) ?? ''; + await sl().post( + '/api/profile/notification/token/unregister', + data: { + 'platform': platform, + 'token': token, + }, + ).timeout( + const Duration(seconds: 10), + onTimeout: () { + throw TimeoutException('unregisterPushToken timeout'); + }, + ); + } catch (e, st) { + log(e.toString(), stackTrace: st); + await _errorReporter.report(e, st, origin: 'FirebaseApi'); + } + } + Future _registerIosVoipPushToken() async { try { final token = (await FlutterCallkitIncoming.getDevicePushTokenVoIP())?.trim(); @@ -124,6 +161,26 @@ class FirebaseApi { } } + Future _unregisterIosVoipPushToken() async { + try { + final token = (await FlutterCallkitIncoming.getDevicePushTokenVoIP())?.trim() ?? ''; + await sl().post( + '/api/profile/notification/token/voip/unregister', + data: { + 'token': token, + }, + ).timeout( + const Duration(seconds: 10), + onTimeout: () { + throw TimeoutException('unregisterPushToken voip timeout'); + }, + ); + } catch (e, st) { + log(e.toString(), stackTrace: st); + await _errorReporter.report(e, st, origin: 'FirebaseApi'); + } + } + Future _isHuaweiDevice() async { if (!Platform.isAndroid) return false; try { diff --git a/lib/features/menu/presentation/pages/menu_screen.dart b/lib/features/menu/presentation/pages/menu_screen.dart index 41f405fd..67017dd0 100644 --- a/lib/features/menu/presentation/pages/menu_screen.dart +++ b/lib/features/menu/presentation/pages/menu_screen.dart @@ -2,6 +2,7 @@ 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'; @@ -166,6 +167,12 @@ class MenuScreen extends StatelessWidget { } catch (e, s) { await sl().report(e, s, origin: 'MenuScreen'); } finally { + try { + // Needs Authorization header → must run before removing token. + await sl().unregisterPushTokens(); + } catch (e, s) { + await sl().report(e, s, origin: 'MenuScreen'); + } await sl().endCall(); await sl().clear(); await TokenStorage.removeTokenInStorage(); -- 2.49.1 From c0c7e2e65812bdf59f192fe1aa5ff9ca59f81e39 Mon Sep 17 00:00:00 2001 From: misha Date: Mon, 29 Jun 2026 12:15:34 +0300 Subject: [PATCH 49/88] version changed --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index ab312c89..06d61909 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.30+119 +version: 1.3.31+120 publish_to: 'none' environment: -- 2.49.1 From f493e07f9fdef040bfebef8ebc79b8733952f251 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 30 Jun 2026 16:11:18 +0300 Subject: [PATCH 50/88] feat(video_calls): call ended push, recall button for message --- .../app/EatvioFcmMessagingService.kt | 5 + .../com/eatvioapp/app/HmsPushService.kt | 5 + .../com/eatvioapp/app/VideoCallPushBridge.kt | 55 +++++++++ lib/core/device/device_hw_id_provider.dart | 16 +++ .../push_navigation_handler.dart | 12 ++ .../chat/models/chat_message_model.dart | 21 +++- .../chat/presentation/pages/chat_screen.dart | 106 +++++++++++------- .../widgets/chat_bubble_widget.dart | 4 +- .../video_call_chat_message_widget.dart | 103 +++++++++++++++++ .../managers/video_call_manager.dart | 55 +++++---- .../video_calls/models/video_call_models.dart | 11 ++ .../video_call_session_cubit.dart | 51 +++++++++ .../widgets/video_call_top_overlay.dart | 55 +++++++++ .../repositories/video_call_repository.dart | 10 ++ .../video_call_callkit_coordinator.dart | 38 ++++++- .../video_call_ended_push_handler.dart | 19 ++++ .../video_call_ended_push_payload.dart | 11 ++ .../services/video_call_fcm_handler.dart | 32 +++++- .../services/video_call_push_payload.dart | 25 +++++ .../services/video_call_service.dart | 6 + pubspec.yaml | 2 +- .../chat/models/chat_message_model_test.dart | 2 +- .../chat/models/chat_messages_model_test.dart | 2 +- .../presentation/bloc/chats_cubit_test.dart | 4 +- .../models/video_call_models_test.dart | 20 ++++ .../video_call_session_cubit_test.dart | 3 + .../video_call_push_payload_test.dart | 24 ++-- .../services/video_call_service_test.dart | 20 ++++ .../widgets/video_call_top_overlay_test.dart | 33 ++++++ .../video_call_accept_response.json | 4 + 30 files changed, 671 insertions(+), 83 deletions(-) create mode 100644 lib/core/device/device_hw_id_provider.dart create mode 100644 lib/features/chat/presentation/widgets/video_call_chat_message_widget.dart create mode 100644 lib/features/video_calls/services/video_call_ended_push_handler.dart create mode 100644 lib/features/video_calls/services/video_call_ended_push_payload.dart create mode 100644 test/fixtures/video_calls/video_call_accept_response.json diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/EatvioFcmMessagingService.kt b/android/app/src/main/kotlin/com/eatvioapp/app/EatvioFcmMessagingService.kt index 7e9e7377..4a370800 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/EatvioFcmMessagingService.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/EatvioFcmMessagingService.kt @@ -15,6 +15,11 @@ class EatvioFcmMessagingService : FirebaseMessagingService() { override fun onMessageReceived(message: RemoteMessage) { Log.i(TAG, "onMessageReceived: ${message.data}") + if (VideoCallPushBridge.isEndedVideoCall(message.data)) { + VideoCallPushBridge.dismissAllIncomingCalls(this) + return + } + if (!VideoCallPushBridge.isIncomingVideoCall(message.data)) { return } diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt b/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt index b7b986a6..5c1c0d3e 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt @@ -20,6 +20,11 @@ class HmsPushService : HmsMessageService() { if (message == null) return + if (VideoCallPushBridge.isEndedVideoCall(message.dataOfMap)) { + VideoCallPushBridge.dismissAllIncomingCalls(this) + return + } + if (VideoCallPushBridge.isIncomingVideoCall(message.dataOfMap)) { VideoCallPushBridge.showIncomingCall(this, message.dataOfMap) return diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt index 358f0540..a52976e9 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt @@ -4,10 +4,13 @@ import android.content.Context import android.os.Bundle import android.util.Log import com.hiennv.flutter_callkit_incoming.CallkitConstants +import com.hiennv.flutter_callkit_incoming.CallkitIncomingBroadcastReceiver import com.hiennv.flutter_callkit_incoming.CallkitNotificationManager import com.hiennv.flutter_callkit_incoming.CallkitSoundPlayerManager import com.hiennv.flutter_callkit_incoming.Data import com.hiennv.flutter_callkit_incoming.addCall +import com.hiennv.flutter_callkit_incoming.getDataActiveCalls +import com.hiennv.flutter_callkit_incoming.removeAllCalls import java.util.HashMap object VideoCallPushBridge { @@ -52,6 +55,29 @@ object VideoCallPushBridge { return false } + fun isEndedVideoCall(data: Map): Boolean { + val rawType = data["type"] + ?: data["notification_type"] + ?: data["event"] + ?: data["action"] + ?: data["push_type"] + ?: data["call_type"] + + if (rawType != null) { + val normalized = rawType.lowercase().replace("-", "_") + if (normalized == "video_call_ended" || normalized == "video_call_end") { + return true + } + if (normalized.contains("video_call") && + (normalized.contains("ended") || normalized.endsWith("_end")) + ) { + return true + } + } + + return false + } + fun showIncomingCall(context: Context, data: Map) { val appContext = context.applicationContext val callData = buildCallDataBundle(data) @@ -74,6 +100,35 @@ object VideoCallPushBridge { } } + fun dismissAllIncomingCalls(context: Context) { + val appContext = context.applicationContext + val calls = getDataActiveCalls(appContext) + Log.i(TAG, "dismissAllIncomingCalls: active calls=${calls.size}") + + val soundManager = CallkitSoundPlayerManager(appContext) + val notificationManager = CallkitNotificationManager(appContext, soundManager) + + for (call in calls) { + val bundle = call.toBundle() + try { + notificationManager.clearIncomingNotification(bundle, isAccepted = false) + appContext.sendBroadcast( + CallkitIncomingBroadcastReceiver.getIntentEnded(appContext, bundle), + ) + } catch (error: Exception) { + Log.w(TAG, "dismissAllIncomingCalls: failed for call ${call.id}", error) + } + } + + try { + removeAllCalls(appContext) + } catch (error: Exception) { + Log.w(TAG, "dismissAllIncomingCalls: failed to remove persisted calls", error) + } + + soundManager.stop() + } + private fun buildCallDataBundle(data: Map): Bundle? { val conversationUuid = conversationUuid(data) ?: return null val callerName = data["counterpartName"] diff --git a/lib/core/device/device_hw_id_provider.dart b/lib/core/device/device_hw_id_provider.dart new file mode 100644 index 00000000..383aac2b --- /dev/null +++ b/lib/core/device/device_hw_id_provider.dart @@ -0,0 +1,16 @@ +import 'dart:io'; + +import 'package:device_info_plus/device_info_plus.dart'; + +abstract final class DeviceHwIdProvider { + static Future current() async { + final deviceInfo = DeviceInfoPlugin(); + if (Platform.isIOS) { + return (await deviceInfo.iosInfo).identifierForVendor; + } + if (Platform.isAndroid) { + return (await deviceInfo.androidInfo).id; + } + return null; + } +} diff --git a/lib/core/notifications/push_navigation_handler.dart b/lib/core/notifications/push_navigation_handler.dart index e44c32ab..22191b85 100644 --- a/lib/core/notifications/push_navigation_handler.dart +++ b/lib/core/notifications/push_navigation_handler.dart @@ -4,6 +4,8 @@ import 'dart:async'; import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_handler.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_payload.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; @@ -57,6 +59,16 @@ class PushNavigationHandler { static void _handleMessageInternal(RemoteMessage message, {required String source}) { final data = Map.from(message.data); + final endedPayload = VideoCallEndedPushPayload.tryParse(data); + if (endedPayload != null) { + developer.log( + 'PushNavigationHandler: dismiss incoming call UI, source=$source', + name: 'PushNavigationHandler', + ); + unawaited(VideoCallEndedPushHandler.dismissIncomingCallUi()); + return; + } + final videoCallPayload = VideoCallPushPayload.tryParse(data); if (videoCallPayload != null) { developer.log( diff --git a/lib/features/chat/models/chat_message_model.dart b/lib/features/chat/models/chat_message_model.dart index c50a68f8..4bffd8b7 100644 --- a/lib/features/chat/models/chat_message_model.dart +++ b/lib/features/chat/models/chat_message_model.dart @@ -33,7 +33,7 @@ abstract class ChatMessageModel with _$ChatMessageModel { @JsonKey(name: 'read_by_to_user', defaultValue: false) required bool isRead, @JsonKey(name: 'is_author_message_auth_user') required bool? isCurrentUserAuthor, @JsonKey(name: 'room_id') required String? roomId, - @JsonKey(name: 'type') String? type, + @JsonKey(name: 'type', unknownEnumValue: ChatMessageType.unknown) ChatMessageType? type, @Default(MessageState.sent) @JsonKey(includeToJson: false, includeFromJson: false) MessageState messageState, @Default(null) @JsonKey(includeToJson: false, includeFromJson: false) String? uuid, }) = _ChatMessageModel; @@ -42,3 +42,22 @@ abstract class ChatMessageModel with _$ChatMessageModel { } enum MessageState { loading, sent, error } + +enum ChatMessageType { + @JsonValue('text') + text, + @JsonValue('system') + system, + @JsonValue('attachment') + attachment, + @JsonValue('image') + image, + @JsonValue('plan_order') + planOrder, + @JsonValue('video_call_started') + videoCallStarted, + @JsonValue('video_call_ended') + videoCallEnded, + @JsonValue('unknown') + unknown; +} diff --git a/lib/features/chat/presentation/pages/chat_screen.dart b/lib/features/chat/presentation/pages/chat_screen.dart index 5746beae..e712955b 100644 --- a/lib/features/chat/presentation/pages/chat_screen.dart +++ b/lib/features/chat/presentation/pages/chat_screen.dart @@ -13,7 +13,10 @@ import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cub import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart'; import 'package:eatvio_app/features/chat/presentation/widgets/about_order_button.dart'; import 'package:eatvio_app/features/chat/presentation/widgets/chat_bubble_widget.dart'; +import 'package:eatvio_app/features/chat/models/chat_message_model.dart'; +import 'package:eatvio_app/features/chat/models/chat_user_model.dart'; import 'package:eatvio_app/features/chat/presentation/widgets/plan_order_message_widget.dart'; +import 'package:eatvio_app/features/chat/presentation/widgets/video_call_chat_message_widget.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/websockets/ws_manager.dart'; import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart'; @@ -262,20 +265,7 @@ class _ChatScreenState extends State { Padding( padding: const EdgeInsetsDirectional.only(end: 8), child: _ChatHeaderVideoCallButton( - onPressed: () { - GoRouter.of(context).pushNamed( - VideoCallScreen.name, - extra: { - 'conversationUuid': widget.uuid, - 'counterpartName': chatCounterpart.name, - 'counterpartAvatarUrl': chatCounterpart.profilePhotoUrl, - 'counterpartUserId': chatCounterpart.id, - 'counterpartIsOnline': chatCounterpart.isOnline, - 'counterpartLastSeenText': chatCounterpart.lastSeenText, - 'localParticipantName': state.chatRoom.users.sender.name, - }, - ); - }, + onPressed: () => _openVideoCall(context, state, chatCounterpart), ), ), ], @@ -311,7 +301,7 @@ class _ChatScreenState extends State { index + 1 == state.chatMessages.length) { final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId; final message = state.chatMessages[index]; - final isPlanOrderMessage = message.type == 'plan_order'; + final isPlanOrderMessage = message.type == ChatMessageType.planOrder; PacketOptions? messageOption; if (isPlanOrderMessage && chosenPlan != null) { @@ -352,18 +342,13 @@ class _ChatScreenState extends State { }); } }, - child: ChatBubbleWidget( - isRead: message.isRead, - sendTime: message.updatedAt, - text: message.data.trim(), - isCurrentUser: isCurrentUserAuthor, - mediaData: message.mediaData ?? [], - messageState: message.messageState, - messageUuid: message.uuid ?? '', - userSlug: state.chatRoom.users.receiver.slug, - messageType: message.type, - isConsultant: amIConsultant, - urlsMediaInfo: message.urlsMediaInfo, + child: _buildMessageBubble( + context: context, + state: state, + chatCounterpart: chatCounterpart, + isCurrentUserAuthor: isCurrentUserAuthor, + message: message, + amIConsultant: amIConsultant, ), ), ], @@ -371,7 +356,7 @@ class _ChatScreenState extends State { } final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId; final message = state.chatMessages[index]; - final isPlanOrderMessage = message.type == 'plan_order'; + final isPlanOrderMessage = message.type == ChatMessageType.planOrder; PacketOptions? messageOption; if (isPlanOrderMessage && chosenPlan != null) { @@ -408,18 +393,13 @@ class _ChatScreenState extends State { }); } }, - child: ChatBubbleWidget( - isRead: message.isRead, - sendTime: message.updatedAt, - text: message.data.trim(), - isCurrentUser: isCurrentUserAuthor, - mediaData: message.mediaData ?? [], - messageState: message.messageState, - messageUuid: message.uuid ?? '', - userSlug: state.chatRoom.users.receiver.slug, - messageType: message.type, - isConsultant: amIConsultant, - urlsMediaInfo: message.urlsMediaInfo, + child: _buildMessageBubble( + context: context, + state: state, + chatCounterpart: chatCounterpart, + isCurrentUserAuthor: isCurrentUserAuthor, + message: message, + amIConsultant: amIConsultant, ), ), ], @@ -786,6 +766,52 @@ class _ChatScreenState extends State { ), ); } + + void _openVideoCall(BuildContext context, ChatRoomStateLoaded state, ChatUserModel chatCounterpart) { + GoRouter.of(context).pushNamed( + VideoCallScreen.name, + extra: { + 'conversationUuid': widget.uuid, + 'counterpartName': chatCounterpart.name, + 'counterpartAvatarUrl': chatCounterpart.profilePhotoUrl, + 'counterpartUserId': chatCounterpart.id, + 'counterpartIsOnline': chatCounterpart.isOnline, + 'counterpartLastSeenText': chatCounterpart.lastSeenText, + 'localParticipantName': state.chatRoom.users.sender.name, + }, + ); + } + + Widget _buildMessageBubble({ + required BuildContext context, + required ChatRoomStateLoaded state, + required ChatUserModel chatCounterpart, + required bool isCurrentUserAuthor, + required ChatMessageModel message, + required bool? amIConsultant, + }) { + if (message.type == ChatMessageType.videoCallStarted && !isCurrentUserAuthor) { + return VideoCallChatMessageWidget( + text: message.data.trim(), + sendTime: message.updatedAt, + onVideoCallPressed: () => _openVideoCall(context, state, chatCounterpart), + ); + } + + return ChatBubbleWidget( + isRead: message.isRead, + sendTime: message.updatedAt, + text: message.data.trim(), + isCurrentUser: isCurrentUserAuthor, + mediaData: message.mediaData ?? [], + messageState: message.messageState, + messageUuid: message.uuid ?? '', + userSlug: state.chatRoom.users.receiver.slug, + messageType: message.type, + isConsultant: amIConsultant, + urlsMediaInfo: message.urlsMediaInfo, + ); + } } class _ChatHeaderVideoCallButton extends StatelessWidget { diff --git a/lib/features/chat/presentation/widgets/chat_bubble_widget.dart b/lib/features/chat/presentation/widgets/chat_bubble_widget.dart index 00d4e729..8d83e961 100644 --- a/lib/features/chat/presentation/widgets/chat_bubble_widget.dart +++ b/lib/features/chat/presentation/widgets/chat_bubble_widget.dart @@ -39,7 +39,7 @@ class ChatBubbleWidget extends StatelessWidget { final MessageState messageState; final String messageUuid; final String userSlug; - final String? messageType; + final ChatMessageType? messageType; final bool? isConsultant; final List>? urlsMediaInfo; @override @@ -258,7 +258,7 @@ class ChatBubbleWidget extends StatelessWidget { } String _getDisplayText() { - if (messageType == 'plan_order') { + if (messageType == ChatMessageType.planOrder) { if (isConsultant == true) { // Текст для консультанта return '1. Уточните у подопечного его цели и пожелания\n' diff --git a/lib/features/chat/presentation/widgets/video_call_chat_message_widget.dart b/lib/features/chat/presentation/widgets/video_call_chat_message_widget.dart new file mode 100644 index 00000000..90930e1d --- /dev/null +++ b/lib/features/chat/presentation/widgets/video_call_chat_message_widget.dart @@ -0,0 +1,103 @@ +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/utils/date_time_utils.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +class VideoCallChatMessageWidget extends StatelessWidget { + const VideoCallChatMessageWidget({ + super.key, + required this.text, + required this.sendTime, + required this.onVideoCallPressed, + }); + + final String text; + final DateTime sendTime; + final VoidCallback onVideoCallPressed; + + @override + Widget build(BuildContext context) { + final size = MediaQuery.sizeOf(context); + final textStyle = TextStyles.textFS13FW400.copyWith(color: appTheme.black, fontSize: 16); + + return SizedBox( + width: size.width, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 64, 4), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Container( + constraints: BoxConstraints(maxWidth: size.width * 0.7), + decoration: BoxDecoration( + color: appTheme.gray60004, + borderRadius: const BorderRadius.horizontal( + left: Radius.circular(16), + right: Radius.circular(24), + ), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 16, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (text.isNotEmpty) + Flexible( + child: Text(text, style: textStyle), + ), + if (text.isNotEmpty) const SizedBox(width: 8), + _VideoCallBubbleActionButton(onPressed: onVideoCallPressed), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 4), + child: Text( + sendTime.format('HH:mm', 'ru'), + style: const TextStyle( + color: Color(0xFF7B8794), + height: 1, + fontSize: 10, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ); + } +} + +class _VideoCallBubbleActionButton extends StatelessWidget { + const _VideoCallBubbleActionButton({required this.onPressed}); + + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return Material( + color: appTheme.greenA700, + borderRadius: BorderRadius.circular(10), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onPressed, + child: SizedBox( + width: 36, + height: 36, + child: Center( + child: SvgPicture.asset( + 'assets/icons/video_call/vc_control_enter.svg', + width: 20, + height: 20, + fit: BoxFit.contain, + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/video_calls/managers/video_call_manager.dart b/lib/features/video_calls/managers/video_call_manager.dart index 6bd2294b..2501a772 100644 --- a/lib/features/video_calls/managers/video_call_manager.dart +++ b/lib/features/video_calls/managers/video_call_manager.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:eatvio_app/core/app_router.dart'; +import 'package:eatvio_app/core/device/device_hw_id_provider.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/widgets/show_error_dialog.dart'; import 'package:eatvio_app/features/video_calls/managers/video_call_manager_snapshot.dart'; @@ -56,7 +57,6 @@ class VideoCallManager { lk.Room? _room; lk.EventsListener? _roomEventsListener; - OverlayEntry? _poorConnectionEntry; Timer? _mutedMicPollTimer; DateTime? _lastMutedMicOverlayAt; bool _recordingOverlayScheduled = false; @@ -64,6 +64,7 @@ class VideoCallManager { num? _lastMutedMicTotalEnergy; bool _liveKitDisposed = true; bool _callEndedNotified = true; + final Set _callAcceptNotifiedConversationUuids = {}; bool _callStageEntered = false; bool _joinInProgress = false; bool _counterpartJoinedNotified = false; @@ -409,6 +410,27 @@ class VideoCallManager { _emitSnapshot(VideoCallManagerSnapshot.initial); } + /// Notifies backend that this device accepted an incoming call so ringing + /// stops on the user's other devices. + Future notifyIncomingCallAccepted(String conversationUuid) async { + final uuid = conversationUuid.trim(); + if (uuid.isEmpty) return; + if (_callAcceptNotifiedConversationUuids.contains(uuid)) return; + + final hwId = (await DeviceHwIdProvider.current())?.trim(); + if (hwId == null || hwId.length < 8) return; + + _callAcceptNotifiedConversationUuids.add(uuid); + try { + await _videoCallRepository.notifyCallAccepted( + conversationUuid: uuid, + hwId: hwId, + ); + } catch (_) { + _callAcceptNotifiedConversationUuids.remove(uuid); + } + } + /// Decline from incoming UI before joining the LiveKit room. Future declineIncomingCall(String conversationUuid) async { final uuid = conversationUuid.trim(); @@ -716,6 +738,7 @@ class VideoCallManager { _counterpartJoinedNotified = false; _roomConnectFuture = null; _clearPreparedConnectionCredentials(); + _callAcceptNotifiedConversationUuids.clear(); _emitSnapshot( _snapshot.copyWith( counterpartPresentInLobby: false, @@ -736,11 +759,7 @@ class VideoCallManager { if (listener != null) { unawaited(listener.dispose()); } - final poor = _poorConnectionEntry; - _poorConnectionEntry = null; - if (poor != null && poor.mounted) { - poor.remove(); - } + VideoCallTopOverlay.hidePoorConnectionIcon(); } void _startMutedMicPollTimer() { @@ -775,6 +794,7 @@ class VideoCallManager { _abortingLiveKitAutoReconnect = true; try { _emitSnapshot(_snapshot.copyWith(linkIssue: VideoCallLinkIssue.lost)); + VideoCallTopOverlay.hidePoorConnectionIcon(); final room = _room; if (room != null && room.connectionState != lk.ConnectionState.disconnected) { @@ -822,29 +842,18 @@ class VideoCallManager { } void _syncPoorConnectionOverlay(lk.ConnectionQuality q) { + if (_liveKitDisposed || !_callStageEntered) { + VideoCallTopOverlay.hidePoorConnectionIcon(); + return; + } final ctx = _navigatorContext; if (ctx == null) return; final bad = q == lk.ConnectionQuality.poor || q == lk.ConnectionQuality.lost; if (bad) { - final existing = _poorConnectionEntry; - if (existing != null && existing.mounted) return; - _poorConnectionEntry = null; - final entry = VideoCallTopOverlay.insertBanner( - ctx, - message: S.of(ctx).video_call_poor_connection, - backgroundColor: appTheme.strongRed, - svgAsset: VideoCallTopOverlay.poorConnectionSvg, - ); - if (entry.mounted) { - _poorConnectionEntry = entry; - } + VideoCallTopOverlay.showPoorConnectionIcon(ctx); } else { - final e = _poorConnectionEntry; - if (e != null && e.mounted) { - e.remove(); - } - _poorConnectionEntry = null; + VideoCallTopOverlay.hidePoorConnectionIcon(); } } diff --git a/lib/features/video_calls/models/video_call_models.dart b/lib/features/video_calls/models/video_call_models.dart index 7ab0a747..f344822d 100644 --- a/lib/features/video_calls/models/video_call_models.dart +++ b/lib/features/video_calls/models/video_call_models.dart @@ -35,3 +35,14 @@ abstract class VideoCallEndedResponse with _$VideoCallEndedResponse { factory VideoCallEndedResponse.fromJson(Map json) => _$VideoCallEndedResponseFromJson(json); } + +@freezed +abstract class VideoCallAcceptResponse with _$VideoCallAcceptResponse { + const factory VideoCallAcceptResponse({ + required bool sent, + @JsonKey(name: 'devices_notified') required int devicesNotified, + }) = _VideoCallAcceptResponse; + + factory VideoCallAcceptResponse.fromJson(Map json) => + _$VideoCallAcceptResponseFromJson(json); +} diff --git a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart index 2ad362d1..220ec4fd 100644 --- a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart +++ b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart @@ -26,6 +26,10 @@ class VideoCallSessionCubit extends BaseCubit { final VideoCallSystemPipCoordinator _systemPipCoordinator; bool _callScreenRouteVisible = false; bool _autoEnterInFlight = false; + bool _counterpartPresentWhenLobbyReady = false; + bool _sawEmptyLobbyWhenConnected = false; + bool _lobbyReadyTracked = false; + String? _lobbyReadyTrackedConversationUuid; Future? _autoEnterFuture; Future? _outgoingAutoEnterFuture; @@ -72,10 +76,12 @@ class VideoCallSessionCubit extends BaseCubit { // [VideoCallManager.startCall] briefly emits idle while replacing the room. return; } + _resetLobbyReadyTracking(); emit(const VideoCallSessionState()); unawaited(_restoreSystemUiAfterCall()); return; } + _trackLobbyReadyCounterpartPresence(snapshot); final next = _sessionStateFromSnapshot(snapshot); emit(next); _scheduleAutoEnterIfNeeded(next); @@ -86,12 +92,47 @@ class VideoCallSessionCubit extends BaseCubit { if (_isCallSessionInactive(snapshot)) { unawaited(_systemPipCoordinator.disarm()); if (_callScreenRouteVisible) return; + _resetLobbyReadyTracking(); emit(const VideoCallSessionState()); return; } + _trackLobbyReadyCounterpartPresence(snapshot); emit(_sessionStateFromSnapshot(snapshot)); } + void _resetLobbyReadyTracking() { + _lobbyReadyTrackedConversationUuid = null; + _counterpartPresentWhenLobbyReady = false; + _sawEmptyLobbyWhenConnected = false; + _lobbyReadyTracked = false; + } + + void _trackLobbyReadyCounterpartPresence(VideoCallManagerSnapshot snapshot) { + final uuid = snapshot.conversationUuid; + if (uuid.isEmpty) { + _resetLobbyReadyTracking(); + return; + } + if (snapshot.connectPhase != VideoCallConnectPhase.connected) return; + + if (!_lobbyReadyTracked || _lobbyReadyTrackedConversationUuid != uuid) { + _lobbyReadyTracked = true; + _lobbyReadyTrackedConversationUuid = uuid; + _counterpartPresentWhenLobbyReady = snapshot.counterpartPresentInLobby; + _sawEmptyLobbyWhenConnected = !snapshot.counterpartPresentInLobby; + return; + } + + if (_sawEmptyLobbyWhenConnected && snapshot.counterpartPresentInLobby) { + _counterpartPresentWhenLobbyReady = false; + _sawEmptyLobbyWhenConnected = false; + } + } + + Future _notifyIncomingCallAcceptedIfNeeded(String conversationUuid) { + return _videoCallManager.notifyIncomingCallAccepted(conversationUuid); + } + /// Синхронизирует UI-состояние после [VideoCallManager.endCall] из notification / /// foreground task (без повторного disconnect). Future onCallEndedExternally() async { @@ -144,7 +185,12 @@ class VideoCallSessionCubit extends BaseCubit { !state.callStageEntered && state.connectPhase != VideoCallConnectPhase.idle; + if (isIncomingAccepted && !isResumingActiveCall) { + unawaited(_notifyIncomingCallAcceptedIfNeeded(conversationUuid)); + } + if (!isResumingActiveCall && !isSameCallConnecting) { + _resetLobbyReadyTracking(); await _systemPipCoordinator.disarm(); } @@ -421,6 +467,11 @@ class VideoCallSessionCubit extends BaseCubit { if (state.connectPhase != VideoCallConnectPhase.connected) return; if (state.callStageEntered) return; + if (state.entryMode == VideoCallEntryMode.outgoing && + _counterpartPresentWhenLobbyReady) { + unawaited(_notifyIncomingCallAcceptedIfNeeded(state.conversationUuid)); + } + final entered = await _videoCallManager.onEnterCallStage(); if (!entered || isClosed) return; diff --git a/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart b/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart index 8732f24c..8ea2ddb4 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart @@ -9,12 +9,67 @@ abstract final class VideoCallTopOverlay { 'assets/icons/video_call/poor_connection.svg'; static const String warningSvg = 'assets/icons/video_call/warning.svg'; + static const double _poorConnectionIconSize = 44; + static const double _poorConnectionGlyphSize = 22; + + static OverlayEntry? _poorConnectionEntry; + + static bool get isPoorConnectionIconVisible => + _poorConnectionEntry?.mounted ?? false; + static OverlayState? _resolveOverlayState(BuildContext context) { return VideoCallBannerOverlay.overlayState ?? AppRouter.navigatorKey.currentState?.overlay ?? Overlay.maybeOf(context, rootOverlay: true); } + /// Compact poor-connection indicator: red square with icon only. + static void showPoorConnectionIcon(BuildContext context) { + if (isPoorConnectionIconVisible) return; + + hidePoorConnectionIcon(); + + final overlayState = _resolveOverlayState(context); + late OverlayEntry entry; + entry = OverlayEntry( + builder: (ctx) { + final topInset = MediaQuery.viewPaddingOf(ctx).top; + return Positioned( + left: 16, + top: topInset + 8, + width: _poorConnectionIconSize, + height: _poorConnectionIconSize, + child: Material( + color: appTheme.strongRed, + borderRadius: BorderRadius.circular(10), + elevation: 6, + child: Center( + child: SvgPicture.asset( + poorConnectionSvg, + width: _poorConnectionGlyphSize, + height: _poorConnectionGlyphSize, + ), + ), + ), + ); + }, + ); + + _poorConnectionEntry = entry; + if (overlayState == null) { + _poorConnectionEntry = null; + return; + } + overlayState.insert(entry); + } + + static void hidePoorConnectionIcon() { + final entry = _poorConnectionEntry; + _poorConnectionEntry = null; + if (entry != null && entry.mounted) { + entry.remove(); + } + } static OverlayEntry insertBanner( BuildContext context, { diff --git a/lib/features/video_calls/repositories/video_call_repository.dart b/lib/features/video_calls/repositories/video_call_repository.dart index 1b9835b5..15cbb4f1 100644 --- a/lib/features/video_calls/repositories/video_call_repository.dart +++ b/lib/features/video_calls/repositories/video_call_repository.dart @@ -24,4 +24,14 @@ class VideoCallRepository { body: _emptyJsonBody, ); } + + Future notifyCallAccepted({ + required String conversationUuid, + required String hwId, + }) { + return videoCallService.notifyCallAccepted( + conversationUuid: conversationUuid, + body: {'hw_id': hwId}, + ); + } } 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 3c5b1ad0..57c718fa 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -48,6 +48,8 @@ class VideoCallCallKitCoordinator { _eventSubscription = FlutterCallkitIncoming.onEvent.listen(_onCallKitEvent); + VideoCallFcmHandler.registerForegroundListener(); + if (Platform.isIOS) { _voipDiagnosticsChannel.setMethodCallHandler((call) async { switch (call.method) { @@ -67,7 +69,6 @@ class VideoCallCallKitCoordinator { } if (Platform.isAndroid) { - VideoCallFcmHandler.registerForegroundListener(); FlutterCallkitIncoming.acceptCallHandle(_onNativeAcceptCallHandle); await FlutterCallkitIncoming.requestNotificationPermission(< String, @@ -179,7 +180,7 @@ class VideoCallCallKitCoordinator { case CallEventActionCallDecline(): await _handleDeclineEvent(event); case CallEventActionCallEnded(): - await _handleDeclineEvent(event); + await _handleEndedEvent(event); case CallEventActionCallTimeout(): await _handleDeclineEvent(event); default: @@ -211,6 +212,18 @@ class VideoCallCallKitCoordinator { await _handleDecline(id); } + Future _handleEndedEvent(CallEvent event) async { + final id = _callKitEventId(event); + if (id == null) { + log( + 'VideoCallCallKitCoordinator: ended event without callKit id, event=$event', + name: 'VideoCallCallKit', + ); + return; + } + await _handleEnded(id); + } + Future _captureCallKitEventToSentry(CallEvent event) async { if (!Platform.isIOS) return; final context = { @@ -468,6 +481,8 @@ class VideoCallCallKitCoordinator { _acceptHandledCallKitIds.add(callKitId); _suppressedEndCallKitIds.add(callKitId); + unawaited(_manager.notifyIncomingCallAccepted(conversationUuid)); + try { if (!Platform.isIOS) { await FlutterCallkitIncoming.endCall(callKitId); @@ -565,6 +580,25 @@ class VideoCallCallKitCoordinator { } } + Future _handleEnded(String callKitId) async { + if (_suppressedEndCallKitIds.contains(callKitId)) { + log( + 'VideoCallCallKitCoordinator: ignore spurious ended during accept, callKitId=$callKitId', + name: 'VideoCallCallKit', + ); + return; + } + + _callKitParamsById.remove(callKitId); + if (_activeCallKitId == callKitId) { + _activeCallKitId = null; + } + + try { + await FlutterCallkitIncoming.endCall(callKitId); + } catch (_) {} + } + static String _parseConversationUuid( Map extra, { String? handle, diff --git a/lib/features/video_calls/services/video_call_ended_push_handler.dart b/lib/features/video_calls/services/video_call_ended_push_handler.dart new file mode 100644 index 00000000..1b27f32f --- /dev/null +++ b/lib/features/video_calls/services/video_call_ended_push_handler.dart @@ -0,0 +1,19 @@ +import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; + +/// Dismisses incoming call UI after a remote `video_call_ended` push. +abstract final class VideoCallEndedPushHandler { + static Future dismissIncomingCallUi() async { + try { + final activeCalls = await FlutterCallkitIncoming.activeCalls(); + for (final call in activeCalls) { + try { + await FlutterCallkitIncoming.endCall(call.id); + } catch (_) {} + } + } catch (_) {} + + try { + await FlutterCallkitIncoming.endAllCalls(); + } catch (_) {} + } +} diff --git a/lib/features/video_calls/services/video_call_ended_push_payload.dart b/lib/features/video_calls/services/video_call_ended_push_payload.dart new file mode 100644 index 00000000..4defe8fe --- /dev/null +++ b/lib/features/video_calls/services/video_call_ended_push_payload.dart @@ -0,0 +1,11 @@ +import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart'; + +/// Marker for a data-only `video_call_ended` push (no conversation uuid required). +class VideoCallEndedPushPayload { + const VideoCallEndedPushPayload._(); + + static VideoCallEndedPushPayload? tryParse(Map data) { + if (!VideoCallPushPayload.isEndedVideoCall(data)) return null; + return const VideoCallEndedPushPayload._(); + } +} diff --git a/lib/features/video_calls/services/video_call_fcm_handler.dart b/lib/features/video_calls/services/video_call_fcm_handler.dart index 9d582d66..941da2f9 100644 --- a/lib/features/video_calls/services/video_call_fcm_handler.dart +++ b/lib/features/video_calls/services/video_call_fcm_handler.dart @@ -1,6 +1,8 @@ import 'dart:developer'; import 'dart:io'; +import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_handler.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_payload.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_incoming_push_presenter.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart'; import 'package:eatvio_app/firebase_options.dart'; @@ -43,9 +45,35 @@ abstract final class VideoCallFcmHandler { RemoteMessage message, { required String source, }) async { - if (!Platform.isAndroid) return; - final data = Map.from(message.data); + + final endedPayload = VideoCallEndedPushPayload.tryParse(data); + if (endedPayload != null) { + if (Platform.isAndroid && source == 'background') { + log( + 'VideoCallFcmHandler: skip background ended call, handled by native FCM service', + name: 'VideoCallFcm', + ); + return; + } + + log( + 'VideoCallFcmHandler: dismiss incoming call UI, source=$source', + name: 'VideoCallFcm', + ); + try { + await VideoCallEndedPushHandler.dismissIncomingCallUi(); + } catch (e, st) { + log( + 'VideoCallFcmHandler: failed to dismiss incoming call UI: $e', + name: 'VideoCallFcm', + stackTrace: st, + ); + } + return; + } + + if (!Platform.isAndroid) return; if (data.isEmpty) return; final payload = VideoCallPushPayload.tryParse(data); diff --git a/lib/features/video_calls/services/video_call_push_payload.dart b/lib/features/video_calls/services/video_call_push_payload.dart index ceb0767a..4b1f578d 100644 --- a/lib/features/video_calls/services/video_call_push_payload.dart +++ b/lib/features/video_calls/services/video_call_push_payload.dart @@ -52,6 +52,28 @@ class VideoCallPushPayload { ); } + static bool isEndedVideoCall(Map data) { + final type = _string(data, const [ + 'type', + 'notification_type', + 'event', + 'action', + 'push_type', + 'call_type', + ]); + if (type != null) { + final normalized = type.toLowerCase().replaceAll('-', '_'); + if (normalized == 'video_call_ended' || normalized == 'video_call_end') { + return true; + } + if (normalized.contains('video_call') && + (normalized.contains('ended') || normalized.endsWith('_end'))) { + return true; + } + } + return false; + } + static bool isIncomingVideoCall(Map data) { final type = _string(data, const [ 'type', @@ -91,6 +113,9 @@ class VideoCallPushPayload { return false; } + static String? conversationUuidFromData(Map data) => + _conversationUuid(data); + static String? _conversationUuid(Map data) { final fromData = _string(data, const [ 'conversationUuid', diff --git a/lib/features/video_calls/services/video_call_service.dart b/lib/features/video_calls/services/video_call_service.dart index e46b0194..eff4e99b 100644 --- a/lib/features/video_calls/services/video_call_service.dart +++ b/lib/features/video_calls/services/video_call_service.dart @@ -23,4 +23,10 @@ abstract class VideoCallService { @Path('conversation') required String conversationUuid, @Body() required Map body, }); + + @POST('/api/v2/chat/{conversation}/video-call/accept') + Future notifyCallAccepted({ + @Path('conversation') required String conversationUuid, + @Body() required Map body, + }); } diff --git a/pubspec.yaml b/pubspec.yaml index 06d61909..a9be1b5e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.31+120 +version: 1.3.32+121 publish_to: 'none' environment: diff --git a/test/features/chat/models/chat_message_model_test.dart b/test/features/chat/models/chat_message_model_test.dart index fc23b1b4..b731b951 100644 --- a/test/features/chat/models/chat_message_model_test.dart +++ b/test/features/chat/models/chat_message_model_test.dart @@ -54,7 +54,7 @@ void main() { isRead: false, isCurrentUserAuthor: true, roomId: 'room-1', - type: 'text', + type: ChatMessageType.text, ); final json = model.toJson(); diff --git a/test/features/chat/models/chat_messages_model_test.dart b/test/features/chat/models/chat_messages_model_test.dart index 0eab83cf..630bcb36 100644 --- a/test/features/chat/models/chat_messages_model_test.dart +++ b/test/features/chat/models/chat_messages_model_test.dart @@ -48,7 +48,7 @@ void main() { isRead: false, isCurrentUserAuthor: true, roomId: 'room-1', - type: 'text', + type: ChatMessageType.text, ); const meta = ChatMetaModel(lastPage: 1); diff --git a/test/features/chat/presentation/bloc/chats_cubit_test.dart b/test/features/chat/presentation/bloc/chats_cubit_test.dart index 9f8dca92..c3af3197 100644 --- a/test/features/chat/presentation/bloc/chats_cubit_test.dart +++ b/test/features/chat/presentation/bloc/chats_cubit_test.dart @@ -336,7 +336,7 @@ void main() { isRead: false, isCurrentUserAuthor: true, roomId: chatId, - type: 'text', + type: ChatMessageType.text, ); // Используем рефлексию для вызова приватного метода через тестовый доступ @@ -390,7 +390,7 @@ void main() { isRead: false, isCurrentUserAuthor: true, roomId: chatId, - type: 'text', + type: ChatMessageType.text, ); final streamController = StreamController(); diff --git a/test/features/video_calls/models/video_call_models_test.dart b/test/features/video_calls/models/video_call_models_test.dart index ee2e730f..bc46c8f3 100644 --- a/test/features/video_calls/models/video_call_models_test.dart +++ b/test/features/video_calls/models/video_call_models_test.dart @@ -112,4 +112,24 @@ void main() { expect(restored, model); }); }); + + group('VideoCallAcceptResponse', () { + test('fromJson десериализует sent и devices_notified', () { + final json = FixtureReader.readAsMap('video_calls/video_call_accept_response.json'); + + final model = VideoCallAcceptResponse.fromJson(json); + + expect(model.sent, isTrue); + expect(model.devicesNotified, 2); + }); + + test('round-trip JSON сохраняет значения', () { + final json = FixtureReader.readAsMap('video_calls/video_call_accept_response.json'); + + final model = VideoCallAcceptResponse.fromJson(json); + final restored = VideoCallAcceptResponse.fromJson(model.toJson()); + + expect(restored, model); + }); + }); } diff --git a/test/features/video_calls/presentation/video_call_session_cubit_test.dart b/test/features/video_calls/presentation/video_call_session_cubit_test.dart index d6df22d4..2f9e9c3b 100644 --- a/test/features/video_calls/presentation/video_call_session_cubit_test.dart +++ b/test/features/video_calls/presentation/video_call_session_cubit_test.dart @@ -27,6 +27,9 @@ void main() { managerListener = invocation.positionalArguments[0] as VideoCallManagerListener; }); + when( + mockManager.notifyIncomingCallAccepted(any), + ).thenAnswer((_) async {}); cubit = _TestVideoCallSessionCubit(videoCallManager: mockManager); }); diff --git a/test/features/video_calls/services/video_call_push_payload_test.dart b/test/features/video_calls/services/video_call_push_payload_test.dart index 91b3e8d6..4e30a006 100644 --- a/test/features/video_calls/services/video_call_push_payload_test.dart +++ b/test/features/video_calls/services/video_call_push_payload_test.dart @@ -1,3 +1,4 @@ +import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_payload.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -40,14 +41,21 @@ void main() { expect(payload.counterpartName, 'Ivan'); }); - test('ignores ended video call push', () { - expect( - VideoCallPushPayload.tryParse({ - 'type': 'video_call_ended', - 'conversation_uuid': 'abc-123', - }), - isNull, - ); + test('parses ended video call push with type only', () { + final payload = VideoCallEndedPushPayload.tryParse({ + 'type': 'video_call_ended', + }); + + expect(payload, isNotNull); + }); + + test('parses ended video call push with optional conversation uuid', () { + final payload = VideoCallEndedPushPayload.tryParse({ + 'type': 'video_call_ended', + 'conversation_uuid': 'abc-123', + }); + + expect(payload, isNotNull); }); test('ignores regular chat push', () { diff --git a/test/features/video_calls/services/video_call_service_test.dart b/test/features/video_calls/services/video_call_service_test.dart index 3bada540..4d2aa887 100644 --- a/test/features/video_calls/services/video_call_service_test.dart +++ b/test/features/video_calls/services/video_call_service_test.dart @@ -90,5 +90,25 @@ void main() { throwsA(isA()), ); }); + + test('notifyCallAccepted - отправляет hw_id и получает ответ', () async { + const conversationUuid = 'conv-uuid-123'; + const body = {'hw_id': 'device-android-001'}; + final json = FixtureReader.readAsMap('video_calls/video_call_accept_response.json'); + final expected = VideoCallAcceptResponse.fromJson(json); + + when(mockService.notifyCallAccepted(conversationUuid: conversationUuid, body: body)) + .thenAnswer((_) async => expected); + + final result = await mockService.notifyCallAccepted( + conversationUuid: conversationUuid, + body: body, + ); + + expect(result, isA()); + expect(result.sent, isTrue); + expect(result.devicesNotified, 2); + verify(mockService.notifyCallAccepted(conversationUuid: conversationUuid, body: body)).called(1); + }); }); } diff --git a/test/features/video_calls/widgets/video_call_top_overlay_test.dart b/test/features/video_calls/widgets/video_call_top_overlay_test.dart index 77d7c312..f6dc791a 100644 --- a/test/features/video_calls/widgets/video_call_top_overlay_test.dart +++ b/test/features/video_calls/widgets/video_call_top_overlay_test.dart @@ -1,6 +1,7 @@ import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_top_overlay.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_svg/flutter_svg.dart'; void main() { testWidgets('insertBanner shows message and can be removed manually', ( @@ -67,4 +68,36 @@ void main() { await tester.pump(); expect(find.text('Recording notice'), findsNothing); }); + + testWidgets('showPoorConnectionIcon renders compact icon chip', (tester) async { + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: SizedBox.shrink())), + ); + + final context = tester.element(find.byType(SizedBox)); + VideoCallTopOverlay.showPoorConnectionIcon(context); + + await tester.pump(); + expect(find.byType(SvgPicture), findsOneWidget); + expect(find.text('Poor connection'), findsNothing); + expect(VideoCallTopOverlay.isPoorConnectionIconVisible, isTrue); + }); + + testWidgets('hidePoorConnectionIcon removes poor connection chip', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: SizedBox.shrink())), + ); + + final context = tester.element(find.byType(SizedBox)); + VideoCallTopOverlay.showPoorConnectionIcon(context); + await tester.pump(); + expect(VideoCallTopOverlay.isPoorConnectionIconVisible, isTrue); + + VideoCallTopOverlay.hidePoorConnectionIcon(); + await tester.pump(); + expect(VideoCallTopOverlay.isPoorConnectionIconVisible, isFalse); + expect(find.byType(SvgPicture), findsNothing); + }); } diff --git a/test/fixtures/video_calls/video_call_accept_response.json b/test/fixtures/video_calls/video_call_accept_response.json new file mode 100644 index 00000000..ec80c0fd --- /dev/null +++ b/test/fixtures/video_calls/video_call_accept_response.json @@ -0,0 +1,4 @@ +{ + "sent": true, + "devices_notified": 2 +} -- 2.49.1 From c8ad600d97336c6a583cf1b85b621d60dc9ea5d1 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 30 Jun 2026 17:17:58 +0300 Subject: [PATCH 51/88] fix: analyzer --- lib/core/error_reporting/error_report_policy.dart | 1 + pubspec.yaml | 2 +- test/core/error_reporting/error_report_policy_test.dart | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/core/error_reporting/error_report_policy.dart b/lib/core/error_reporting/error_report_policy.dart index 5e813fd8..b7ff92ec 100644 --- a/lib/core/error_reporting/error_report_policy.dart +++ b/lib/core/error_reporting/error_report_policy.dart @@ -44,6 +44,7 @@ class ErrorReportPolicy { case DioExceptionType.connectionTimeout: case DioExceptionType.sendTimeout: case DioExceptionType.receiveTimeout: + case DioExceptionType.transformTimeout: case DioExceptionType.connectionError: return true; case DioExceptionType.unknown: diff --git a/pubspec.yaml b/pubspec.yaml index a9be1b5e..ee16681e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -29,7 +29,7 @@ dependencies: keyboard_dismisser: ^3.0.0 retrofit: 4.7.2 get_it: ^9.2.0 - dio: ^5.9.0 + dio: ^5.10.0 dart_pusher_channels: ^1.2.3 photo_view: ^0.15.0 dismissible_page: ^1.0.2 diff --git a/test/core/error_reporting/error_report_policy_test.dart b/test/core/error_reporting/error_report_policy_test.dart index d504e50a..ee84f765 100644 --- a/test/core/error_reporting/error_report_policy_test.dart +++ b/test/core/error_reporting/error_report_policy_test.dart @@ -32,6 +32,7 @@ void main() { DioExceptionType.connectionTimeout, DioExceptionType.sendTimeout, DioExceptionType.receiveTimeout, + DioExceptionType.transformTimeout, ]) { final e = DioException( requestOptions: RequestOptions(path: '/x'), -- 2.49.1 From 9ce7b035ff1ec839448ea9f200b2f85cf3d90c3f Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 30 Jun 2026 18:48:45 +0300 Subject: [PATCH 52/88] feat(video_calls): fixed call ended push handling --- .../kotlin/com/eatvioapp/app/MainActivity.kt | 16 +++ .../com/eatvioapp/app/VideoCallPushBridge.kt | 113 ++++++++++++++++-- .../reports/problems/problems-report.html | 2 +- .../video_call_ended_push_handler.dart | 18 +++ pubspec.yaml | 2 +- 5 files changed, 138 insertions(+), 13 deletions(-) diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt b/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt index 65903d96..c6b2fb43 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt @@ -25,8 +25,10 @@ class MainActivity: FlutterActivity() { private val channelName = "com.eatvioapp/hms_push" private val videoCallPipChannelName = "com.eatvioapp/video_call_pip" + private val videoCallPushChannelName = "com.eatvioapp/video_call_push" private var methodChannel: MethodChannel? = null private var videoCallPipChannel: MethodChannel? = null + private var videoCallPushChannel: MethodChannel? = null private var videoCallPipEnabled: Boolean = false override fun onResume() { @@ -44,6 +46,20 @@ class MainActivity: FlutterActivity() { methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName) videoCallPipChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPipChannelName) + videoCallPushChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPushChannelName) + videoCallPushChannel?.setMethodCallHandler { call, result -> + when (call.method) { + "dismissIncomingCalls" -> { + try { + VideoCallPushBridge.dismissAllIncomingCalls(applicationContext) + result.success(null) + } catch (e: Exception) { + result.error("DISMISS_INCOMING_CALLS_FAILED", e.message, null) + } + } + else -> result.notImplemented() + } + } videoCallPipChannel?.setMethodCallHandler { call, result -> when (call.method) { "setPipEnabled" -> { diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt index a52976e9..fbad936e 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt @@ -2,12 +2,17 @@ package com.eatvioapp.app import android.content.Context import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat import com.hiennv.flutter_callkit_incoming.CallkitConstants -import com.hiennv.flutter_callkit_incoming.CallkitIncomingBroadcastReceiver import com.hiennv.flutter_callkit_incoming.CallkitNotificationManager +import com.hiennv.flutter_callkit_incoming.CallkitNotificationService import com.hiennv.flutter_callkit_incoming.CallkitSoundPlayerManager import com.hiennv.flutter_callkit_incoming.Data +import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin import com.hiennv.flutter_callkit_incoming.addCall import com.hiennv.flutter_callkit_incoming.getDataActiveCalls import com.hiennv.flutter_callkit_incoming.removeAllCalls @@ -16,6 +21,10 @@ import java.util.HashMap object VideoCallPushBridge { private const val TAG = "VideoCallPushBridge" private const val ANDROID_ACCENT_COLOR = "#2787F5" + private const val MISSED_CALL_CLEANUP_DELAY_MS = 250L + + private var activeSoundManager: CallkitSoundPlayerManager? = null + private var activeNotificationManager: CallkitNotificationManager? = null fun isIncomingVideoCall(data: Map): Boolean { val rawType = data["type"] @@ -90,6 +99,8 @@ object VideoCallPushBridge { val soundManager = CallkitSoundPlayerManager(appContext) val notificationManager = CallkitNotificationManager(appContext, soundManager) + activeSoundManager = soundManager + activeNotificationManager = notificationManager notificationManager.createNotificationChanel(callData) notificationManager.showIncomingNotification(callData) @@ -105,28 +116,108 @@ object VideoCallPushBridge { val calls = getDataActiveCalls(appContext) Log.i(TAG, "dismissAllIncomingCalls: active calls=${calls.size}") - val soundManager = CallkitSoundPlayerManager(appContext) - val notificationManager = CallkitNotificationManager(appContext, soundManager) + stopIncomingRingtone(appContext) - for (call in calls) { - val bundle = call.toBundle() + val notificationManager = resolveNotificationManager(appContext) + val bundles = calls.map { it.toBundle() } + + for (bundle in bundles) { try { - notificationManager.clearIncomingNotification(bundle, isAccepted = false) - appContext.sendBroadcast( - CallkitIncomingBroadcastReceiver.getIntentEnded(appContext, bundle), - ) + dismissIncomingCallNotification(appContext, notificationManager, bundle) } catch (error: Exception) { - Log.w(TAG, "dismissAllIncomingCalls: failed for call ${call.id}", error) + Log.w( + TAG, + "dismissAllIncomingCalls: failed for call ${bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)}", + error, + ) } } + try { + CallkitNotificationService.stopService(appContext) + } catch (error: Exception) { + Log.w(TAG, "dismissAllIncomingCalls: failed to stop callkit service", error) + } + try { removeAllCalls(appContext) } catch (error: Exception) { Log.w(TAG, "dismissAllIncomingCalls: failed to remove persisted calls", error) } - soundManager.stop() + stopIncomingRingtone(appContext) + scheduleMissedCallCleanup(notificationManager, bundles) + activeSoundManager = null + activeNotificationManager = null + } + + private fun resolveNotificationManager(context: Context): CallkitNotificationManager { + activeNotificationManager?.let { return it } + FlutterCallkitIncomingPlugin.getInstance() + ?.getCallkitNotificationManager() + ?.let { return it } + + val soundManager = CallkitSoundPlayerManager(context) + return CallkitNotificationManager(context, soundManager) + } + + private fun stopIncomingRingtone(context: Context) { + activeSoundManager?.stop() + FlutterCallkitIncomingPlugin.getInstance() + ?.getCallkitSoundPlayerManager() + ?.stop() + } + + /** + * Replaces the incoming notification before cancel so Android does not fire the + * timeout delete intent (which would show a missed-call notification). + */ + private fun dismissIncomingCallNotification( + context: Context, + notificationManager: CallkitNotificationManager, + bundle: Bundle, + ) { + val notificationId = incomingNotificationId(bundle) + + val silentNotification = NotificationCompat.Builder( + context, + CallkitNotificationManager.NOTIFICATION_CHANNEL_ID_INCOMING, + ) + .setSmallIcon(context.applicationInfo.icon) + .setContentTitle("") + .setSilent(true) + .setOngoing(false) + .setPriority(NotificationCompat.PRIORITY_MIN) + .build() + + val notificationManagerCompat = NotificationManagerCompat.from(context) + notificationManagerCompat.notify(notificationId, silentNotification) + notificationManagerCompat.cancel(notificationId) + + bundle.putBoolean(CallkitConstants.EXTRA_CALLKIT_MISSED_CALL_SHOW, false) + notificationManager.clearIncomingNotification(bundle, isAccepted = true) + notificationManager.clearMissCallNotification(bundle) + } + + private fun scheduleMissedCallCleanup( + notificationManager: CallkitNotificationManager, + bundles: List, + ) { + if (bundles.isEmpty()) return + + Handler(Looper.getMainLooper()).postDelayed({ + for (bundle in bundles) { + try { + notificationManager.clearMissCallNotification(bundle) + } catch (error: Exception) { + Log.w(TAG, "dismissAllIncomingCalls: delayed missed-call cleanup failed", error) + } + } + }, MISSED_CALL_CLEANUP_DELAY_MS) + } + + private fun incomingNotificationId(bundle: Bundle): Int { + return bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID, "callkit_incoming").hashCode() } private fun buildCallDataBundle(data: Map): Bundle? { diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html index 379f47c9..fa7c1a97 100644 --- a/android/build/reports/problems/problems-report.html +++ b/android/build/reports/problems/problems-report.html @@ -650,7 +650,7 @@ code + .copy-button { diff --git a/lib/features/video_calls/services/video_call_ended_push_handler.dart b/lib/features/video_calls/services/video_call_ended_push_handler.dart index 1b27f32f..5ff7ec6e 100644 --- a/lib/features/video_calls/services/video_call_ended_push_handler.dart +++ b/lib/features/video_calls/services/video_call_ended_push_handler.dart @@ -1,8 +1,26 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; /// Dismisses incoming call UI after a remote `video_call_ended` push. abstract final class VideoCallEndedPushHandler { + static const MethodChannel _androidPushChannel = MethodChannel( + 'com.eatvioapp/video_call_push', + ); + static Future dismissIncomingCallUi() async { + if (Platform.isAndroid) { + try { + await _androidPushChannel.invokeMethod('dismissIncomingCalls'); + return; + } catch (_) {} + } + + await _dismissViaCallKit(); + } + + static Future _dismissViaCallKit() async { try { final activeCalls = await FlutterCallkitIncoming.activeCalls(); for (final call in activeCalls) { diff --git a/pubspec.yaml b/pubspec.yaml index ee16681e..94fc5131 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.32+121 +version: 1.3.33+122 publish_to: 'none' environment: -- 2.49.1 From 219908684f4982e9dccc7ab8ddbf56fe2d53198f Mon Sep 17 00:00:00 2001 From: misha Date: Mon, 6 Jul 2026 11:18:05 +0300 Subject: [PATCH 53/88] feat(video_calls): fixed naviagtion from push notification to call and added wake lock --- .../app/CallKitAcceptIntentMapper.kt | 53 ++ .../com/eatvioapp/app/CallKitIntentBridge.kt | 19 +- .../kotlin/com/eatvioapp/app/MainActivity.kt | 51 ++ .../app/PendingCallKitAcceptBridge.kt | 42 ++ .../managers/video_call_manager.dart | 2 +- .../video_call_session_cubit.dart | 8 + .../video_call_callkit_coordinator.dart | 471 +++++++++++++++--- .../video_call_notification_navigator.dart | 174 +++++-- ...deo_call_screen_wake_lock_coordinator.dart | 28 ++ pubspec.yaml | 3 +- 10 files changed, 752 insertions(+), 99 deletions(-) create mode 100644 android/app/src/main/kotlin/com/eatvioapp/app/CallKitAcceptIntentMapper.kt create mode 100644 android/app/src/main/kotlin/com/eatvioapp/app/PendingCallKitAcceptBridge.kt create mode 100644 lib/features/video_calls/services/video_call_screen_wake_lock_coordinator.dart diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/CallKitAcceptIntentMapper.kt b/android/app/src/main/kotlin/com/eatvioapp/app/CallKitAcceptIntentMapper.kt new file mode 100644 index 00000000..3b6d3732 --- /dev/null +++ b/android/app/src/main/kotlin/com/eatvioapp/app/CallKitAcceptIntentMapper.kt @@ -0,0 +1,53 @@ +package com.eatvioapp.app + +import android.content.Intent +import android.os.Bundle +import com.hiennv.flutter_callkit_incoming.CallkitConstants +import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin + +object CallKitAcceptIntentMapper { + fun payloadFromBundle(data: Bundle, action: String): Map? { + if (!action.contains(CallkitConstants.ACTION_CALL_ACCEPT) && + !action.contains(CallkitConstants.ACTION_CALL_CALLBACK) + ) { + return null + } + + return hashMapOf( + "action" to action, + "id" to data.getString(CallkitConstants.EXTRA_CALLKIT_ID, ""), + "handle" to data.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE, ""), + "nameCaller" to data.getString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, ""), + "avatar" to data.getString(CallkitConstants.EXTRA_CALLKIT_AVATAR, ""), + "extra" to data.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA), + ) + } + + fun payloadFromIntent(intent: Intent?): Map? { + if (intent == null) return null + + val data = intent.getBundleExtra(FlutterCallkitIncomingPlugin.EXTRA_CALLKIT_CALL_DATA) + ?: return null + val action = intent.action ?: return null + if (!action.contains(CallkitConstants.ACTION_CALL_ACCEPT) && + !action.contains(CallkitConstants.ACTION_CALL_CALLBACK) + ) { + return null + } + + return hashMapOf( + "action" to action, + "id" to data.getString(CallkitConstants.EXTRA_CALLKIT_ID, ""), + "handle" to data.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE, ""), + "nameCaller" to data.getString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, ""), + "avatar" to data.getString(CallkitConstants.EXTRA_CALLKIT_AVATAR, ""), + "extra" to data.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA), + ) + } + + fun clearCallKitLaunchIntent(intent: Intent?) { + if (intent == null) return + intent.removeExtra(FlutterCallkitIncomingPlugin.EXTRA_CALLKIT_CALL_DATA) + intent.action = null + } +} diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/CallKitIntentBridge.kt b/android/app/src/main/kotlin/com/eatvioapp/app/CallKitIntentBridge.kt index 2b412781..e433b4fc 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/CallKitIntentBridge.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/CallKitIntentBridge.kt @@ -8,16 +8,31 @@ import com.hiennv.flutter_callkit_incoming.CallkitIncomingBroadcastReceiver object CallKitIntentBridge { fun dispatchCallKitAction(context: Context, action: String, data: Bundle?) { + if (data != null) { + CallKitAcceptIntentMapper.payloadFromBundle(data, action)?.let { payload -> + PendingCallKitAcceptBridge.store(payload) + } + } + val broadcastIntent = CallkitIncomingBroadcastReceiver.getIntent(context, action, data) broadcastIntent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND) context.sendBroadcast(broadcastIntent) - if (MainActivity.isInForeground) { + val shouldLaunchMainActivity = + action == com.hiennv.flutter_callkit_incoming.CallkitConstants.ACTION_CALL_ACCEPT || + action == com.hiennv.flutter_callkit_incoming.CallkitConstants.ACTION_CALL_CALLBACK || + !MainActivity.isInForeground + + if (!shouldLaunchMainActivity) { return } val activityIntent = AppUtils.getAppIntent(context, action, data) ?: return - activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + activityIntent.addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_SINGLE_TOP or + Intent.FLAG_ACTIVITY_REORDER_TO_FRONT, + ) context.startActivity(activityIntent) } } diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt b/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt index c6b2fb43..1ae3354f 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.content.pm.ApplicationInfo import android.content.pm.PackageManager import android.os.Build +import android.os.Bundle import android.util.Rational import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine @@ -34,6 +35,10 @@ class MainActivity: FlutterActivity() { override fun onResume() { super.onResume() isInForeground = true + captureCallKitLaunchIntent(intent) + if (PendingCallKitAcceptBridge.peek() != null) { + scheduleDeliverPendingCallKitAccept() + } } override fun onPause() { @@ -57,6 +62,12 @@ class MainActivity: FlutterActivity() { result.error("DISMISS_INCOMING_CALLS_FAILED", e.message, null) } } + "drainPendingCallKitAccept" -> { + result.success(PendingCallKitAcceptBridge.drain()) + } + "peekPendingCallKitAccept" -> { + result.success(PendingCallKitAcceptBridge.peekPending()) + } else -> result.notImplemented() } } @@ -124,6 +135,7 @@ class MainActivity: FlutterActivity() { // Обрабатываем возможный action_url, с которым была открыта Activity (например, из HMS уведомления). handleIntentForActionUrl(intent) + captureCallKitLaunchIntent(intent) } override fun onUserLeaveHint() { @@ -134,10 +146,49 @@ class MainActivity: FlutterActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) + setIntent(intent) + captureCallKitLaunchIntent(intent) // Обработка нового интента, когда Activity уже запущена (тап по уведомлению). handleIntentForActionUrl(intent) } + private fun captureCallKitLaunchIntent(intent: Intent?) { + val payload = CallKitAcceptIntentMapper.payloadFromIntent(intent) ?: return + + android.util.Log.i( + "VideoCallPush", + "captureCallKitLaunchIntent: action=${payload["action"]} id=${payload["id"]}", + ) + + PendingCallKitAcceptBridge.store(payload) + CallKitAcceptIntentMapper.clearCallKitLaunchIntent(intent) + scheduleDeliverPendingCallKitAccept() + } + + private fun scheduleDeliverPendingCallKitAccept() { + PendingCallKitAcceptBridge.scheduleDeliverToFlutter( + deliver = { deliverPendingCallKitAcceptToFlutter() }, + ) + } + + private fun deliverPendingCallKitAcceptToFlutter() { + val payload = PendingCallKitAcceptBridge.peek() ?: return + val channel = videoCallPushChannel + if (channel == null) { + android.util.Log.w( + "VideoCallPush", + "deliverPendingCallKitAcceptToFlutter: channel not ready", + ) + return + } + + android.util.Log.i( + "VideoCallPush", + "deliverPendingCallKitAcceptToFlutter: id=${payload["id"]}", + ) + channel.invokeMethod("onCallKitLaunchIntent", payload) + } + private fun handleIntentForActionUrl(intent: Intent?) { if (intent == null) return diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/PendingCallKitAcceptBridge.kt b/android/app/src/main/kotlin/com/eatvioapp/app/PendingCallKitAcceptBridge.kt new file mode 100644 index 00000000..8be98e66 --- /dev/null +++ b/android/app/src/main/kotlin/com/eatvioapp/app/PendingCallKitAcceptBridge.kt @@ -0,0 +1,42 @@ +package com.eatvioapp.app + +import android.os.Handler +import android.os.Looper +import android.util.Log + +/// Stores accepted call payloads until the Flutter engine is ready to drain them. +object PendingCallKitAcceptBridge { + private const val TAG = "PendingCallKitAccept" + + @Volatile + private var pendingPayload: Map? = null + + private val mainHandler = Handler(Looper.getMainLooper()) + + fun store(payload: Map) { + pendingPayload = payload + Log.i(TAG, "stored pending accept id=${payload["id"]}") + } + + fun peek(): Map? = pendingPayload + + fun drain(): Map? { + val payload = pendingPayload + pendingPayload = null + if (payload != null) { + Log.i(TAG, "drained pending accept id=${payload["id"]}") + } + return payload + } + + fun peekPending(): Map? = pendingPayload + + fun scheduleDeliverToFlutter( + deliver: () -> Unit, + delaysMs: LongArray = longArrayOf(0L, 250L, 700L, 1500L, 2800L, 4200L, 6000L), + ) { + for (delayMs in delaysMs) { + mainHandler.postDelayed({ deliver() }, delayMs) + } + } +} diff --git a/lib/features/video_calls/managers/video_call_manager.dart b/lib/features/video_calls/managers/video_call_manager.dart index 2501a772..12832204 100644 --- a/lib/features/video_calls/managers/video_call_manager.dart +++ b/lib/features/video_calls/managers/video_call_manager.dart @@ -1136,4 +1136,4 @@ class VideoCallManager { await _audioSession.stop(); } catch (_) {} } -} +} \ No newline at end of file diff --git a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart index 220ec4fd..c35b9b34 100644 --- a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart +++ b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart @@ -4,6 +4,7 @@ import 'package:eatvio_app/core/cubit/base_cubit.dart'; import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart'; import 'package:eatvio_app/features/video_calls/managers/video_call_manager_snapshot.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_screen_wake_lock_coordinator.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_system_pip_coordinator.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -14,9 +15,12 @@ class VideoCallSessionCubit extends BaseCubit { VideoCallSessionCubit({ required VideoCallManager videoCallManager, VideoCallSystemPipCoordinator? systemPipCoordinator, + VideoCallScreenWakeLockCoordinator? screenWakeLockCoordinator, }) : _videoCallManager = videoCallManager, _systemPipCoordinator = systemPipCoordinator ?? VideoCallSystemPipCoordinator(), + _screenWakeLock = + screenWakeLockCoordinator ?? VideoCallScreenWakeLockCoordinator(), super(const VideoCallSessionState()) { _videoCallManager.addListener(_onManagerSnapshot); _syncFromManager(_videoCallManager.snapshot); @@ -24,6 +28,7 @@ class VideoCallSessionCubit extends BaseCubit { final VideoCallManager _videoCallManager; final VideoCallSystemPipCoordinator _systemPipCoordinator; + final VideoCallScreenWakeLockCoordinator _screenWakeLock; bool _callScreenRouteVisible = false; bool _autoEnterInFlight = false; bool _counterpartPresentWhenLobbyReady = false; @@ -168,6 +173,7 @@ class VideoCallSessionCubit extends BaseCubit { VideoCallEntryMode entryMode = VideoCallEntryMode.outgoing, }) async { _callScreenRouteVisible = true; + await _screenWakeLock.acquire(); final effectiveEntryMode = state.entryMode == VideoCallEntryMode.incomingAccepted || @@ -507,6 +513,7 @@ class VideoCallSessionCubit extends BaseCubit { } Future endCall() async { + await _screenWakeLock.release(); await _systemPipCoordinator.disarm(); await _videoCallManager.endCall(); } @@ -567,6 +574,7 @@ class VideoCallSessionCubit extends BaseCubit { } Future _restoreSystemUiAfterCall() async { + await _screenWakeLock.release(); try { await SystemChrome.setPreferredOrientations(const []); await SystemChrome.setEnabledSystemUIMode( 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 57c718fa..8facc589 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -32,7 +32,8 @@ class VideoCallCallKitCoordinator { StreamSubscription? _eventSubscription; final Map _callKitParamsById = {}; - final Set _acceptHandledCallKitIds = {}; + final Set _acceptNavigationCompletedIds = {}; + final Map> _acceptDeliveriesInFlight = {}; final Set _suppressedEndCallKitIds = {}; String? _activeCallKitId; bool _initialized = false; @@ -41,12 +42,29 @@ class VideoCallCallKitCoordinator { static const MethodChannel _voipDiagnosticsChannel = MethodChannel( 'com.eatvioapp/voip_diagnostics', ); + static const MethodChannel _androidCallPushChannel = MethodChannel( + 'com.eatvioapp/video_call_push', + ); + + _PendingIncomingAccept? _pendingIncomingAccept; + _AndroidAcceptResumeObserver? _androidResumeObserver; + bool _pendingAcceptDrainScheduled = false; Future initialize() async { if (_initialized) return; _initialized = true; - _eventSubscription = FlutterCallkitIncoming.onEvent.listen(_onCallKitEvent); + _eventSubscription = FlutterCallkitIncoming.onEvent.listen( + _onCallKitEvent, + onError: (Object error, StackTrace stackTrace) { + log( + 'VideoCallCallKitCoordinator: CallKit event stream error: $error', + name: 'VideoCallCallKit', + stackTrace: stackTrace, + ); + }, + cancelOnError: false, + ); VideoCallFcmHandler.registerForegroundListener(); @@ -70,6 +88,11 @@ class VideoCallCallKitCoordinator { if (Platform.isAndroid) { FlutterCallkitIncoming.acceptCallHandle(_onNativeAcceptCallHandle); + _androidResumeObserver = _AndroidAcceptResumeObserver( + onResumed: _onAndroidAppResumed, + ); + WidgetsBinding.instance.addObserver(_androidResumeObserver!); + _androidCallPushChannel.setMethodCallHandler(_onAndroidCallPushMethodCall); await FlutterCallkitIncoming.requestNotificationPermission(< String, dynamic @@ -80,13 +103,142 @@ class VideoCallCallKitCoordinator { 'postNotificationMessageRequired': 'Разрешите уведомления в настройках, чтобы получать входящие видеозвонки.', }); + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_pollNativePendingAcceptAfterResume()); + }); } } + Future _onAndroidCallPushMethodCall(MethodCall call) async { + if (call.method == 'onCallKitLaunchIntent') { + log( + 'VideoCallCallKitCoordinator: received onCallKitLaunchIntent push from native', + name: 'VideoCallCallKit', + ); + await _handleAndroidCallKitLaunchIntent(call.arguments); + } + } + + void _onAndroidAppResumed() { + unawaited(_pollNativePendingAcceptAfterResume()); + } + + Future _pollNativePendingAcceptAfterResume() async { + for (var attempt = 0; attempt < 16; attempt++) { + await _processNativePendingAndroidCallAccept(); + if (_pendingIncomingAccept == null) { + if (VideoCallNotificationNavigator.isOnCallRoute()) { + return; + } + final hasNativePending = await _hasNativePendingAndroidCallAccept(); + if (!hasNativePending && attempt >= 3) { + break; + } + } + await Future.delayed(const Duration(milliseconds: 300)); + } + unawaited(_drainPendingIncomingAcceptWithRetries()); + } + + Future _hasNativePendingAndroidCallAccept() async { + try { + final pending = await _androidCallPushChannel.invokeMethod( + 'peekPendingCallKitAccept', + ); + return pending != null; + } catch (_) { + return false; + } + } + + Future _processNativePendingAndroidCallAccept() async { + try { + final pending = await _androidCallPushChannel.invokeMethod( + 'peekPendingCallKitAccept', + ); + if (pending == null) return; + + log( + 'VideoCallCallKitCoordinator: processing native pending accept', + name: 'VideoCallCallKit', + ); + await _handleAndroidCallKitLaunchIntent(pending); + } on PlatformException catch (e, stackTrace) { + log( + 'VideoCallCallKitCoordinator: failed to peek native pending accept: $e', + name: 'VideoCallCallKit', + stackTrace: stackTrace, + ); + } catch (e, stackTrace) { + log( + 'VideoCallCallKitCoordinator: failed to process native pending accept: $e', + name: 'VideoCallCallKit', + stackTrace: stackTrace, + ); + } + } + + Future _handleAndroidCallKitLaunchIntent(Object? arguments) async { + final data = _stringKeyedMap(arguments); + final action = data['action']?.toString() ?? ''; + if (!action.contains('ACTION_CALL_ACCEPT') && + !action.contains('ACTION_CALL_CALLBACK')) { + return; + } + + final extra = _stringKeyedMap(data['extra']); + final callKitId = + _parseString(data, const ['id', 'uuid']) ?? + _parseString(extra, const ['conversationUuid', 'conversation_uuid']); + if (callKitId == null) return; + + final conversationUuid = _parseConversationUuid( + extra, + handle: _parseString(data, const ['handle']) ?? callKitId, + ); + if (conversationUuid.isEmpty) return; + + await _deliverIncomingAccept( + callKitId: callKitId, + conversationUuid: conversationUuid, + counterpartName: + _parseString(extra, const [ + 'counterpartName', + 'caller_name', + 'callerName', + ]) ?? + _parseString(data, const ['nameCaller']), + counterpartAvatarUrl: + _parseString(extra, const [ + 'counterpartAvatarUrl', + 'avatar', + 'avatarUrl', + 'caller_avatar', + ]) ?? + _parseString(data, const ['avatar']), + localParticipantName: _parseString(extra, const [ + 'localParticipantName', + 'local_participant_name', + ]), + counterpartIsConsultant: _parseBool(extra, const [ + 'counterpartIsConsultant', + 'counterpart_is_consultant', + 'is_consultant', + 'isConsultant', + ]), + ); + } + void dispose() { + final observer = _androidResumeObserver; + if (observer != null) { + WidgetsBinding.instance.removeObserver(observer); + _androidResumeObserver = null; + } unawaited(_eventSubscription?.cancel()); _eventSubscription = null; _callKitParamsById.clear(); + _pendingIncomingAccept = null; _initialized = false; } @@ -166,25 +318,34 @@ class VideoCallCallKitCoordinator { Future _onCallKitEvent(CallEvent? event) async { if (event == null) return; - unawaited(_captureCallKitEventToSentry(event)); - switch (event) { - case CallEventActionDidUpdateDevicePushTokenVoip(): - await _firebaseApi.registerPushToken(); - case CallEventActionCallIncoming(:final callKitParams): - _cacheCallKitParams(callKitParams); - case CallEventActionCallAccept(): - await _handleAcceptEvent(event); - case CallEventActionCallCallback(): - await _handleAcceptEvent(event); - case CallEventActionCallDecline(): - await _handleDeclineEvent(event); - case CallEventActionCallEnded(): - await _handleEndedEvent(event); - case CallEventActionCallTimeout(): - await _handleDeclineEvent(event); - default: - break; + try { + unawaited(_captureCallKitEventToSentry(event)); + + switch (event) { + case CallEventActionDidUpdateDevicePushTokenVoip(): + await _firebaseApi.registerPushToken(); + case CallEventActionCallIncoming(:final callKitParams): + _cacheCallKitParams(callKitParams); + case CallEventActionCallAccept(): + await _handleAcceptEvent(event); + case CallEventActionCallCallback(): + await _handleAcceptEvent(event); + case CallEventActionCallDecline(): + await _handleDeclineEvent(event); + case CallEventActionCallEnded(): + await _handleEndedEvent(event); + case CallEventActionCallTimeout(): + await _handleDeclineEvent(event); + default: + break; + } + } catch (error, stackTrace) { + log( + 'VideoCallCallKitCoordinator: failed to handle CallKit event $event: $error', + name: 'VideoCallCallKit', + stackTrace: stackTrace, + ); } } @@ -314,7 +475,7 @@ class VideoCallCallKitCoordinator { Future _paramsForCallIdOrActiveCall(String id) async { final cached = _paramsForCallId(id); - if (cached != null || !Platform.isIOS) return cached; + if (cached != null) return cached; final activeCalls = await FlutterCallkitIncoming.activeCalls(); for (final params in activeCalls) { @@ -420,7 +581,7 @@ class VideoCallCallKitCoordinator { } Future _handleAccept(String callKitId) async { - if (_acceptHandledCallKitIds.contains(callKitId)) return; + if (_acceptNavigationCompletedIds.contains(callKitId)) return; _activeCallKitId = callKitId; final params = await _paramsForCallIdOrActiveCall(callKitId); @@ -469,6 +630,24 @@ class VideoCallCallKitCoordinator { ); } + void _rememberPendingIncomingAccept({ + required String callKitId, + required String conversationUuid, + String? counterpartName, + String? counterpartAvatarUrl, + String? localParticipantName, + bool? counterpartIsConsultant, + }) { + _pendingIncomingAccept = _PendingIncomingAccept( + callKitId: callKitId, + conversationUuid: conversationUuid, + counterpartName: counterpartName, + counterpartAvatarUrl: counterpartAvatarUrl, + localParticipantName: localParticipantName, + counterpartIsConsultant: counterpartIsConsultant, + ); + } + Future _deliverIncomingAccept({ required String callKitId, required String conversationUuid, @@ -477,57 +656,202 @@ class VideoCallCallKitCoordinator { String? localParticipantName, bool? counterpartIsConsultant, }) async { - if (_acceptHandledCallKitIds.contains(callKitId)) return; - _acceptHandledCallKitIds.add(callKitId); - _suppressedEndCallKitIds.add(callKitId); + log( + 'VideoCallCallKitCoordinator: deliverIncomingAccept callKitId=$callKitId', + name: 'VideoCallCallKit', + ); - unawaited(_manager.notifyIncomingCallAccepted(conversationUuid)); + _rememberPendingIncomingAccept( + callKitId: callKitId, + conversationUuid: conversationUuid, + counterpartName: counterpartName, + counterpartAvatarUrl: counterpartAvatarUrl, + localParticipantName: localParticipantName, + counterpartIsConsultant: counterpartIsConsultant, + ); + if (_acceptNavigationCompletedIds.contains(callKitId)) return; + + final inFlight = _acceptDeliveriesInFlight[callKitId]; + if (inFlight != null) { + await inFlight; + if (_acceptNavigationCompletedIds.contains(callKitId)) return; + } + + final delivery = _runIncomingAcceptDelivery( + callKitId: callKitId, + conversationUuid: conversationUuid, + counterpartName: counterpartName, + counterpartAvatarUrl: counterpartAvatarUrl, + localParticipantName: localParticipantName, + counterpartIsConsultant: counterpartIsConsultant, + ); + _acceptDeliveriesInFlight[callKitId] = delivery; try { - if (!Platform.isIOS) { - await FlutterCallkitIncoming.endCall(callKitId); - } - - final binding = WidgetsBinding.instance; - final completer = Completer(); - binding.addPostFrameCallback((_) { - unawaited( - _openConnectingScreenAfterAccept( - conversationUuid: conversationUuid, - counterpartName: counterpartName, - counterpartAvatarUrl: counterpartAvatarUrl, - localParticipantName: localParticipantName, - counterpartIsConsultant: counterpartIsConsultant, - ).whenComplete(completer.complete), - ); - }); - await completer.future; - - if (Platform.isIOS) { - await FlutterCallkitIncoming.endCall(callKitId); - } + await delivery; } finally { - unawaited( - Future.delayed(const Duration(seconds: 2), () { - _acceptHandledCallKitIds.remove(callKitId); - _suppressedEndCallKitIds.remove(callKitId); - }), - ); + if (identical(_acceptDeliveriesInFlight[callKitId], delivery)) { + _acceptDeliveriesInFlight.remove(callKitId); + } } } - Future _openConnectingScreenAfterAccept({ + Future _runIncomingAcceptDelivery({ + required String callKitId, required String conversationUuid, String? counterpartName, String? counterpartAvatarUrl, String? localParticipantName, bool? counterpartIsConsultant, }) async { - if (Platform.isIOS) { - FlutterNativeSplash.remove(); + if (_acceptNavigationCompletedIds.contains(callKitId)) return; + + _suppressedEndCallKitIds.add(callKitId); + unawaited(_manager.notifyIncomingCallAccepted(conversationUuid)); + + try { + await _waitForAcceptNavigationReady(); + + final navigated = await _openConnectingScreenAfterAccept( + conversationUuid: conversationUuid, + counterpartName: counterpartName, + counterpartAvatarUrl: counterpartAvatarUrl, + localParticipantName: localParticipantName, + counterpartIsConsultant: counterpartIsConsultant, + ); + + if (navigated) { + _acceptNavigationCompletedIds.add(callKitId); + _pendingIncomingAccept = null; + if (Platform.isAndroid) { + try { + await _androidCallPushChannel.invokeMethod( + 'drainPendingCallKitAccept', + ); + } catch (_) {} + } + } else { + _pendingIncomingAccept = _PendingIncomingAccept( + callKitId: callKitId, + conversationUuid: conversationUuid, + counterpartName: counterpartName, + counterpartAvatarUrl: counterpartAvatarUrl, + localParticipantName: localParticipantName, + counterpartIsConsultant: counterpartIsConsultant, + ); + log( + 'VideoCallCallKitCoordinator: accept navigation deferred until UI is ready, callKitId=$callKitId', + name: 'VideoCallCallKit', + ); + unawaited(_drainPendingIncomingAcceptWithRetries()); + } + + await FlutterCallkitIncoming.endCall(callKitId); + } catch (error, stackTrace) { + log( + 'VideoCallCallKitCoordinator: accept delivery failed, callKitId=$callKitId: $error', + name: 'VideoCallCallKit', + stackTrace: stackTrace, + ); + } finally { + unawaited( + Future.delayed(const Duration(seconds: 2), () { + _suppressedEndCallKitIds.remove(callKitId); + _acceptNavigationCompletedIds.remove(callKitId); + }), + ); + } + } + + Future _drainPendingIncomingAcceptWithRetries() async { + if (_pendingIncomingAccept == null) return; + if (_pendingAcceptDrainScheduled) return; + + _pendingAcceptDrainScheduled = true; + try { + const retryDelaysMs = [0, 250, 600, 1200, 2000, 3200]; + for (final delayMs in retryDelaysMs) { + if (_pendingIncomingAccept == null) return; + if (delayMs > 0) { + await Future.delayed(Duration(milliseconds: delayMs)); + } + await _drainPendingIncomingAccept(); + if (_pendingIncomingAccept == null) return; + } + } finally { + _pendingAcceptDrainScheduled = false; + } + } + + Future _drainPendingIncomingAccept() async { + final pending = _pendingIncomingAccept; + if (pending == null) return; + if (_acceptNavigationCompletedIds.contains(pending.callKitId)) { + _pendingIncomingAccept = null; + return; } - await VideoCallNotificationNavigator.openIncomingCall( + await _waitForAcceptNavigationReady(); + await _settleUiAfterResume(); + + final navigated = await _openConnectingScreenAfterAccept( + conversationUuid: pending.conversationUuid, + counterpartName: pending.counterpartName, + counterpartAvatarUrl: pending.counterpartAvatarUrl, + localParticipantName: pending.localParticipantName, + counterpartIsConsultant: pending.counterpartIsConsultant, + ); + + if (!navigated) return; + + _acceptNavigationCompletedIds.add(pending.callKitId); + _pendingIncomingAccept = null; + if (Platform.isAndroid) { + try { + await _androidCallPushChannel.invokeMethod( + 'drainPendingCallKitAccept', + ); + } catch (_) {} + } + try { + await FlutterCallkitIncoming.endCall(pending.callKitId); + } catch (_) {} + } + + Future _settleUiAfterResume() async { + final binding = WidgetsBinding.instance; + for (var frame = 0; frame < 3; frame++) { + await binding.endOfFrame; + } + await Future.delayed(const Duration(milliseconds: 80)); + } + + Future _waitForAcceptNavigationReady({ + Duration timeout = const Duration(seconds: 10), + }) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + final lifecycle = WidgetsBinding.instance.lifecycleState; + if (lifecycle == AppLifecycleState.resumed) { + for (var frame = 0; frame < 2; frame++) { + await WidgetsBinding.instance.endOfFrame; + } + return; + } + await Future.delayed(const Duration(milliseconds: 50)); + } + } + + Future _openConnectingScreenAfterAccept({ + required String conversationUuid, + String? counterpartName, + String? counterpartAvatarUrl, + String? localParticipantName, + bool? counterpartIsConsultant, + }) async { + FlutterNativeSplash.remove(); + + return VideoCallNotificationNavigator.openIncomingCall( conversationUuid: conversationUuid, counterpartName: counterpartName, counterpartAvatarUrl: counterpartAvatarUrl, @@ -663,3 +987,34 @@ class VideoCallCallKitCoordinator { }); } } + +class _PendingIncomingAccept { + const _PendingIncomingAccept({ + required this.callKitId, + required this.conversationUuid, + this.counterpartName, + this.counterpartAvatarUrl, + this.localParticipantName, + this.counterpartIsConsultant, + }); + + final String callKitId; + final String conversationUuid; + final String? counterpartName; + final String? counterpartAvatarUrl; + final String? localParticipantName; + final bool? counterpartIsConsultant; +} + +class _AndroidAcceptResumeObserver with WidgetsBindingObserver { + _AndroidAcceptResumeObserver({required this.onResumed}); + + final VoidCallback onResumed; + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + onResumed(); + } + } +} diff --git a/lib/features/video_calls/services/video_call_notification_navigator.dart b/lib/features/video_calls/services/video_call_notification_navigator.dart index 5ed4bc16..8462baf0 100644 --- a/lib/features/video_calls/services/video_call_notification_navigator.dart +++ b/lib/features/video_calls/services/video_call_notification_navigator.dart @@ -1,14 +1,19 @@ import 'dart:async'; +import 'dart:developer'; import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/routing/route_constants.dart'; import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart'; -import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart'; -import 'package:go_router/go_router.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_native_splash/flutter_native_splash.dart'; class VideoCallNotificationNavigator { + static bool _pushInFlight = false; + static Future openActiveCall({String? conversationUuid}) async { final manager = sl(); final uuid = conversationUuid ?? manager.conversationUuid; @@ -27,7 +32,8 @@ class VideoCallNotificationNavigator { ); } - static Future openIncomingCall({ + /// Returns `true` when the call UI is visible or navigation succeeded. + static Future openIncomingCall({ required String conversationUuid, String? counterpartName, String? counterpartAvatarUrl, @@ -35,26 +41,103 @@ class VideoCallNotificationNavigator { bool? counterpartIsConsultant, VideoCallEntryMode entryMode = VideoCallEntryMode.outgoing, }) async { - if (conversationUuid.isEmpty) return; + if (conversationUuid.isEmpty) return false; - final pushed = await _pushCallScreen( - conversationUuid: conversationUuid, - counterpartName: counterpartName, - counterpartAvatarUrl: counterpartAvatarUrl, - localParticipantName: localParticipantName, - counterpartIsConsultant: counterpartIsConsultant, - entryMode: entryMode, - ); + FlutterNativeSplash.remove(); + final readySince = DateTime.now(); + await _waitUntilNavigationReady(since: readySince); - if (!pushed) { - await sl().onCallScreenVisible( + for (var attempt = 0; attempt < 8; attempt++) { + if (attempt > 0) { + await Future.delayed(Duration(milliseconds: 200 * attempt)); + await _waitUntilNavigationReady(since: readySince); + } + + if (isOnCallRoute()) { + return true; + } + + final pushed = await _pushCallScreen( conversationUuid: conversationUuid, counterpartName: counterpartName, + counterpartAvatarUrl: counterpartAvatarUrl, localParticipantName: localParticipantName, counterpartIsConsultant: counterpartIsConsultant, entryMode: entryMode, ); + + if (pushed) { + return true; + } } + + return isOnCallRoute(); + } + + static bool isOnCallRoute() { + final matches = + AppRouter.appRouter.routerDelegate.currentConfiguration.matches; + if (matches.isEmpty) return false; + return matches.last.matchedLocation.contains(VideoCallScreen.path); + } + + static Future _waitUntilNavigationReady({ + required DateTime since, + Duration timeout = const Duration(seconds: 12), + }) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + final lifecycle = WidgetsBinding.instance.lifecycleState; + final context = AppRouter.navigatorKey.currentContext; + final routerReady = _isRouterReadyForCallNavigation(since: since); + + if (lifecycle == AppLifecycleState.resumed && + context != null && + context.mounted && + routerReady) { + for (var frame = 0; frame < 2; frame++) { + await SchedulerBinding.instance.endOfFrame; + } + return; + } + await Future.delayed(const Duration(milliseconds: 50)); + } + } + + static bool _isRouterReadyForCallNavigation({required DateTime since}) { + final matches = + AppRouter.appRouter.routerDelegate.currentConfiguration.matches; + if (matches.isEmpty) return false; + + final topLocation = matches.last.matchedLocation; + if (topLocation == RouteConstants.updateScreen) { + // Cold start: wait for version-check redirect unless it is taking too long. + return DateTime.now().difference(since) > + const Duration(seconds: 6); + } + return true; + } + + static Future _waitForRouteToSettle() async { + for (var frame = 0; frame < 4; frame++) { + await SchedulerBinding.instance.endOfFrame; + } + await Future.delayed(const Duration(milliseconds: 80)); + } + + static Future _waitForCallRouteVisible({ + Duration timeout = const Duration(seconds: 3), + }) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + if (isOnCallRoute()) { + await _waitForRouteToSettle(); + return isOnCallRoute(); + } + await SchedulerBinding.instance.endOfFrame; + await Future.delayed(const Duration(milliseconds: 30)); + } + return isOnCallRoute(); } static Future _pushCallScreen({ @@ -65,32 +148,49 @@ class VideoCallNotificationNavigator { bool? counterpartIsConsultant, VideoCallEntryMode entryMode = VideoCallEntryMode.outgoing, }) async { - for (var attempt = 0; attempt < 100; attempt++) { - final ctx = AppRouter.navigatorKey.currentContext; - if (ctx != null && ctx.mounted) { - final router = GoRouter.of(ctx); - final matches = router.routerDelegate.currentConfiguration.matches; - final onCallRoute = matches.any( - (m) => m.matchedLocation.contains(VideoCallScreen.path), - ); - if (!onCallRoute) { - await router.pushNamed( + if (isOnCallRoute()) { + return true; + } + if (_pushInFlight) { + return false; + } + + final context = AppRouter.navigatorKey.currentContext; + if (context == null || !context.mounted) { + return false; + } + + final extra = { + 'conversationUuid': conversationUuid, + 'counterpartName': counterpartName, + 'counterpartAvatarUrl': counterpartAvatarUrl, + 'localParticipantName': localParticipantName, + 'counterpartIsConsultant': ?counterpartIsConsultant, + 'entryMode': entryMode.name, + }; + + _pushInFlight = true; + try { + try { + // pushNamed completes when the route is popped, not when it opens. + unawaited( + AppRouter.appRouter.pushNamed( VideoCallScreen.name, - extra: { - 'conversationUuid': conversationUuid, - 'counterpartName': counterpartName, - 'counterpartAvatarUrl': counterpartAvatarUrl, - 'localParticipantName': localParticipantName, - 'counterpartIsConsultant': ?counterpartIsConsultant, - 'entryMode': entryMode.name, - }, - ); - return true; - } + extra: extra, + ), + ); + } catch (error, stackTrace) { + log( + 'VideoCallNotificationNavigator: pushNamed failed: $error', + name: 'VideoCallCallKit', + stackTrace: stackTrace, + ); return false; } - await Future.delayed(const Duration(milliseconds: 100)); + + return _waitForCallRouteVisible(); + } finally { + _pushInFlight = false; } - return false; } } diff --git a/lib/features/video_calls/services/video_call_screen_wake_lock_coordinator.dart b/lib/features/video_calls/services/video_call_screen_wake_lock_coordinator.dart new file mode 100644 index 00000000..522177de --- /dev/null +++ b/lib/features/video_calls/services/video_call_screen_wake_lock_coordinator.dart @@ -0,0 +1,28 @@ +import 'package:wakelock_plus/wakelock_plus.dart'; + + +class VideoCallScreenWakeLockCoordinator { + bool _held = false; + + bool get isHeld => _held; + + Future acquire() async { + if (_held) return; + + try { + await WakelockPlus.enable(); + _held = true; + } catch (_) { + _held = false; + } + } + + Future release() async { + if (!_held) return; + + _held = false; + try { + await WakelockPlus.disable(); + } catch (_) {} + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 94fc5131..81aa249c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.33+122 +version: 1.3.34+123 publish_to: 'none' environment: @@ -89,6 +89,7 @@ dependencies: flutter_foreground_task: ^9.2.1 flutter_callkit_incoming: ^3.1.1 floating: ^6.0.0 + wakelock_plus: ^1.5.2 dev_dependencies: build_runner: ^2.10.4 -- 2.49.1 From dbaf639ac619ffcdc0121148e49d6b0e706d37db Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 15 Jul 2026 15:18:53 +0300 Subject: [PATCH 54/88] fix: analyzer fix --- lib/core/error_reporting/error_report_policy.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/core/error_reporting/error_report_policy.dart b/lib/core/error_reporting/error_report_policy.dart index b8fb63ea..b7ff92ec 100644 --- a/lib/core/error_reporting/error_report_policy.dart +++ b/lib/core/error_reporting/error_report_policy.dart @@ -46,7 +46,6 @@ class ErrorReportPolicy { case DioExceptionType.receiveTimeout: case DioExceptionType.transformTimeout: case DioExceptionType.connectionError: - case DioExceptionType.transformTimeout: return true; case DioExceptionType.unknown: case DioExceptionType.badResponse: -- 2.49.1 From 5ec877ef72f6eef46e198aff0f479a9222ea9566 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 15 Jul 2026 15:19:56 +0300 Subject: [PATCH 55/88] version changed --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index cb90827b..e5cc431d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.34+123 +version: 1.3.35+124 publish_to: 'none' environment: -- 2.49.1 From 5dc1f48fd1cfc9b48121cd3772d7aa6a501de349 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 15 Jul 2026 17:31:32 +0300 Subject: [PATCH 56/88] feat(ci/cd): added api key for tf --- ios/fastlane/Fastfile | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index 242ef07d..904991ee 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -92,8 +92,14 @@ platform :ios do # codesigning_identity: "Apple Distribution", export_team_id: "VL68C36HYS" ) - + app_store_connect_api_key( + key_id: "325JS9JCZH", + issuer_id: "dfa27d1d-2bb8-4d4d-847e-0b32f9b880b3", + key_filepath: auth_key_path, + duration: 1200, + in_house: false + ) begin -- 2.49.1 From 48a984f5e94932be470d615e950b2ddbb21760c9 Mon Sep 17 00:00:00 2001 From: misha Date: Mon, 20 Jul 2026 16:47:19 +0300 Subject: [PATCH 57/88] feat(personalized_feed): added fixtures for new feed, added new feed parsing and layout, added webview player for post videos --- .../feed/models/feed_blog_post_model.dart | 4 + .../feed/models/post_feed_mapper.dart | 41 ++ .../models/post_feed_pagination_model.dart | 93 +++ .../bloc/feed_cubit/feed_cubit.dart | 61 +- .../feed/presentation/pages/feed_screen.dart | 11 +- .../widgets/blog_post_widget.dart | 608 +++++++++++------- .../feed/presentation/widgets/blog_posts.dart | 21 +- .../widgets/feed_video_player_widget.dart | 272 ++++++++ .../feed/repositories/feed_repository.dart | 11 +- lib/features/feed/services/feed_service.dart | 7 + pubspec.yaml | 2 + .../feed/models/post_feed_mapper_test.dart | 56 ++ .../post_feed_pagination_model_test.dart | 57 ++ .../bloc/feed_cubit/feed_cubit_test.dart | 231 +------ .../presentation/bloc/feed_cubit_test.dart | 408 +----------- .../widgets/basic_widget_test.dart | 10 +- .../widgets/blog_post_widget_test.dart | 2 +- .../repositories/feed_repository_test.dart | 85 ++- .../feed/services/feed_service_test.dart | 27 + .../feed/post_feed_empty_response.json | 15 + .../feed/post_feed_next_page_response.json | 45 ++ test/fixtures/feed/post_feed_response.json | 115 ++++ test/fixtures/feed_fixtures.dart | 51 ++ 23 files changed, 1330 insertions(+), 903 deletions(-) create mode 100644 lib/features/feed/models/post_feed_mapper.dart create mode 100644 lib/features/feed/models/post_feed_pagination_model.dart create mode 100644 lib/features/feed/presentation/widgets/feed_video_player_widget.dart create mode 100644 test/features/feed/models/post_feed_mapper_test.dart create mode 100644 test/features/feed/models/post_feed_pagination_model_test.dart create mode 100644 test/fixtures/feed/post_feed_empty_response.json create mode 100644 test/fixtures/feed/post_feed_next_page_response.json create mode 100644 test/fixtures/feed/post_feed_response.json create mode 100644 test/fixtures/feed_fixtures.dart diff --git a/lib/features/feed/models/feed_blog_post_model.dart b/lib/features/feed/models/feed_blog_post_model.dart index 89eb5838..157a2da0 100644 --- a/lib/features/feed/models/feed_blog_post_model.dart +++ b/lib/features/feed/models/feed_blog_post_model.dart @@ -1,4 +1,5 @@ import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; +import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'feed_blog_post_model.freezed.dart'; @@ -20,6 +21,9 @@ abstract class FeedBlogPostModel with _$FeedBlogPostModel { @JsonKey(name: "total_views") required int totalViews, @JsonKey(name: "total_comments") required int totalComments, @JsonKey(name: "was_liked_by_me") required bool wasLikedByMe, + @Default([]) List images, + @JsonKey(name: "video_embed_url") String? videoEmbedUrl, + @JsonKey(name: "video_url_thumb") String? videoUrlThumb, }) = _FeedBlogPostModel; factory FeedBlogPostModel.fromJson(Map json) => _$FeedBlogPostModelFromJson(json); diff --git a/lib/features/feed/models/post_feed_mapper.dart b/lib/features/feed/models/post_feed_mapper.dart new file mode 100644 index 00000000..f88e01ed --- /dev/null +++ b/lib/features/feed/models/post_feed_mapper.dart @@ -0,0 +1,41 @@ +import 'package:eatvio_app/core/app_config.dart'; +import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; +import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; +import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart'; + +class PostFeedMapper { + static FeedBlogPostModel fromPostFeedItem(PostFeedItemModel item, {int? currentUserId}) { + return FeedBlogPostModel( + id: item.postId, + state: item.postState, + image: resolveMediaUrl(item.postImageUrl), + description: item.postTextMd ?? item.postShortDescription ?? '', + author: Author( + authorSlug: item.postAuthorDetails?.slug ?? '', + authorName: item.postAuthorName, + authorProfileIcon: resolveMediaUrl(item.postAuthorImageUrl), + authorIsMe: currentUserId != null && item.postAuthorId == currentUserId, + isEntitySubscribedByMe: item.isEntitySubscribedByMe, + ), + videoUrl: '', + createdAt: item.postCreatedAt, + updatedAt: item.updatedAt, + title: item.postTitle, + totalLikes: item.postLikesCount, + totalViews: item.postViewsCount, + totalComments: item.postCommentsCount, + wasLikedByMe: item.postIsLikedByUser, + images: item.images, + videoEmbedUrl: item.videoEmbedUrl, + videoUrlThumb: item.videoUrlThumb, + ); + } + + static String resolveMediaUrl(String? url) { + if (url == null || url.isEmpty) return ''; + if (url.startsWith('http://') || url.startsWith('https://')) return url; + if (url.startsWith('/')) return '${sl().baseUrl}$url'; + return '${sl().baseUrl}/storage/$url'; + } +} diff --git a/lib/features/feed/models/post_feed_pagination_model.dart b/lib/features/feed/models/post_feed_pagination_model.dart new file mode 100644 index 00000000..c558e64e --- /dev/null +++ b/lib/features/feed/models/post_feed_pagination_model.dart @@ -0,0 +1,93 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'post_feed_pagination_model.freezed.dart'; +part 'post_feed_pagination_model.g.dart'; + +@freezed +abstract class PostFeedPaginationModel with _$PostFeedPaginationModel { + const factory PostFeedPaginationModel({ + @JsonKey(name: 'data') required List data, + required CursorLinks links, + required CursorMeta meta, + }) = _PostFeedPaginationModel; + + factory PostFeedPaginationModel.fromJson(Map json) => _$PostFeedPaginationModelFromJson(json); +} + +@freezed +abstract class PostFeedItemModel with _$PostFeedItemModel { + const factory PostFeedItemModel({ + required int id, + @JsonKey(name: 'post_id') required int postId, + @JsonKey(name: 'post_author_id') required int postAuthorId, + @JsonKey(name: 'post_author_name') required String postAuthorName, + @JsonKey(name: 'post_author_image_url') String? postAuthorImageUrl, + @JsonKey(name: 'post_author_profile_url') String? postAuthorProfileUrl, + @JsonKey(name: 'post_created_at', fromJson: parseDateTime) required DateTime postCreatedAt, + @JsonKey(name: 'post_title') required String postTitle, + @JsonKey(name: 'post_short_description') String? postShortDescription, + @JsonKey(name: 'post_text_md') String? postTextMd, + @JsonKey(name: 'post_image_url') String? postImageUrl, + @Default([]) List images, + @JsonKey(name: 'video_embed_url') String? videoEmbedUrl, + @JsonKey(name: 'post_views_count') required int postViewsCount, + @JsonKey(name: 'post_likes_count') required int postLikesCount, + @JsonKey(name: 'post_comments_count') required int postCommentsCount, + @JsonKey(name: 'post_is_liked_by_user') required bool postIsLikedByUser, + @JsonKey(name: 'is_entity_subscribed_by_me') bool? isEntitySubscribedByMe, + @JsonKey(name: 'post_url') String? postUrl, + @JsonKey(name: 'post_state') required String postState, + @JsonKey(name: 'post_author_detailz') PostAuthorDetails? postAuthorDetails, + @JsonKey(name: "video_url_thumb") String? videoUrlThumb, + @JsonKey(name: 'created_at', fromJson: parseDateTime) required DateTime createdAt, + @JsonKey(name: 'updated_at', fromJson: parseDateTime) required DateTime updatedAt, + }) = _PostFeedItemModel; + + factory PostFeedItemModel.fromJson(Map json) => _$PostFeedItemModelFromJson(json); +} + +@freezed +abstract class FeedGalleryImage with _$FeedGalleryImage { + const factory FeedGalleryImage({ + required String url, + required String orientation, + }) = _FeedGalleryImage; + + factory FeedGalleryImage.fromJson(Map json) => _$FeedGalleryImageFromJson(json); +} + +@freezed +abstract class PostAuthorDetails with _$PostAuthorDetails { + const factory PostAuthorDetails({ + required String slug, + @JsonKey(name: 'is_consultant') required bool isConsultant, + }) = _PostAuthorDetails; + + factory PostAuthorDetails.fromJson(Map json) => _$PostAuthorDetailsFromJson(json); +} + +@freezed +abstract class CursorLinks with _$CursorLinks { + const factory CursorLinks({ + required String? first, + required String? last, + required String? prev, + required String? next, + }) = _CursorLinks; + + factory CursorLinks.fromJson(Map json) => _$CursorLinksFromJson(json); +} + +@freezed +abstract class CursorMeta with _$CursorMeta { + const factory CursorMeta({ + required String path, + @JsonKey(name: 'per_page') required int perPage, + @JsonKey(name: 'next_cursor') String? nextCursor, + @JsonKey(name: 'prev_cursor') String? prevCursor, + }) = _CursorMeta; + + factory CursorMeta.fromJson(Map json) => _$CursorMetaFromJson(json); +} + +DateTime parseDateTime(String date) => DateTime.parse(date).toLocal(); 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 9254bf07..a88d4623 100644 --- a/lib/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart +++ b/lib/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart @@ -8,8 +8,8 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart'; class FeedCubit extends Cubit { final FeedRepository feedRepository; - int currentPage = 1; - late int lastPage; + String? _nextCursor; + bool _hasMore = true; bool isLoading = false; String? _query; @@ -38,13 +38,16 @@ class FeedCubit extends Cubit { } } - Future loadFeedBlogPostList({String? query}) async { + Future loadFeedBlogPostList({String? query, int? currentUserId}) async { try { _query = query; - final feedBlogPosts = await feedRepository.getFeedBlogPosts(query: query); - lastPage = feedBlogPosts.meta.lastPage; + _nextCursor = null; + _hasMore = true; + final feed = await feedRepository.getFeed(search: query, currentUserId: currentUserId); + _nextCursor = feed.nextCursor; + _hasMore = _nextCursor != null; if (!isClosed) { - emit(FeedState.loaded(feedBlogPostList: feedBlogPosts.feedBlogPostList, isLoading: false)); + emit(FeedState.loaded(feedBlogPostList: feed.posts, isLoading: false)); } } catch (e, st) { await reportFrom(e, st, this); @@ -54,29 +57,35 @@ class FeedCubit extends Cubit { } } - Future loadNextPage() async { - if (currentPage < lastPage && !isLoading && !isClosed) { - isLoading = true; + Future loadNextPage({int? currentUserId}) async { + if (!_hasMore || isLoading || isClosed || _nextCursor == null || state is! FeedStateLoaded) { + return; + } - if (!isClosed) { - emit((state as FeedStateLoaded).copyWith(isLoading: isLoading)); - } + isLoading = true; + emit((state as FeedStateLoaded).copyWith(isLoading: true)); - try { - if (currentPage < lastPage && !isClosed) { - currentPage++; - final feedBlogPosts = await feedRepository.getFeedBlogPosts(page: currentPage, query: _query); - isLoading = false; - if (!isClosed) { - emit(FeedState.loaded(feedBlogPostList: [...(state as FeedStateLoaded).feedBlogPostList, ...feedBlogPosts.feedBlogPostList], isLoading: isLoading)); - } - } - } catch (e, st) { - await reportFrom(e, st, this); - if (!isClosed) { - emit(FeedState.error(errorMessage: e.toString())); - } + try { + final feed = await feedRepository.getFeed(search: _query, cursor: _nextCursor, currentUserId: currentUserId); + _nextCursor = feed.nextCursor; + _hasMore = _nextCursor != null; + if (!isClosed && state is FeedStateLoaded) { + emit( + FeedState.loaded( + feedBlogPostList: [...(state as FeedStateLoaded).feedBlogPostList, ...feed.posts], + isLoading: false, + ), + ); } + } catch (e, st) { + await reportFrom(e, st, this); + if (!isClosed && state is FeedStateLoaded) { + emit((state as FeedStateLoaded).copyWith(isLoading: false)); + } else if (!isClosed) { + emit(FeedState.error(errorMessage: e.toString())); + } + } finally { + isLoading = false; } } diff --git a/lib/features/feed/presentation/pages/feed_screen.dart b/lib/features/feed/presentation/pages/feed_screen.dart index 5eb4f454..58664992 100644 --- a/lib/features/feed/presentation/pages/feed_screen.dart +++ b/lib/features/feed/presentation/pages/feed_screen.dart @@ -83,7 +83,7 @@ class _FeedScreenContentState extends State { final state = feedCubit.state; // Загружаем данные, если они еще не загружены if (state is! FeedStateLoaded) { - feedCubit.loadFeedBlogPostList(); + feedCubit.loadFeedBlogPostList(currentUserId: context.read().state.profile?.id); } }); super.initState(); @@ -201,7 +201,10 @@ class _FeedScreenContentState extends State { EasyDebounce.debounce('searchFeed', const Duration(milliseconds: 500), () { switch (_selectedChipIndex) { case 0: - context.read().loadFeedBlogPostList(query: query); + context.read().loadFeedBlogPostList( + query: query, + currentUserId: context.read().state.profile?.id, + ); break; case 1 when !hidePlans: context.read().loadPlans(query: query); @@ -224,7 +227,9 @@ class _FeedScreenContentState extends State { EasyDebounce.debounce('searchFeed', const Duration(milliseconds: 500), () { switch (_selectedChipIndex) { case 0: - context.read().loadFeedBlogPostList(); + context.read().loadFeedBlogPostList( + currentUserId: context.read().state.profile?.id, + ); break; case 1 when !hidePlans: context.read().loadPlans(); diff --git a/lib/features/feed/presentation/widgets/blog_post_widget.dart b/lib/features/feed/presentation/widgets/blog_post_widget.dart index 4192aaff..7ad825a5 100644 --- a/lib/features/feed/presentation/widgets/blog_post_widget.dart +++ b/lib/features/feed/presentation/widgets/blog_post_widget.dart @@ -1,4 +1,5 @@ import 'package:cached_network_image/cached_network_image.dart'; +import 'package:carousel_slider_plus/carousel_slider_plus.dart'; import 'package:dismissible_page/dismissible_page.dart'; import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/enums/complained_content.dart'; @@ -9,8 +10,10 @@ import 'package:eatvio_app/core/widgets/custom_popup.dart'; import 'package:eatvio_app/core/widgets/feed_icon_button.dart'; import 'package:eatvio_app/core/widgets/markdown_html_widget.dart'; import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart'; +import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; +import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; -import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart'; +import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player_widget.dart'; import 'package:eatvio_app/features/feed/presentation/widgets/subscribe_button_widget.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -19,9 +22,9 @@ import 'package:go_router/go_router.dart'; import 'package:share_plus/share_plus.dart'; class BlogPostWidget extends StatefulWidget { - final int index; + final FeedBlogPostModel post; - const BlogPostWidget({super.key, required this.index}); + const BlogPostWidget({super.key, required this.post}); @override State createState() => _BlogPostWidgetState(); @@ -30,243 +33,378 @@ class BlogPostWidget extends StatefulWidget { class _BlogPostWidgetState extends State { late bool isLiked; late int totalLikes; - bool isMoreOptionsVisible = false; + int currentGalleryIndex = 0; + + FeedBlogPostModel get post => widget.post; + + @override + void initState() { + super.initState(); + isLiked = post.wasLikedByMe; + totalLikes = post.totalLikes; + } + + @override + void didUpdateWidget(covariant BlogPostWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.post.id != post.id) { + currentGalleryIndex = 0; + isLiked = post.wasLikedByMe; + totalLikes = post.totalLikes; + return; + } + if (oldWidget.post.wasLikedByMe != post.wasLikedByMe || oldWidget.post.totalLikes != post.totalLikes) { + isLiked = post.wasLikedByMe; + totalLikes = post.totalLikes; + } + } + + void _openGallery(List imageUrls, int index) { + context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index)); + } + + Future _toggleLike() async { + final nextLiked = !isLiked; + setState(() { + isLiked = nextLiked; + nextLiked ? totalLikes++ : totalLikes--; + }); + context.read().updateFeedBlogPostList(post, nextLiked); + if (nextLiked) { + await context.read().likePost(post.id); + } else { + await context.read().unlikedPost(post.id); + } + } @override Widget build(BuildContext context) { - return BlocBuilder( - builder: (BuildContext context, FeedState state) { - if (state is FeedStateLoaded) { - isLiked = state.feedBlogPostList[widget.index].wasLikedByMe; - totalLikes = state.feedBlogPostList[widget.index].totalLikes; - return InkWell( - borderRadius: BorderRadius.circular(12), - onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.feedBlogPostList[widget.index].id), - child: Material( - borderRadius: BorderRadius.circular(12), - color: Colors.white, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 12), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - GestureDetector( - onTap: () { - GoRouter.of(context).pushNamed('profileScreen', extra: state.feedBlogPostList[widget.index].author.authorSlug); - }, - child: CircleAvatar( - radius: 20, - backgroundColor: Colors.black12, - foregroundImage: NetworkImage( - state.feedBlogPostList[widget.index].author.authorProfileIcon.contains('https') - ? state.feedBlogPostList[widget.index].author.authorProfileIcon - : sl().baseUrl + state.feedBlogPostList[widget.index].author.authorProfileIcon, - ), - backgroundImage: const AssetImage('assets/images/no_avatar.png'), - ), - ), - const SizedBox(width: 8), - // Имя и дата внутри Expanded - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - state.feedBlogPostList[widget.index].author.authorName, - overflow: TextOverflow.ellipsis, - style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16), - ), - const SizedBox(height: 4), - Text( - '${calculateDifference(state.feedBlogPostList[widget.index].createdAt)} в ${state.feedBlogPostList[widget.index].createdAt.hour.toString().padLeft(2, '0')}:${state.feedBlogPostList[widget.index].createdAt.minute.toString().padLeft(2, '0')}', - style: const TextStyle(height: 1, color: Colors.black45, fontSize: 14), - ), - ], - ), - ), - // Кнопка подписки (если нужно) - if (!(state.feedBlogPostList[widget.index].author.isEntitySubscribedByMe ?? false) && - !state.feedBlogPostList[widget.index].author.authorIsMe) - Padding( - padding: const EdgeInsets.only(right: 8), - child: SubscribeButtonWidget(slug: state.feedBlogPostList[widget.index].author.authorSlug), - ), - // Кнопка меню - CustomPopup( - isAuthor: state.feedBlogPostList[widget.index].author.authorIsMe, - complainedContentType: ComplainedContent.posts, - path: 'blog_post/${(state.feedBlogPostList[widget.index].id)}', - id: state.feedBlogPostList[widget.index].id, - feedBlogPostModel: state.feedBlogPostList[widget.index], - userSlug: state.feedBlogPostList[widget.index].author.authorSlug, - ), - ], - ), - ), - if (state.feedBlogPostList[widget.index].title.isNotEmpty) ...[ - const SizedBox(height: 12), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Text( - state.feedBlogPostList[widget.index].title, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.black87), - ), - ), - ], - if (state.feedBlogPostList[widget.index].title.isEmpty) const SizedBox(height: 8), - if (state.feedBlogPostList[widget.index].title.isNotEmpty && state.feedBlogPostList[widget.index].description.isNotEmpty) - const SizedBox(height: 8), - if (state.feedBlogPostList[widget.index].description.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Builder( - builder: (context) { - String description = state.feedBlogPostList[widget.index].description; - final isLong = description.length > 200; - - if (isLong) { - // Умная обрезка: обрезаем markdown текст, не ломая синтаксис - int cutPoint = 200; - - // Проверяем, есть ли незакрытые markdown теги - String preview = description.substring(0, cutPoint); - - // Считаем открывающие и закрывающие теги - int boldOpen = (preview.split('**').length - 1) ~/ 2; - int boldClose = preview.split('**').length - 1 - boldOpen * 2; - bool hasUnclosedBold = boldOpen > boldClose; - - // Если есть незакрытый bold, ищем закрытие - if (hasUnclosedBold) { - int searchEnd = (cutPoint + 100).clamp(0, description.length); - int boldClosePos = description.indexOf('**', cutPoint); - if (boldClosePos != -1 && boldClosePos < searchEnd) { - cutPoint = boldClosePos + 2; - } else { - // Если не нашли, обрезаем до последнего пробела - cutPoint = description.lastIndexOf(' ', 200); - if (cutPoint == -1 || cutPoint < 150) cutPoint = 200; - } - } else { - // Обрезаем до последнего пробела для красоты - cutPoint = description.lastIndexOf(' ', cutPoint); - if (cutPoint == -1 || cutPoint < 150) cutPoint = 200; - } - - description = description.substring(0, cutPoint); - } + final imageUrl = PostFeedMapper.resolveMediaUrl(post.image); + final galleryUrls = post.images.map((e) => PostFeedMapper.resolveMediaUrl(e.url)).where((e) => e.isNotEmpty).toList(); + final embedUrl = post.videoEmbedUrl?.trim(); - return MarkdownHtmlWidget( - markdownText: description, - ); - }, - ), - ), - if (state.feedBlogPostList[widget.index].description.length > 200) - const Padding( - padding: EdgeInsets.symmetric(horizontal: 12), - child: Text( - 'Показать ещё', - style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600), - ), - ), - if (state.feedBlogPostList[widget.index].title.isNotEmpty || state.feedBlogPostList[widget.index].description.isNotEmpty) - const SizedBox(height: 8), - GestureDetector( - onTap: () { - context.pushTransparentRoute( - rootNavigator: true, - PhotoViewScreen(imageUrls: ['${sl().baseUrl}/storage/${state.feedBlogPostList[widget.index].image}'], index: 0), - ); - }, - child: Hero( - tag: '${sl().baseUrl}/storage/${state.feedBlogPostList[widget.index].image}', - child: CachedNetworkImage( - fit: BoxFit.fitWidth, - width: MediaQuery.of(context).size.width, - imageUrl: '${sl().baseUrl}/storage/${state.feedBlogPostList[widget.index].image}', - errorWidget: (context, str, object) => const SizedBox.shrink(), - ), - ), - ), - const SizedBox(height: 12), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( - children: [ - FeedIconButton( - icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded, - text: totalLikes.toString(), - iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null, - backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null, - textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null, - onTap: () async { - setState(() { - isLiked = !isLiked; - isLiked ? totalLikes++ : totalLikes--; - }); - // Update feed list immediately by ID (no reload) - context.read().updateFeedBlogPostList(state.feedBlogPostList[widget.index], isLiked); - // Fire network call without introducing UI delay - if (isLiked) { - await context.read().likePost(state.feedBlogPostList[widget.index].id); - } else { - await context.read().unlikedPost(state.feedBlogPostList[widget.index].id); - } - }, - ), - const SizedBox(width: 8), - FeedIconButton( - svgIcon: SvgPicture.asset( - 'assets/icons/comment.svg', - height: 20, - width: 20, - colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn), - ), - text: state.feedBlogPostList[widget.index].totalComments.toString(), - onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.feedBlogPostList[widget.index].id), - ), - const SizedBox(width: 8), - FeedIconButton( - svgIcon: SvgPicture.asset( - height: 15, - width: 12, - 'assets/icons/repost.svg', - colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn), - ), - onTap: () { - final box = context.findRenderObject() as RenderBox?; - SharePlus.instance.share( - ShareParams( - uri: Uri.parse('${sl().baseUrl}/ru/blog_post/${state.feedBlogPostList[widget.index].id}'), - sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, - ), - ); - }, - ), - const Spacer(), - Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500), - const SizedBox(width: 4), - Text( - state.feedBlogPostList[widget.index].totalViews.toString(), - style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400), - ), - ], - ), - ), - const SizedBox(height: 8), - ], - ), + return InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id), + child: Material( + borderRadius: BorderRadius.circular(12), + color: Colors.white, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 12), + _BlogPostHeader(post: post), + _BlogPostTextContent(post: post), + _BlogPostMediaContent( + galleryUrls: galleryUrls, + imageUrl: imageUrl, + videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null, + videoUrlThumb: post.videoUrlThumb, + currentGalleryIndex: currentGalleryIndex, + onOpenGallery: _openGallery, + onGalleryChanged: (index) { + setState(() { + currentGalleryIndex = index; + }); + }, ), - ); - } - return const SizedBox.shrink(); - }, + const SizedBox(height: 12), + _BlogPostActionsRow(post: post, isLiked: isLiked, totalLikes: totalLikes, onToggleLike: _toggleLike), + const SizedBox(height: 8), + ], + ), + ), ); } } + +class _BlogPostHeader extends StatelessWidget { + const _BlogPostHeader({required this.post}); + + final FeedBlogPostModel post; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: () => GoRouter.of(context).pushNamed('profileScreen', extra: post.author.authorSlug), + child: CircleAvatar( + radius: 20, + backgroundColor: Colors.black12, + foregroundImage: post.author.authorProfileIcon.isNotEmpty ? NetworkImage(PostFeedMapper.resolveMediaUrl(post.author.authorProfileIcon)) : null, + backgroundImage: const AssetImage('assets/images/no_avatar.png'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + post.author.authorName, + overflow: TextOverflow.ellipsis, + style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16), + ), + const SizedBox(height: 4), + Text( + '${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}', + style: const TextStyle(height: 1, color: Colors.black45, fontSize: 14), + ), + ], + ), + ), + if (!(post.author.isEntitySubscribedByMe ?? false) && !post.author.authorIsMe) + Padding( + padding: const EdgeInsets.only(right: 8), + child: SubscribeButtonWidget(slug: post.author.authorSlug), + ), + CustomPopup( + isAuthor: post.author.authorIsMe, + complainedContentType: ComplainedContent.posts, + path: 'blog_post/${post.id}', + id: post.id, + feedBlogPostModel: post, + userSlug: post.author.authorSlug, + ), + ], + ), + ); + } +} + +class _BlogPostTextContent extends StatelessWidget { + const _BlogPostTextContent({required this.post}); + + final FeedBlogPostModel post; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (post.title.isNotEmpty) ...[ + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text( + post.title, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.black87), + ), + ), + ], + if (post.title.isEmpty) const SizedBox(height: 8), + if (post.title.isNotEmpty && post.description.isNotEmpty) const SizedBox(height: 8), + if (post.description.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: MarkdownHtmlWidget(markdownText: _truncateDescription(post.description)), + ), + if (post.description.length > 200) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 12), + child: Text( + 'Показать ещё', + style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600), + ), + ), + if (post.title.isNotEmpty || post.description.isNotEmpty) const SizedBox(height: 8), + ], + ); + } +} + +class _BlogPostMediaContent extends StatelessWidget { + const _BlogPostMediaContent({ + required this.galleryUrls, + required this.imageUrl, + required this.videoEmbedUrl, + required this.currentGalleryIndex, + required this.onOpenGallery, + required this.onGalleryChanged, + required this.videoUrlThumb, + }); + + final List galleryUrls; + final String imageUrl; + final String? videoEmbedUrl; + final String? videoUrlThumb; + final int currentGalleryIndex; + final void Function(List urls, int index) onOpenGallery; + final ValueChanged onGalleryChanged; + + @override + Widget build(BuildContext context) { + final mediaContentCount = videoEmbedUrl != null ? galleryUrls.length + 1 : galleryUrls.length; + return Column( + children: [ + if (galleryUrls.length > 1) ...[ + Stack( + children: [ + CarouselSlider.builder( + itemCount: galleryUrls.length + 1, + itemBuilder: (context, itemIndex, _) { + if (itemIndex == galleryUrls.length) { + if (videoEmbedUrl != null) return FeedVideoPlayer(embedUrl: videoEmbedUrl!, videoUrlThumb: videoUrlThumb); + return const SizedBox.shrink(); + } + final itemUrl = galleryUrls[itemIndex]; + return GestureDetector( + onTap: () => onOpenGallery(galleryUrls, itemIndex), + child: CachedNetworkImage( + fit: BoxFit.cover, + width: MediaQuery.of(context).size.width, + imageUrl: itemUrl, + errorWidget: (context, str, object) => const SizedBox.shrink(), + ), + ); + }, + options: CarouselOptions(viewportFraction: 1, height: 260, enableInfiniteScroll: false, onPageChanged: (index, _) => onGalleryChanged(index)), + ), + Positioned( + top: 12, + right: 12, + child: _GalleryPhotoCounter(currentIndex: currentGalleryIndex, total: mediaContentCount), + ), + ], + ), + ] else if (galleryUrls.length == 1) ...[ + GestureDetector( + onTap: () => onOpenGallery(galleryUrls, 0), + child: CachedNetworkImage( + fit: BoxFit.cover, + width: MediaQuery.of(context).size.width, + height: 260, + imageUrl: galleryUrls.first, + errorWidget: (context, str, object) => const SizedBox.shrink(), + ), + ), + ] else if (imageUrl.isNotEmpty) ...[ + GestureDetector( + onTap: () => onOpenGallery([imageUrl], 0), + child: Hero( + tag: imageUrl, + child: CachedNetworkImage( + fit: BoxFit.fitWidth, + width: MediaQuery.of(context).size.width, + imageUrl: imageUrl, + errorWidget: (context, str, object) => const SizedBox.shrink(), + ), + ), + ), + ], + ], + ); + } +} + +class _GalleryPhotoCounter extends StatelessWidget { + const _GalleryPhotoCounter({required this.currentIndex, required this.total}); + + final int currentIndex; + final int total; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(999)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.photo_library_outlined, size: 14, color: Colors.white), + const SizedBox(width: 8), + Text( + '${currentIndex + 1} / $total', + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500, height: 1.2), + ), + ], + ), + ); + } +} + +class _BlogPostActionsRow extends StatelessWidget { + const _BlogPostActionsRow({required this.post, required this.isLiked, required this.totalLikes, required this.onToggleLike}); + + final FeedBlogPostModel post; + final bool isLiked; + final int totalLikes; + final Future Function() onToggleLike; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + FeedIconButton( + icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded, + text: totalLikes.toString(), + iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null, + backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null, + textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null, + onTap: onToggleLike, + ), + const SizedBox(width: 8), + FeedIconButton( + svgIcon: SvgPicture.asset('assets/icons/comment.svg', height: 20, width: 20, colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn)), + text: post.totalComments.toString(), + onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id), + ), + const SizedBox(width: 8), + FeedIconButton( + svgIcon: SvgPicture.asset(height: 15, width: 12, 'assets/icons/repost.svg', colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn)), + onTap: () { + final box = context.findRenderObject() as RenderBox?; + SharePlus.instance.share( + ShareParams( + uri: Uri.parse('${sl().baseUrl}/ru/blog_post/${post.id}'), + sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, + ), + ); + }, + ), + const Spacer(), + Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500), + const SizedBox(width: 4), + Text( + post.totalViews.toString(), + style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400), + ), + ], + ), + ); + } +} + +String _truncateDescription(String description) { + final isLong = description.length > 200; + if (!isLong) return description; + + var cutPoint = 200; + final preview = description.substring(0, cutPoint); + final boldOpen = (preview.split('**').length - 1) ~/ 2; + final boldClose = preview.split('**').length - 1 - boldOpen * 2; + final hasUnclosedBold = boldOpen > boldClose; + + if (hasUnclosedBold) { + final searchEnd = (cutPoint + 100).clamp(0, description.length); + final boldClosePos = description.indexOf('**', cutPoint); + if (boldClosePos != -1 && boldClosePos < searchEnd) { + cutPoint = boldClosePos + 2; + } else { + cutPoint = description.lastIndexOf(' ', 200); + if (cutPoint == -1 || cutPoint < 150) cutPoint = 200; + } + } else { + cutPoint = description.lastIndexOf(' ', cutPoint); + if (cutPoint == -1 || cutPoint < 150) cutPoint = 200; + } + + return description.substring(0, cutPoint); +} diff --git a/lib/features/feed/presentation/widgets/blog_posts.dart b/lib/features/feed/presentation/widgets/blog_posts.dart index b9da2ecc..afae31c7 100644 --- a/lib/features/feed/presentation/widgets/blog_posts.dart +++ b/lib/features/feed/presentation/widgets/blog_posts.dart @@ -3,6 +3,7 @@ import 'package:eatvio_app/core/widgets/data_error_widget.dart'; import 'package:eatvio_app/core/widgets/loader.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart'; +import 'package:eatvio_app/features/feed/presentation/bloc/profile_cubit/profile_cubit.dart'; import 'package:eatvio_app/features/feed/presentation/pages/feed_screen.dart'; import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_widget.dart'; import 'package:flutter/material.dart'; @@ -11,18 +12,18 @@ import 'package:visibility_detector/visibility_detector.dart'; class BlogPosts extends StatelessWidget { const BlogPosts({super.key, required this.handleVisibility}); - final Function({required double visibleFraction,required int id, required ViewableContentType type}) handleVisibility; - + final Function({required double visibleFraction, required int id, required ViewableContentType type}) handleVisibility; + @override Widget build(BuildContext context) { return RefreshIndicator( - onRefresh: () => context.read().loadFeedBlogPostList(), + onRefresh: () => context.read().loadFeedBlogPostList(currentUserId: context.read().state.profile?.id), child: BlocBuilder( builder: (BuildContext context, FeedState state) { if (state is FeedStateLoaded) { return NotificationListener( onNotification: (notification) { - loadNewContent(notification, context.read().loadNextPage); + loadNewContent(notification, () => context.read().loadNextPage(currentUserId: context.read().state.profile?.id)); return false; }, child: ListView.separated( @@ -32,13 +33,13 @@ class BlogPosts extends StatelessWidget { if (index == state.feedBlogPostList.length) { return const Loader(); } + final post = state.feedBlogPostList[index]; return VisibilityDetector( + key: ValueKey(post.id), onVisibilityChanged: (info) { - final id = state.feedBlogPostList[index].id; - handleVisibility(visibleFraction: info.visibleFraction, id: id, type: ViewableContentType.posts); + handleVisibility(visibleFraction: info.visibleFraction, id: post.id, type: ViewableContentType.posts); }, - key: ValueKey(index), - child: BlogPostWidget(index: index), + child: BlogPostWidget(post: post), ); }, separatorBuilder: (context, index) => const SizedBox(height: 8), @@ -46,7 +47,9 @@ class BlogPosts extends StatelessWidget { ), ); } else if (state is FeedStateError) { - return DataErrorWidget(onReload: () => context.read().loadFeedBlogPostList()); + return DataErrorWidget( + onReload: () => context.read().loadFeedBlogPostList(currentUserId: context.read().state.profile?.id), + ); } else { return Scaffold(backgroundColor: Colors.grey.shade200, body: const Loader()); } diff --git a/lib/features/feed/presentation/widgets/feed_video_player_widget.dart b/lib/features/feed/presentation/widgets/feed_video_player_widget.dart new file mode 100644 index 00000000..701988a9 --- /dev/null +++ b/lib/features/feed/presentation/widgets/feed_video_player_widget.dart @@ -0,0 +1,272 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:webview_flutter/webview_flutter.dart'; +import 'package:webview_flutter_android/webview_flutter_android.dart'; +import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart'; + +/// Embed-видео в ленте. +/// +/// 1) Пока пользователь не нажал play — только превью (без WebView). +/// 2) После play — превью + лоадер, WebView грузится скрыто. +/// 3) Когда embed готов — показываем WebView и запускаем воспроизведение. +class FeedVideoPlayer extends StatefulWidget { + const FeedVideoPlayer({super.key, required this.embedUrl, this.videoUrlThumb}); + + final String embedUrl; + + /// Пока бэкенд не отдаёт превью — null, показывается серая заглушка. + final String? videoUrlThumb; + + @override + State createState() => _FeedVideoPlayerState(); +} + +class _FeedVideoPlayerState extends State { + static const _readyChannel = 'EmbedReady'; + + WebViewController? _controller; + bool _isLoading = false; + bool _isReady = false; + bool _hasError = false; + + @override + void didUpdateWidget(covariant FeedVideoPlayer oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.embedUrl != widget.embedUrl || oldWidget.videoUrlThumb != widget.videoUrlThumb) { + _reset(); + } + } + + @override + void dispose() { + _controller = null; + super.dispose(); + } + + void _reset() { + _controller = null; + _isLoading = false; + _isReady = false; + _hasError = false; + } + + /// После жеста пользователя включаем autoplay, чтобы стартнуть сразу после загрузки. + Uri _embedUriForPlayback(String url) { + final uri = Uri.parse(url); + final params = Map.from(uri.queryParameters); + params['autoplay'] = '1'; + params['mute'] = '1'; + params.putIfAbsent('playsinline', () => '1'); + return uri.replace(queryParameters: params); + } + + String _embedHtml(String embedUrl) { + final src = const HtmlEscape(HtmlEscapeMode.attribute).convert(embedUrl); + return ''' + + + + + + + + + + + + +'''; + } + + void _markReady() { + if (!mounted || _isReady || _hasError) return; + setState(() { + _isReady = true; + _isLoading = false; + }); + } + + Future _onPlayPressed() async { + if (_isLoading || _isReady || !mounted) return; + + setState(() { + _isLoading = true; + _hasError = false; + }); + + try { + final embedUri = _embedUriForPlayback(widget.embedUrl); + final baseUrl = '${embedUri.scheme}://${embedUri.host}/'; + + late final PlatformWebViewControllerCreationParams params; + if (WebViewPlatform.instance is WebKitWebViewPlatform) { + // Жест play уже был — разрешаем media без дополнительного тапа. + params = WebKitWebViewControllerCreationParams(allowsInlineMediaPlayback: true, mediaTypesRequiringUserAction: const {}); + } else { + params = const PlatformWebViewControllerCreationParams(); + } + + final controller = WebViewController.fromPlatformCreationParams(params) + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setBackgroundColor(Colors.black) + ..addJavaScriptChannel(_readyChannel, onMessageReceived: (_) => _markReady()) + ..setOnConsoleMessage((message) { + log('FeedVideoPlayer console message: ${message.message}'); + }) + ..setNavigationDelegate( + NavigationDelegate( + onWebResourceError: (error) { + if (!mounted) return; + if (error.isForMainFrame != true) return; + if (error.description.contains('ERR_BLOCKED_BY_ORB')) return; + if (_isReady) return; + + setState(() { + _hasError = true; + _isLoading = false; + _controller = null; + }); + }, + ), + ); + + final platform = controller.platform; + if (platform is AndroidWebViewController) { + // Жест уже был на кнопке play. + await platform.setMediaPlaybackRequiresUserGesture(false); + } + + await controller.loadHtmlString(_embedHtml(embedUri.toString()), baseUrl: baseUrl); + + if (!mounted) return; + setState(() => _controller = controller); + } catch (e, st) { + if (kDebugMode) { + debugPrint('FeedVideoPlayer load failed: $e\n$st'); + } + if (mounted) { + setState(() { + _hasError = true; + _isLoading = false; + _controller = null; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final player = SizedBox( + height: 220, + width: MediaQuery.sizeOf(context).width, + child: Stack( + fit: StackFit.expand, + children: [ + // WebView создаётся только после play и до ready остаётся скрытым под превью. + if (_controller != null) + Opacity( + opacity: _isReady ? 1 : 0, + child: IgnorePointer( + ignoring: !_isReady, + child: WebViewWidget(key: ValueKey('feed-webview-${widget.embedUrl}'), controller: _controller!), + ), + ), + if (!_isReady) + _VideoPreviewPlaceholder( + previewUrl: widget.videoUrlThumb, + isLoading: _isLoading, + hasError: _hasError, + onPlay: _onPlayPressed, + onRetry: _onPlayPressed, + ), + ], + ), + ); + + // Пока превью/лоадер — не даём InkWell карточки перехватывать тап. + if (_isReady) return player; + return GestureDetector(onTap: () {}, behavior: HitTestBehavior.opaque, child: player); + } +} + +class _VideoPreviewPlaceholder extends StatelessWidget { + const _VideoPreviewPlaceholder({required this.previewUrl, required this.isLoading, required this.hasError, required this.onPlay, required this.onRetry}); + + final String? previewUrl; + final bool isLoading; + final bool hasError; + final VoidCallback onPlay; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + if (previewUrl != null && previewUrl!.isNotEmpty) + CachedNetworkImage( + imageUrl: previewUrl!, + fit: BoxFit.cover, + errorWidget: (_, _, _) => const ColoredBox(color: Color(0xFF2C2C2C)), + ) + else + const ColoredBox(color: Color(0xFF2C2C2C)), + if (hasError) + Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40, color: Colors.white54), + const SizedBox(height: 8), + TextButton( + onPressed: onRetry, + child: const Text('Повторить', style: TextStyle(color: Colors.white70)), + ), + ], + ), + ) + else if (isLoading) + const Center(child: CircularProgressIndicator(color: Colors.white)) + else + Center( + child: GestureDetector( + onTap: onPlay, + child: Container( + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.45), shape: BoxShape.circle), + padding: const EdgeInsets.all(12), + child: const Icon(Icons.play_arrow_rounded, color: Colors.white, size: 40), + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/feed/repositories/feed_repository.dart b/lib/features/feed/repositories/feed_repository.dart index b5128be8..781e0f3c 100644 --- a/lib/features/feed/repositories/feed_repository.dart +++ b/lib/features/feed/repositories/feed_repository.dart @@ -1,9 +1,9 @@ import 'package:eatvio_app/features/feed/models/comment_model.dart'; import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; -import 'package:eatvio_app/features/feed/models/feed_blog_post_settings_model.dart'; import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart'; import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'; +import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart'; import 'package:eatvio_app/features/feed/models/recipes_model.dart'; import 'package:eatvio_app/features/feed/models/view_model.dart'; import 'package:eatvio_app/features/feed/services/feed_service.dart'; @@ -13,9 +13,12 @@ class FeedRepository { FeedRepository({required this.feedService}); - Future getFeedBlogPosts({String? query, int page = 1}) async { - return (await feedService.getBlogPostList(page: page, query: query)); - + Future<({List posts, String? nextCursor})> getFeed({String? search, String? cursor, int? currentUserId}) async { + final response = await feedService.getFeed(search: search, cursor: cursor); + return ( + posts: response.data.map((item) => PostFeedMapper.fromPostFeedItem(item, currentUserId: currentUserId)).toList(), + nextCursor: response.meta.nextCursor, + ); } Future getBlogPost({required int id}) async { diff --git a/lib/features/feed/services/feed_service.dart b/lib/features/feed/services/feed_service.dart index c756f8c7..9d88dd8f 100644 --- a/lib/features/feed/services/feed_service.dart +++ b/lib/features/feed/services/feed_service.dart @@ -5,6 +5,7 @@ import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; import 'package:eatvio_app/features/feed/models/feed_blog_post_settings_model.dart'; import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart'; import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'; +import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/recipes_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/view_model.dart'; import 'package:retrofit/retrofit.dart'; @@ -18,6 +19,12 @@ abstract class FeedService { @GET('/api/blogPosts/index') Future getBlogPostList({@Query('page') required int page, @Query('query_search') String? query}); + @GET('/api/feed') + Future getFeed({ + @Query('search') String? search, + @Query('cursor') String? cursor, + }); + @GET('/api/blogPosts/show/{postId}') Future getBlogPost({@Path('postId') required int postId}); diff --git a/pubspec.yaml b/pubspec.yaml index e6badd50..8c44cc5a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -86,6 +86,8 @@ dependencies: sentry_dio: ^9.10.0 livekit_client: ^2.6.1 sbp: 0.1.0 + webview_flutter_android: ^4.12.0 + webview_flutter_wkwebview: ^3.25.1 dev_dependencies: build_runner: ^2.10.4 diff --git a/test/features/feed/models/post_feed_mapper_test.dart b/test/features/feed/models/post_feed_mapper_test.dart new file mode 100644 index 00000000..2873aa0c --- /dev/null +++ b/test/features/feed/models/post_feed_mapper_test.dart @@ -0,0 +1,56 @@ +import 'package:eatvio_app/core/app_config.dart'; +import 'package:eatvio_app/core/app_initializer.dart'; +import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../../fixtures/feed_fixtures.dart'; + +void main() { + group('PostFeedMapper', () { + setUpAll(() async { + await sl.reset(); + dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); + sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); + }); + + test('maps fixture item post_id to FeedBlogPostModel.id', () { + final item = FeedFixtures.postFeedItem(); + final model = PostFeedMapper.fromPostFeedItem(item, currentUserId: 10); + + expect(model.id, 1); + expect(model.author.authorIsMe, isTrue); + expect(model.author.authorSlug, 'anna-ivanova'); + expect(model.images, hasLength(2)); + expect(model.videoUrl, isEmpty); + expect(model.videoEmbedUrl, isNull); + expect(model.title, 'Завтрак с овсянкой'); + expect(model.image, 'http://localhost/storage/posts/oatmeal.jpg'); + expect(model.author.isEntitySubscribedByMe, isFalse); + }); + + test('maps fixture embed video post and likes', () { + final item = FeedFixtures.postFeedItem(index: 1); + final model = PostFeedMapper.fromPostFeedItem(item); + + expect(model.id, 2); + expect(model.videoUrl, isEmpty); + expect(model.videoEmbedUrl, 'https://www.youtube.com/embed/dQw4w9WgXcQ'); + expect(model.wasLikedByMe, isTrue); + expect(model.totalLikes, 42); + expect(model.author.authorIsMe, isFalse); + expect(model.author.isEntitySubscribedByMe, isTrue); + }); + + test('maps all fixture posts', () { + final posts = FeedFixtures.mappedFeedPosts(currentUserId: 11); + + expect(posts, hasLength(3)); + expect(posts[1].author.authorIsMe, isTrue); + expect(posts[1].author.isEntitySubscribedByMe, isTrue); + expect(posts[2].videoEmbedUrl, contains('rutube.ru/play/embed')); + expect(posts[2].author.isEntitySubscribedByMe, isNull); + }); + }); +} diff --git a/test/features/feed/models/post_feed_pagination_model_test.dart b/test/features/feed/models/post_feed_pagination_model_test.dart new file mode 100644 index 00000000..fb1fb47f --- /dev/null +++ b/test/features/feed/models/post_feed_pagination_model_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter_test/flutter_test.dart'; + +import '../../../fixtures/feed_fixtures.dart'; + +void main() { + group('PostFeedPaginationModel fixtures', () { + test('parses first page with gallery, video and next_cursor', () { + final model = FeedFixtures.postFeedPaginationModel(); + + expect(model.data, hasLength(3)); + expect(model.meta.perPage, 20); + expect(model.meta.nextCursor, 'eyJpZCI6MTAzfQ'); + expect(model.meta.prevCursor, isNull); + expect(model.links.next, contains('cursor=eyJpZCI6MTAzfQ')); + + final withGallery = model.data[0]; + expect(withGallery.postId, 1); + expect(withGallery.images, hasLength(2)); + expect(withGallery.images.first.orientation, 'horizontal'); + expect(withGallery.videoEmbedUrl, isNull); + expect(withGallery.isEntitySubscribedByMe, isFalse); + expect(withGallery.postAuthorDetails?.slug, 'anna-ivanova'); + expect(withGallery.postAuthorDetails?.isConsultant, isTrue); + + final withYoutubeEmbed = model.data[1]; + expect(withYoutubeEmbed.postId, 2); + expect(withYoutubeEmbed.videoEmbedUrl, 'https://www.youtube.com/embed/dQw4w9WgXcQ'); + expect(withYoutubeEmbed.postIsLikedByUser, isTrue); + expect(withYoutubeEmbed.isEntitySubscribedByMe, isTrue); + expect(withYoutubeEmbed.images, isEmpty); + + final withRutubeEmbed = model.data[2]; + expect(withRutubeEmbed.postId, 3); + expect(withRutubeEmbed.postImageUrl, isNull); + expect(withRutubeEmbed.images, hasLength(1)); + expect(withRutubeEmbed.videoEmbedUrl, contains('rutube.ru/play/embed')); + expect(withRutubeEmbed.isEntitySubscribedByMe, isNull); }); + + test('parses next page without next_cursor', () { + final model = FeedFixtures.postFeedNextPagePaginationModel(); + + expect(model.data, hasLength(1)); + expect(model.data.first.postId, 4); + expect(model.meta.nextCursor, isNull); + expect(model.meta.prevCursor, 'eyJpZCI6MTAzfQ'); + expect(model.links.next, isNull); + }); + + test('parses empty feed', () { + final model = FeedFixtures.postFeedEmptyPaginationModel(); + + expect(model.data, isEmpty); + expect(model.meta.nextCursor, isNull); + expect(model.links.next, isNull); + }); + }); +} diff --git a/test/features/feed/presentation/bloc/feed_cubit/feed_cubit_test.dart b/test/features/feed/presentation/bloc/feed_cubit/feed_cubit_test.dart index 15cfaa79..cb17180e 100644 --- a/test/features/feed/presentation/bloc/feed_cubit/feed_cubit_test.dart +++ b/test/features/feed/presentation/bloc/feed_cubit/feed_cubit_test.dart @@ -1,7 +1,5 @@ -import 'package:eatvio_app/core/models/pagination_model.dart'; import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; -import 'package:eatvio_app/features/feed/models/feed_blog_post_settings_model.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart'; import 'package:eatvio_app/features/feed/repositories/feed_repository.dart'; @@ -13,211 +11,52 @@ import 'feed_cubit_test.mocks.dart'; @GenerateNiceMocks([MockSpec()]) void main() { - group('FeedCubit', () { - FeedCubit? feedCubit; - late MockFeedRepository mockFeedRepository; - late FeedBlogPostSettingsModel feedBlogPostsFixture; + group('FeedCubit cursor flow', () { + late FeedCubit cubit; + late MockFeedRepository repository; + + final post = FeedBlogPostModel( + id: 10, + state: 'active', + image: '', + description: 'description', + author: const Author(authorSlug: 'slug', authorName: 'name', authorProfileIcon: '', authorIsMe: false), + videoUrl: '', + createdAt: DateTime(2024, 1, 1), + updatedAt: DateTime(2024, 1, 1), + title: 'title', + totalLikes: 1, + totalViews: 1, + totalComments: 1, + wasLikedByMe: false, + ); setUp(() { - mockFeedRepository = MockFeedRepository(); - - // Создаём тестовые данные - final testPosts = [ - FeedBlogPostModel( - id: 1, - state: 'published', - image: 'https://example.com/image1.jpg', - description: 'Описание поста 1', - author: const Author( - authorSlug: 'author-1', - authorName: 'Автор 1', - authorProfileIcon: 'https://example.com/photo1.jpg', - authorIsMe: false, - isEntitySubscribedByMe: null, - authorPlaceInRating: null, - ), - videoUrl: '', - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - title: 'Тестовый пост 1', - totalLikes: 10, - totalViews: 100, - totalComments: 5, - wasLikedByMe: false, - ), - FeedBlogPostModel( - id: 2, - state: 'published', - image: 'https://example.com/image2.jpg', - description: 'Описание поста 2', - author: const Author( - authorSlug: 'author-2', - authorName: 'Автор 2', - authorProfileIcon: 'https://example.com/photo2.jpg', - authorIsMe: false, - isEntitySubscribedByMe: null, - authorPlaceInRating: null, - ), - videoUrl: '', - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - title: 'Тестовый пост 2', - totalLikes: 20, - totalViews: 200, - totalComments: 10, - wasLikedByMe: true, - ), - ]; - - feedBlogPostsFixture = FeedBlogPostSettingsModel( - feedBlogPostList: testPosts, - meta: const Meta( - currentPage: 1, - from: null, - lastPage: 3, - links: [], - path: null, - perPage: 10, - to: null, - total: 25, - totalMinPrice: null, - totalMaxPrice: null, - ), - ); + repository = MockFeedRepository(); + cubit = FeedCubit(feedRepository: repository); }); tearDown(() async { - if (feedCubit != null && !feedCubit!.isClosed) { - await Future.delayed(const Duration(milliseconds: 100)); - feedCubit!.close(); - feedCubit = null; - } + await cubit.close(); }); - test('начальное состояние - FeedStateLoading', () { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - expect(feedCubit!.state, const FeedState.loading()); + test('error state on load failure', () async { + when(repository.getFeed(search: null, cursor: null, currentUserId: null)).thenThrow(Exception('boom')); + + await cubit.loadFeedBlogPostList(); + + expect(cubit.state, isA()); }); - test('loadFeedBlogPostList - успешная загрузка постов', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); + test('updateFeedBlogPostList updates likes in loaded state', () async { + when(repository.getFeed(search: null, cursor: null, currentUserId: null)).thenAnswer((_) async => (posts: [post], nextCursor: null)); + await cubit.loadFeedBlogPostList(); - when(mockFeedRepository.getFeedBlogPosts(query: null)) - .thenAnswer((_) async => feedBlogPostsFixture); + cubit.updateFeedBlogPostList(post, true); - await feedCubit!.loadFeedBlogPostList(); - - await Future.delayed(const Duration(milliseconds: 100)); - expect(feedCubit!.state, isA()); - final loadedState = feedCubit!.state as FeedStateLoaded; - expect(loadedState.feedBlogPostList.length, 2); - expect(loadedState.isLoading, false); - expect(feedCubit!.lastPage, 3); - }); - - test('loadFeedBlogPostList - загрузка с запросом', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - const query = 'тест'; - - when(mockFeedRepository.getFeedBlogPosts(query: query)) - .thenAnswer((_) async => feedBlogPostsFixture); - - await feedCubit!.loadFeedBlogPostList(query: query); - - await Future.delayed(const Duration(milliseconds: 100)); - expect(feedCubit!.state, isA()); - verify(mockFeedRepository.getFeedBlogPosts(query: query)).called(1); - }); - - test('loadFeedBlogPostList - обработка ошибки', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - - when(mockFeedRepository.getFeedBlogPosts(query: anyNamed('query'))) - .thenThrow(Exception('Ошибка загрузки')); - - await feedCubit!.loadFeedBlogPostList(); - - await Future.delayed(const Duration(milliseconds: 100)); - expect(feedCubit!.state, isA()); - final errorState = feedCubit!.state as FeedStateError; - expect(errorState.errorMessage, contains('Exception')); - }); - - test('updateFeedBlogPostList - обновление лайка поста', () { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - - // Сначала загружаем посты - when(mockFeedRepository.getFeedBlogPosts(query: anyNamed('query'))) - .thenAnswer((_) async => feedBlogPostsFixture); - - // Устанавливаем состояние loaded вручную для теста - feedCubit!.emit(FeedState.loaded( - feedBlogPostList: feedBlogPostsFixture.feedBlogPostList, - isLoading: false, - )); - feedCubit!.lastPage = 3; - - final postToUpdate = feedBlogPostsFixture.feedBlogPostList.first; - feedCubit!.updateFeedBlogPostList(postToUpdate, true); - - expect(feedCubit!.state, isA()); - final loadedState = feedCubit!.state as FeedStateLoaded; - final updatedPost = loadedState.feedBlogPostList.firstWhere((p) => p.id == postToUpdate.id); - expect(updatedPost.wasLikedByMe, true); - expect(updatedPost.totalLikes, postToUpdate.totalLikes + 1); - }); - - test('updateFeedBlogPostList - снятие лайка', () { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - - feedCubit!.emit(FeedState.loaded( - feedBlogPostList: feedBlogPostsFixture.feedBlogPostList, - isLoading: false, - )); - feedCubit!.lastPage = 3; - - final postToUpdate = feedBlogPostsFixture.feedBlogPostList[1]; // У этого wasLikedByMe = true - final initialLikes = postToUpdate.totalLikes; - - feedCubit!.updateFeedBlogPostList(postToUpdate, false); - - expect(feedCubit!.state, isA()); - final loadedState = feedCubit!.state as FeedStateLoaded; - final updatedPost = loadedState.feedBlogPostList.firstWhere((p) => p.id == postToUpdate.id); - expect(updatedPost.wasLikedByMe, false); - expect(updatedPost.totalLikes, initialLikes - 1); - }); - - test('updateFeedBlogPostList - игнорирует обновление если состояние не loaded', () { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - - // Состояние loading - feedCubit!.emit(const FeedState.loading()); - - final postToUpdate = feedBlogPostsFixture.feedBlogPostList.first; - feedCubit!.updateFeedBlogPostList(postToUpdate, true); - - // Состояние не должно измениться - expect(feedCubit!.state, const FeedState.loading()); - }); - - test('updateFeedBlogPostList - игнорирует обновление если пост не найден', () { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - - feedCubit!.emit(FeedState.loaded( - feedBlogPostList: feedBlogPostsFixture.feedBlogPostList, - isLoading: false, - )); - feedCubit!.lastPage = 3; - - // Создаём пост с несуществующим ID - final nonExistentPost = feedBlogPostsFixture.feedBlogPostList.first.copyWith(id: 999); - feedCubit!.updateFeedBlogPostList(nonExistentPost, true); - - // Список не должен измениться - final loadedState = feedCubit!.state as FeedStateLoaded; - expect(loadedState.feedBlogPostList.length, 2); + final loaded = cubit.state as FeedStateLoaded; + expect(loaded.feedBlogPostList.first.wasLikedByMe, true); + expect(loaded.feedBlogPostList.first.totalLikes, 2); }); }); } - diff --git a/test/features/feed/presentation/bloc/feed_cubit_test.dart b/test/features/feed/presentation/bloc/feed_cubit_test.dart index 7213c5e9..e8d1de87 100644 --- a/test/features/feed/presentation/bloc/feed_cubit_test.dart +++ b/test/features/feed/presentation/bloc/feed_cubit_test.dart @@ -1,404 +1,66 @@ -import 'package:eatvio_app/core/models/pagination_model.dart'; import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; -import 'package:eatvio_app/features/feed/models/feed_blog_post_settings_model.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart'; import 'package:eatvio_app/features/feed/repositories/feed_repository.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; + import 'feed_cubit_test.mocks.dart'; @GenerateNiceMocks([MockSpec()]) void main() { group('FeedCubit', () { - FeedCubit? feedCubit; - late MockFeedRepository mockFeedRepository; + late MockFeedRepository repository; + late FeedCubit cubit; - final testBlogPost = FeedBlogPostModel( + final post = FeedBlogPostModel( id: 1, - title: 'Test Post', - description: 'Test description', - image: 'https://example.com/image.jpg', - state: 'published', - author: const Author(authorSlug: 'author-1', authorName: 'Author Name', authorProfileIcon: 'https://example.com/icon.jpg', authorIsMe: false), - videoUrl: 'https://example.com/video.mp4', - createdAt: DateTime(2024, 1, 15), - updatedAt: DateTime(2024, 1, 15), - totalLikes: 10, - totalViews: 100, - totalComments: 5, + state: 'active', + image: 'https://example.com/image.png', + description: 'text', + author: const Author(authorSlug: 'a', authorName: 'Author', authorProfileIcon: 'https://example.com/avatar.png', authorIsMe: false), + videoUrl: '', + createdAt: DateTime(2024, 1, 1), + updatedAt: DateTime(2024, 1, 1), + title: 'Title', + totalLikes: 1, + totalViews: 2, + totalComments: 3, wasLikedByMe: false, ); - final testBlogPost2 = FeedBlogPostModel( - id: 2, - title: 'Test Post 2', - description: 'Test description 2', - image: 'https://example.com/image2.jpg', - state: 'published', - author: const Author(authorSlug: 'author-2', authorName: 'Author Name 2', authorProfileIcon: 'https://example.com/icon2.jpg', authorIsMe: false), - videoUrl: 'https://example.com/video2.mp4', - createdAt: DateTime(2024, 1, 16), - updatedAt: DateTime(2024, 1, 16), - totalLikes: 20, - totalViews: 200, - totalComments: 10, - wasLikedByMe: true, - ); - setUp(() { - mockFeedRepository = MockFeedRepository(); + repository = MockFeedRepository(); + cubit = FeedCubit(feedRepository: repository); }); tearDown(() async { - if (feedCubit != null && !feedCubit!.isClosed) { - await Future.delayed(const Duration(milliseconds: 100)); - feedCubit!.close(); - feedCubit = null; - } + await cubit.close(); }); - test('начальное состояние - loading', () { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - expect(feedCubit!.state, const FeedState.loading()); + test('loadFeedBlogPostList emits loaded from getFeed', () async { + when(repository.getFeed(search: null, cursor: null, currentUserId: null)).thenAnswer((_) async => (posts: [post], nextCursor: 'next')); + + await cubit.loadFeedBlogPostList(); + + expect(cubit.state, isA()); + final loaded = cubit.state as FeedStateLoaded; + expect(loaded.feedBlogPostList, hasLength(1)); + verify(repository.getFeed(search: null, cursor: null, currentUserId: null)).called(1); }); - test('loadFeedBlogPostList - успешно загружает список постов', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - final expectedSettings = FeedBlogPostSettingsModel( - feedBlogPostList: [testBlogPost], - meta: const Meta(currentPage: 1, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null), - ); + test('loadNextPage appends posts', () async { + when(repository.getFeed(search: null, cursor: null, currentUserId: null)).thenAnswer((_) async => (posts: [post], nextCursor: 'next')); + when(repository.getFeed(search: null, cursor: 'next', currentUserId: null)).thenAnswer((_) async => (posts: [post.copyWith(id: 2)], nextCursor: null)); - when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => expectedSettings); + await cubit.loadFeedBlogPostList(); + await cubit.loadNextPage(); - await feedCubit!.loadFeedBlogPostList(); - await Future.delayed(const Duration(milliseconds: 100)); - - expect(feedCubit!.state, isA()); - final loadedState = feedCubit!.state as FeedStateLoaded; - expect(loadedState.feedBlogPostList.length, 1); - expect(loadedState.feedBlogPostList.first.id, 1); - expect(loadedState.isLoading, false); - expect(feedCubit!.lastPage, 3); - verify(mockFeedRepository.getFeedBlogPosts(query: null)).called(1); - }); - - test('loadFeedBlogPostList - загружает список постов с query', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - const query = 'test query'; - final expectedSettings = FeedBlogPostSettingsModel( - feedBlogPostList: [testBlogPost, testBlogPost2], - meta: const Meta(currentPage: 1, lastPage: 1, total: 2, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null), - ); - - when(mockFeedRepository.getFeedBlogPosts(query: query)).thenAnswer((_) async => expectedSettings); - - await feedCubit!.loadFeedBlogPostList(query: query); - await Future.delayed(const Duration(milliseconds: 100)); - - expect(feedCubit!.state, isA()); - final loadedState = feedCubit!.state as FeedStateLoaded; - expect(loadedState.feedBlogPostList.length, 2); - verify(mockFeedRepository.getFeedBlogPosts(query: query)).called(1); - }); - - test('loadFeedBlogPostList - обрабатывает ошибку', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - final exception = Exception('Network error'); - - when(mockFeedRepository.getFeedBlogPosts(query: null)).thenThrow(exception); - - await feedCubit!.loadFeedBlogPostList(); - await Future.delayed(const Duration(milliseconds: 100)); - - expect(feedCubit!.state, isA()); - final errorState = feedCubit!.state as FeedStateError; - expect(errorState.errorMessage, contains('Exception: Network error')); - }); - - test('loadNextPage - загружает следующую страницу', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - final firstPageSettings = FeedBlogPostSettingsModel( - feedBlogPostList: [testBlogPost], - meta: const Meta(currentPage: 1, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null), - ); - - final secondPageSettings = FeedBlogPostSettingsModel( - feedBlogPostList: [testBlogPost2], - meta: const Meta(currentPage: 2, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null), - ); - - when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => firstPageSettings); - - await feedCubit!.loadFeedBlogPostList(); - await Future.delayed(const Duration(milliseconds: 100)); - - when(mockFeedRepository.getFeedBlogPosts(page: 2, query: null)).thenAnswer((_) async => secondPageSettings); - - await feedCubit!.loadNextPage(); - await Future.delayed(const Duration(milliseconds: 100)); - - expect(feedCubit!.state, isA()); - final loadedState = feedCubit!.state as FeedStateLoaded; - expect(loadedState.feedBlogPostList.length, 2); - expect(feedCubit!.currentPage, 2); - expect(loadedState.isLoading, false); - verify(mockFeedRepository.getFeedBlogPosts(page: 2, query: null)).called(1); - }); - - test('loadNextPage - не загружает если уже на последней странице', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - final settings = FeedBlogPostSettingsModel( - feedBlogPostList: [testBlogPost], - meta: const Meta(currentPage: 1, lastPage: 1, total: 1, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null), - ); - - when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => settings); - - await feedCubit!.loadFeedBlogPostList(); - await Future.delayed(const Duration(milliseconds: 100)); - - await feedCubit!.loadNextPage(); - await Future.delayed(const Duration(milliseconds: 100)); - - verifyNever(mockFeedRepository.getFeedBlogPosts(page: 2, query: anyNamed('query'))); - }); - - test('loadNextPage - не загружает если уже загружается', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - final settings = FeedBlogPostSettingsModel( - feedBlogPostList: [testBlogPost], - meta: const Meta(currentPage: 1, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null), - ); - - when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => settings); - - await feedCubit!.loadFeedBlogPostList(); - await Future.delayed(const Duration(milliseconds: 100)); - - feedCubit!.isLoading = true; - - await feedCubit!.loadNextPage(); - await Future.delayed(const Duration(milliseconds: 100)); - - verifyNever(mockFeedRepository.getFeedBlogPosts(page: 2, query: anyNamed('query'))); - }); - - test('loadNextPage - обрабатывает ошибку при загрузке следующей страницы', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - final firstPageSettings = FeedBlogPostSettingsModel( - feedBlogPostList: [testBlogPost], - meta: const Meta(currentPage: 1, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null), - ); - - when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => firstPageSettings); - - await feedCubit!.loadFeedBlogPostList(); - await Future.delayed(const Duration(milliseconds: 100)); - - final exception = Exception('Network error'); - when(mockFeedRepository.getFeedBlogPosts(page: 2, query: null)).thenThrow(exception); - - await feedCubit!.loadNextPage(); - await Future.delayed(const Duration(milliseconds: 100)); - - expect(feedCubit!.state, isA()); - final errorState = feedCubit!.state as FeedStateError; - expect(errorState.errorMessage, contains('Exception: Network error')); - }); - - test('loadNextPage - сохраняет query при загрузке следующей страницы', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - const query = 'test query'; - final firstPageSettings = FeedBlogPostSettingsModel( - feedBlogPostList: [testBlogPost], - meta: const Meta(currentPage: 1, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null), - ); - - final secondPageSettings = FeedBlogPostSettingsModel( - feedBlogPostList: [testBlogPost2], - meta: const Meta(currentPage: 2, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null), - ); - - when(mockFeedRepository.getFeedBlogPosts(query: query)).thenAnswer((_) async => firstPageSettings); - - await feedCubit!.loadFeedBlogPostList(query: query); - await Future.delayed(const Duration(milliseconds: 100)); - - when(mockFeedRepository.getFeedBlogPosts(page: 2, query: query)).thenAnswer((_) async => secondPageSettings); - - await feedCubit!.loadNextPage(); - await Future.delayed(const Duration(milliseconds: 100)); - - verify(mockFeedRepository.getFeedBlogPosts(page: 2, query: query)).called(1); - }); - - test('updateFeedBlogPostList - обновляет лайк поста', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - final settings = FeedBlogPostSettingsModel( - feedBlogPostList: [testBlogPost], - meta: const Meta(currentPage: 1, lastPage: 1, total: 1, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null), - ); - - when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => settings); - - await feedCubit!.loadFeedBlogPostList(); - await Future.delayed(const Duration(milliseconds: 100)); - - feedCubit!.updateFeedBlogPostList(testBlogPost, true); - await Future.delayed(const Duration(milliseconds: 100)); - - expect(feedCubit!.state, isA()); - final loadedState = feedCubit!.state as FeedStateLoaded; - expect(loadedState.feedBlogPostList.first.wasLikedByMe, true); - expect(loadedState.feedBlogPostList.first.totalLikes, 11); - }); - - test('updateFeedBlogPostList - убирает лайк поста', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - final settings = FeedBlogPostSettingsModel( - feedBlogPostList: [testBlogPost2], - meta: const Meta(currentPage: 1, lastPage: 1, total: 1, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null), - ); - - when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => settings); - - await feedCubit!.loadFeedBlogPostList(); - await Future.delayed(const Duration(milliseconds: 100)); - - feedCubit!.updateFeedBlogPostList(testBlogPost2, false); - await Future.delayed(const Duration(milliseconds: 100)); - - expect(feedCubit!.state, isA()); - final loadedState = feedCubit!.state as FeedStateLoaded; - expect(loadedState.feedBlogPostList.first.wasLikedByMe, false); - expect(loadedState.feedBlogPostList.first.totalLikes, 19); - }); - - test('updateFeedBlogPostList - не обновляет если пост не найден', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - final settings = FeedBlogPostSettingsModel( - feedBlogPostList: [testBlogPost], - meta: const Meta(currentPage: 1, lastPage: 1, total: 1, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null), - ); - - when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => settings); - - await feedCubit!.loadFeedBlogPostList(); - await Future.delayed(const Duration(milliseconds: 100)); - - final nonExistentPost = testBlogPost.copyWith(id: 999); - final initialState = feedCubit!.state; - feedCubit!.updateFeedBlogPostList(nonExistentPost, true); - await Future.delayed(const Duration(milliseconds: 100)); - - expect(feedCubit!.state, initialState); - }); - - test('updateFeedBlogPostList - не обновляет если state не loaded', () { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - feedCubit!.updateFeedBlogPostList(testBlogPost, true); - expect(feedCubit!.state, const FeedState.loading()); - }); - - test('updateFeedBlogPostList - не обновляет если post null', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - final settings = FeedBlogPostSettingsModel( - feedBlogPostList: [testBlogPost], - meta: const Meta(currentPage: 1, lastPage: 1, total: 1, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null), - ); - - when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => settings); - - await feedCubit!.loadFeedBlogPostList(); - await Future.delayed(const Duration(milliseconds: 100)); - - final initialState = feedCubit!.state; - feedCubit!.updateFeedBlogPostList(null, true); - await Future.delayed(const Duration(milliseconds: 100)); - - expect(feedCubit!.state, initialState); - }); - - test('likePost - лайкает пост', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - const postId = 1; - - when(mockFeedRepository.likeBlogPost(id: postId)).thenAnswer((_) async => Future.value()); - - await feedCubit!.likePost(postId); - - verify(mockFeedRepository.likeBlogPost(id: postId)).called(1); - }); - - test('likePost - обрабатывает ошибку без выбрасывания', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - const postId = 1; - final exception = Exception('Network error'); - - when(mockFeedRepository.likeBlogPost(id: postId)).thenThrow(exception); - - await feedCubit!.likePost(postId); - - verify(mockFeedRepository.likeBlogPost(id: postId)).called(1); - }); - - test('unlikedPost - убирает лайк с поста', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - const postId = 1; - - when(mockFeedRepository.unlikedBlogPost(id: postId)).thenAnswer((_) async => Future.value()); - - await feedCubit!.unlikedPost(postId); - - verify(mockFeedRepository.unlikedBlogPost(id: postId)).called(1); - }); - - test('viewPost - отправляет просмотр поста', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - const postId = 1; - - when(mockFeedRepository.viewPost(postId)).thenAnswer((_) async => Future.value()); - - await feedCubit!.viewPost(postId); - - verify(mockFeedRepository.viewPost(postId)).called(1); - }); - - test('viewRecipe - отправляет просмотр рецепта', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - const slug = 'recipe-1'; - - when(mockFeedRepository.viewRecipe(slug)).thenAnswer((_) async => Future.value()); - - await feedCubit!.viewRecipe(slug); - - verify(mockFeedRepository.viewRecipe(slug)).called(1); - }); - - test('viewDiet - отправляет просмотр диеты', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - const dietId = 2; - - when(mockFeedRepository.viewDiet(dietId)).thenAnswer((_) async => Future.value()); - - await feedCubit!.viewDiet(dietId); - - verify(mockFeedRepository.viewDiet(dietId)).called(1); - }); - - test('viewPlan - отправляет просмотр плана', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - const slug = 'plan-1'; - - when(mockFeedRepository.viewPlan(slug)).thenAnswer((_) async => Future.value()); - - await feedCubit!.viewPlan(slug); - - verify(mockFeedRepository.viewPlan(slug)).called(1); + final loaded = cubit.state as FeedStateLoaded; + expect(loaded.feedBlogPostList, hasLength(2)); + verify(repository.getFeed(search: null, cursor: 'next', currentUserId: null)).called(1); }); }); } diff --git a/test/features/feed/presentation/widgets/basic_widget_test.dart b/test/features/feed/presentation/widgets/basic_widget_test.dart index 16e18b6e..b9d7a80b 100644 --- a/test/features/feed/presentation/widgets/basic_widget_test.dart +++ b/test/features/feed/presentation/widgets/basic_widget_test.dart @@ -1,12 +1,10 @@ import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_initializer.dart'; import 'package:eatvio_app/core/locator_service.dart'; -import 'package:eatvio_app/core/models/pagination_model.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/routing/route_constants.dart'; import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; -import 'package:eatvio_app/features/feed/models/feed_blog_post_settings_model.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart'; import 'package:eatvio_app/features/feed/presentation/widgets/blog_posts.dart'; @@ -378,12 +376,8 @@ void main() { // Arrange feedCubit.emit(FeedState.loaded(feedBlogPostList: [testBlogPost], isLoading: false)); - when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer( - (_) async => FeedBlogPostSettingsModel( - feedBlogPostList: [testBlogPost, testBlogPost2], - meta: const Meta(currentPage: 1, lastPage: 1, total: 2, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null), - ), - ); + when(mockFeedRepository.getFeed(search: null, cursor: null, currentUserId: null)) + .thenAnswer((_) async => (posts: [testBlogPost, testBlogPost2], nextCursor: null)); final widget = BlogPosts(handleVisibility: ({required visibleFraction, required id, required type}) {}); diff --git a/test/features/feed/presentation/widgets/blog_post_widget_test.dart b/test/features/feed/presentation/widgets/blog_post_widget_test.dart index 3571cf2b..ffc2b581 100644 --- a/test/features/feed/presentation/widgets/blog_post_widget_test.dart +++ b/test/features/feed/presentation/widgets/blog_post_widget_test.dart @@ -81,7 +81,7 @@ void main() { when(cubit.stream).thenAnswer((_) => Stream.value(state)); when(cubit.close()).thenAnswer((_) async => Future.value()); - await tester.pumpWidget(BlocProvider.value(value: cubit, child: wrap(const BlogPostWidget(index: 0)))); + await tester.pumpWidget(BlocProvider.value(value: cubit, child: wrap(BlogPostWidget(post: mockBlogPost)))); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); diff --git a/test/features/feed/repositories/feed_repository_test.dart b/test/features/feed/repositories/feed_repository_test.dart index d2a56a55..9165ba3a 100644 --- a/test/features/feed/repositories/feed_repository_test.dart +++ b/test/features/feed/repositories/feed_repository_test.dart @@ -1,8 +1,10 @@ import 'package:eatvio_app/core/models/pagination_model.dart'; +import 'package:eatvio_app/core/app_config.dart'; +import 'package:eatvio_app/core/app_initializer.dart'; +import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/features/feed/models/comment_model.dart'; import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; -import 'package:eatvio_app/features/feed/models/feed_blog_post_settings_model.dart'; import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart'; import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/recipes_model.dart'; @@ -11,9 +13,11 @@ import 'package:eatvio_app/features/feed/models/view_model.dart'; import 'package:eatvio_app/features/feed/repositories/feed_repository.dart'; import 'package:eatvio_app/features/feed/services/feed_service.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; +import '../../../fixtures/feed_fixtures.dart'; import '../../../fixtures/fixture_reader.dart'; import 'feed_repository_test.mocks.dart'; @@ -23,72 +27,57 @@ void main() { late FeedRepository feedRepository; late MockFeedService mockFeedService; - setUp(() { + setUp(() async { + dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); + await sl.reset(); + sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); mockFeedService = MockFeedService(); feedRepository = FeedRepository(feedService: mockFeedService); }); - test('getFeedBlogPosts - успешное получение постов', () async { - const expectedModel = FeedBlogPostSettingsModel( - feedBlogPostList: [], - meta: Meta(currentPage: 1, from: null, lastPage: 1, links: [], path: null, perPage: 10, to: null, total: 0, totalMinPrice: null, totalMaxPrice: null), - ); + test('getFeed - успешное получение постов', () async { + final expectedModel = FeedFixtures.postFeedPaginationModel(); - when(mockFeedService.getBlogPostList(page: 1, query: null)).thenAnswer((_) async => expectedModel); + when(mockFeedService.getFeed(search: null, cursor: null)).thenAnswer((_) async => expectedModel); - final result = await feedRepository.getFeedBlogPosts(); + final result = await feedRepository.getFeed(); - expect(result, isA()); - verify(mockFeedService.getBlogPostList(page: 1, query: null)).called(1); + expect(result.posts, hasLength(3)); + expect(result.posts.first.id, 1); + expect(result.posts[1].videoEmbedUrl, 'https://www.youtube.com/embed/dQw4w9WgXcQ'); + expect(result.nextCursor, 'eyJpZCI6MTAzfQ'); + verify(mockFeedService.getFeed(search: null, cursor: null)).called(1); }); - test('getFeedBlogPosts - с запросом и страницей', () async { + test('getFeed - с запросом и курсором', () async { const query = 'тест'; - const page = 2; - const expectedModel = FeedBlogPostSettingsModel( - feedBlogPostList: [], - meta: Meta( - currentPage: page, - from: null, - lastPage: 2, - links: [], - path: null, - perPage: 10, - to: null, - total: 15, - totalMinPrice: null, - totalMaxPrice: null, - ), - ); + const cursor = 'eyJpZCI6MTAzfQ'; + final expectedModel = FeedFixtures.postFeedNextPagePaginationModel(); - when(mockFeedService.getBlogPostList(page: page, query: query)).thenAnswer((_) async => expectedModel); + when(mockFeedService.getFeed(search: query, cursor: cursor)).thenAnswer((_) async => expectedModel); - final result = await feedRepository.getFeedBlogPosts(query: query, page: page); + final result = await feedRepository.getFeed(search: query, cursor: cursor); - expect(result, isA()); - verify(mockFeedService.getBlogPostList(page: page, query: query)).called(1); + expect(result.posts, hasLength(1)); + expect(result.posts.first.id, 4); + expect(result.nextCursor, isNull); + verify(mockFeedService.getFeed(search: query, cursor: cursor)).called(1); }); - test('getFeedBlogPosts - возвращает список постов без query', () async { - const page = 1; - const expectedSettings = FeedBlogPostSettingsModel( - feedBlogPostList: [], - meta: Meta(currentPage: 1, lastPage: 1, total: 0, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null), - ); + test('getFeed - пустая лента', () async { + final expectedModel = FeedFixtures.postFeedEmptyPaginationModel(); - when(mockFeedService.getBlogPostList(page: page, query: null)).thenAnswer((_) async => expectedSettings); + when(mockFeedService.getFeed(search: null, cursor: null)).thenAnswer((_) async => expectedModel); - final result = await feedRepository.getFeedBlogPosts(page: page); + final result = await feedRepository.getFeed(); - expect(result, isA()); - expect(result.feedBlogPostList.isEmpty, true); - verify(mockFeedService.getBlogPostList(page: page, query: null)).called(1); + expect(result.posts, isEmpty); + expect(result.nextCursor, isNull); }); test('getBlogPost - успешное получение поста', () async { const postId = 1; - final json = FixtureReader.readAsMap('feed/feed_blog_post_response.json'); - final expectedPost = FeedBlogPostModel.fromJson(json); + final expectedPost = FeedFixtures.feedBlogPostModel(); when(mockFeedService.getBlogPost(postId: postId)).thenAnswer((_) async => expectedPost); @@ -529,10 +518,10 @@ void main() { verify(mockFeedService.viewContent(viewModel: viewModel)).called(1); }); - test('getFeedBlogPosts - пробрасывает исключение', () async { - when(mockFeedService.getBlogPostList(page: 1, query: null)).thenThrow(Exception('Ошибка сети')); + test('getFeed - пробрасывает исключение', () async { + when(mockFeedService.getFeed(search: null, cursor: null)).thenThrow(Exception('Ошибка сети')); - expect(() => feedRepository.getFeedBlogPosts(), throwsException); + expect(() => feedRepository.getFeed(), throwsException); }); }); } diff --git a/test/features/feed/services/feed_service_test.dart b/test/features/feed/services/feed_service_test.dart index e8832ec5..1c1ec3c1 100644 --- a/test/features/feed/services/feed_service_test.dart +++ b/test/features/feed/services/feed_service_test.dart @@ -13,6 +13,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; +import '../../../fixtures/feed_fixtures.dart'; import 'feed_service_test.mocks.dart'; @GenerateNiceMocks([MockSpec()]) @@ -55,6 +56,32 @@ void main() { verify(mockFeedService.getBlogPostList(page: page, query: null)).called(1); }); + test('getFeed returns cursor response', () async { + final expected = FeedFixtures.postFeedPaginationModel(); + + when(mockFeedService.getFeed(search: null, cursor: null)).thenAnswer((_) async => expected); + + final result = await mockFeedService.getFeed(search: null, cursor: null); + + expect(result.data, hasLength(3)); + expect(result.meta.perPage, 20); + expect(result.meta.nextCursor, 'eyJpZCI6MTAzfQ'); + verify(mockFeedService.getFeed(search: null, cursor: null)).called(1); + }); + + test('getFeed returns next page without cursor', () async { + const cursor = 'eyJpZCI6MTAzfQ'; + final expected = FeedFixtures.postFeedNextPagePaginationModel(); + + when(mockFeedService.getFeed(search: null, cursor: cursor)).thenAnswer((_) async => expected); + + final result = await mockFeedService.getFeed(search: null, cursor: cursor); + + expect(result.data, hasLength(1)); + expect(result.meta.nextCursor, isNull); + verify(mockFeedService.getFeed(search: null, cursor: cursor)).called(1); + }); + test('getBlogPost returns post', () async { const id = 10; final expected = FeedBlogPostModel( diff --git a/test/fixtures/feed/post_feed_empty_response.json b/test/fixtures/feed/post_feed_empty_response.json new file mode 100644 index 00000000..872f387b --- /dev/null +++ b/test/fixtures/feed/post_feed_empty_response.json @@ -0,0 +1,15 @@ +{ + "data": [], + "links": { + "first": null, + "last": null, + "prev": null, + "next": null + }, + "meta": { + "path": "/api/feed", + "per_page": 20, + "next_cursor": null, + "prev_cursor": null + } +} diff --git a/test/fixtures/feed/post_feed_next_page_response.json b/test/fixtures/feed/post_feed_next_page_response.json new file mode 100644 index 00000000..daf2b0d5 --- /dev/null +++ b/test/fixtures/feed/post_feed_next_page_response.json @@ -0,0 +1,45 @@ +{ + "data": [ + { + "id": 104, + "post_id": 4, + "post_author_id": 13, + "post_author_name": "Олег Кузнецов", + "post_author_image_url": "/storage/avatars/oleg.png", + "post_author_profile_url": "/ru/profiles/oleg-kuznetsov", + "post_created_at": "2024-06-04T11:00:00.000000Z", + "post_title": "Ужин за 20 минут", + "post_short_description": "Быстрый ужин после работы", + "post_text_md": "Курица с овощами на сковороде.", + "post_image_url": "/storage/posts/dinner.jpg", + "images": [], + "video_embed_url": null, + "post_views_count": 88, + "post_likes_count": 7, + "post_comments_count": 2, + "post_is_liked_by_user": false, + "is_entity_subscribed_by_me": false, + "post_url": "/ru/blog_post/4", + "video_url_thumb": null, + "post_state": "active", + "post_author_detailz_json": { + "slug": "oleg-kuznetsov", + "is_consultant": false + }, + "created_at": "2024-06-04T11:05:00.000000Z", + "updated_at": "2024-06-04T11:05:00.000000Z" + } + ], + "links": { + "first": null, + "last": null, + "prev": "https://example.com/api/feed?cursor=eyJpZCI6MTAzfQ", + "next": null + }, + "meta": { + "path": "/api/feed", + "per_page": 20, + "next_cursor": null, + "prev_cursor": "eyJpZCI6MTAzfQ" + } +} diff --git a/test/fixtures/feed/post_feed_response.json b/test/fixtures/feed/post_feed_response.json new file mode 100644 index 00000000..95676951 --- /dev/null +++ b/test/fixtures/feed/post_feed_response.json @@ -0,0 +1,115 @@ +{ + "data": [ + { + "id": 101, + "post_id": 1, + "post_author_id": 10, + "post_author_name": "Анна Иванова", + "post_author_image_url": "/storage/avatars/anna.png", + "post_author_profile_url": "/ru/profiles/anna-ivanova", + "post_created_at": "2024-06-01T10:00:00.000000Z", + "post_title": "Завтрак с овсянкой", + "post_short_description": "Простой и полезный завтрак", + "post_text_md": "Сегодня приготовила **овсянку** с ягодами.", + "post_image_url": "/storage/posts/oatmeal.jpg", + "images": [ + { + "url": "/storage/posts/oatmeal-1.jpg", + "orientation": "horizontal" + }, + { + "url": "/storage/posts/oatmeal-2.jpg", + "orientation": "vertical" + } + ], + "video_embed_url": "https://rutube.ru/play/embed/08c00eb3a54c056aeca34f58d8fd1db8/", + "post_views_count": 120, + "post_likes_count": 15, + "post_comments_count": 4, + "post_is_liked_by_user": false, + "is_entity_subscribed_by_me": false, + "post_url": "/ru/blog_post/1", + "post_state": "active", + "video_url_thumb": "https://mike-dev-ach5iekohk4n.eatv.io/storage/3409/20260720-96266cae-5852-40c9-9692-0a4047e8c934.png", + "post_author_detailz_json": { + "slug": "anna-ivanova", + "is_consultant": true + }, + "created_at": "2024-06-01T10:05:00.000000Z", + "updated_at": "2024-06-01T10:05:00.000000Z" + }, + { + "id": 102, + "post_id": 2, + "post_author_id": 11, + "post_author_name": "Игорь Петров", + "post_author_image_url": "/storage/avatars/igor.png", + "post_author_profile_url": "/ru/profiles/igor-petrov", + "post_created_at": "2024-06-02T08:30:00.000000Z", + "post_title": "Тренировка дома", + "post_short_description": "Короткая тренировка без оборудования", + "post_text_md": "15 минут кардио и растяжка.", + "post_image_url": "/storage/posts/workout.jpg", + "images": [], + "video_embed_url": "https://www.youtube.com/embed/dQw4w9WgXcQ", + "post_views_count": 340, + "post_likes_count": 42, + "post_comments_count": 9, + "post_is_liked_by_user": true, + "is_entity_subscribed_by_me": true, + "post_url": "/ru/blog_post/2", + "post_state": "active", + "post_author_detailz_json": { + "slug": "igor-petrov", + "is_consultant": false + }, + "created_at": "2024-06-02T08:35:00.000000Z", + "updated_at": "2024-06-02T09:00:00.000000Z" + }, + { + "id": 103, + "post_id": 3, + "post_author_id": 12, + "post_author_name": "Мария Смирнова", + "post_author_image_url": null, + "post_author_profile_url": "/ru/profiles/maria-smirnova", + "post_created_at": "2024-06-03T14:15:00.000000Z", + "post_title": "Рецепт смузи", + "post_short_description": null, + "post_text_md": null, + "post_image_url": null, + "images": [ + { + "url": "/storage/posts/smoothie.jpg", + "orientation": "horizontal" + } + ], + "video_embed_url": "https://rutube.ru/play/embed/6a62f217aa99daf00af8fa6cebebf218/", + "post_views_count": 50, + "post_likes_count": 3, + "post_comments_count": 1, + "post_is_liked_by_user": false, + "is_entity_subscribed_by_me": null, + "post_url": "/ru/blog_post/3", + "post_state": "active", + "post_author_detailz_json": { + "slug": "maria-smirnova", + "is_consultant": false + }, + "created_at": "2024-06-03T14:20:00.000000Z", + "updated_at": "2024-06-03T14:20:00.000000Z" + } + ], + "links": { + "first": null, + "last": null, + "prev": null, + "next": "https://example.com/api/feed?cursor=eyJpZCI6MTAzfQ" + }, + "meta": { + "path": "/api/feed", + "per_page": 20, + "next_cursor": "eyJpZCI6MTAzfQ", + "prev_cursor": null + } +} diff --git a/test/fixtures/feed_fixtures.dart b/test/fixtures/feed_fixtures.dart new file mode 100644 index 00000000..3084a69b --- /dev/null +++ b/test/fixtures/feed_fixtures.dart @@ -0,0 +1,51 @@ +import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; +import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart'; +import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart'; + +import 'fixture_reader.dart'; + +class FeedFixtures { + FeedFixtures._(); + + static Map postFeedResponseJson() { + return FixtureReader.readAsMap('feed/post_feed_response.json'); + } + + static Map postFeedNextPageResponseJson() { + return FixtureReader.readAsMap('feed/post_feed_next_page_response.json'); + } + + static Map postFeedEmptyResponseJson() { + return FixtureReader.readAsMap('feed/post_feed_empty_response.json'); + } + + static Map feedBlogPostResponseJson() { + return FixtureReader.readAsMap('feed/feed_blog_post_response.json'); + } + + static PostFeedPaginationModel postFeedPaginationModel() { + return PostFeedPaginationModel.fromJson(postFeedResponseJson()); + } + + static PostFeedPaginationModel postFeedNextPagePaginationModel() { + return PostFeedPaginationModel.fromJson(postFeedNextPageResponseJson()); + } + + static PostFeedPaginationModel postFeedEmptyPaginationModel() { + return PostFeedPaginationModel.fromJson(postFeedEmptyResponseJson()); + } + + static PostFeedItemModel postFeedItem({int index = 0}) { + return postFeedPaginationModel().data[index]; + } + + static List mappedFeedPosts({int? currentUserId}) { + return postFeedPaginationModel().data + .map((item) => PostFeedMapper.fromPostFeedItem(item, currentUserId: currentUserId)) + .toList(); + } + + static FeedBlogPostModel feedBlogPostModel() { + return FeedBlogPostModel.fromJson(feedBlogPostResponseJson()); + } +} -- 2.49.1 From 9c5c6df751d39919b242adadba569cf3a01d2a29 Mon Sep 17 00:00:00 2001 From: Anatoliy Poloz Date: Mon, 20 Jul 2026 18:15:25 +0300 Subject: [PATCH 58/88] refactor(video,webview): webview lib replaced on feed --- .kilocode/rules/memory-bank/context.md | 15 +- .../widgets/feed_video_player_widget.dart | 172 +++++++++++------- pubspec.yaml | 25 ++- 3 files changed, 130 insertions(+), 82 deletions(-) diff --git a/.kilocode/rules/memory-bank/context.md b/.kilocode/rules/memory-bank/context.md index 0bde3ccf..ff45966e 100644 --- a/.kilocode/rules/memory-bank/context.md +++ b/.kilocode/rules/memory-bank/context.md @@ -1,7 +1,7 @@ # Current Context ## Project Status -Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.3.0+79) currently in production. +Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.3.24+106) currently in production. ## Recent Activity - Memory Bank initialization completed @@ -9,10 +9,19 @@ Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1. - Architecture and technology stack catalogued ## Current Focus -Ready for development tasks. No active work in progress. +Виджет видеоплеера ленты (`lib/features/feed/presentation/widgets/feed_video_player_widget.dart`) +мигрирован с `webview_flutter` v4 на `flutter_inappwebview` 6.1.5. Цель — фикс +fullscreen-кнопки внутри embed-плеера (Rutube/YouTube): `webview_flutter` не реализует +Android `WebChromeClient.onShowCustomView/onHideCustomView`, а `flutter_inappwebview` +делает это нативно (`InAppWebViewChromeClient`). Паритет UX сохранён (превью → play → +скрытая загрузка → fade-in по Rutube `player:canPlay`, плюс страховочный таймер 4с +для провайдеров без этого протокола). Добавлена ориентация landscape на fullscreen. +Остальные 4 экрана на `webview_flutter` не трогались; обе зависимости в `pubspec.yaml`. ## Known Issues -None documented at initialization. +- 2 теста в `test/features/feed/` (`post_feed_pagination_model_test`, + `post_feed_mapper_test`) падают на ветке `personalized_feed` — пре-существующие, + связаны с fixtures/маппингом, не с видеоплеером. ## Next Steps Awaiting user direction for: diff --git a/lib/features/feed/presentation/widgets/feed_video_player_widget.dart b/lib/features/feed/presentation/widgets/feed_video_player_widget.dart index 701988a9..29e85300 100644 --- a/lib/features/feed/presentation/widgets/feed_video_player_widget.dart +++ b/lib/features/feed/presentation/widgets/feed_video_player_widget.dart @@ -5,15 +5,18 @@ import 'dart:developer'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:webview_flutter/webview_flutter.dart'; -import 'package:webview_flutter_android/webview_flutter_android.dart'; -import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; /// Embed-видео в ленте. /// /// 1) Пока пользователь не нажал play — только превью (без WebView). /// 2) После play — превью + лоадер, WebView грузится скрыто. /// 3) Когда embed готов — показываем WebView и запускаем воспроизведение. +/// +/// Реализовано на `flutter_inappwebview` — в отличие от `webview_flutter` v4 +/// нативно поддерживает fullscreen внутри embed-плеера (Rutube/YouTube): +/// Android `WebChromeClient.onShowCustomView/onHideCustomView`. class FeedVideoPlayer extends StatefulWidget { const FeedVideoPlayer({super.key, required this.embedUrl, this.videoUrlThumb}); @@ -28,12 +31,17 @@ class FeedVideoPlayer extends StatefulWidget { class _FeedVideoPlayerState extends State { static const _readyChannel = 'EmbedReady'; + static const Duration _readyFallbackDelay = Duration(seconds: 4); - WebViewController? _controller; + /// Пока null — WebView не рендерится. Заполняется по жесту play. + InAppWebViewInitialData? _initialData; bool _isLoading = false; bool _isReady = false; bool _hasError = false; + /// Страховка готовности для провайдеров без Rutube-протокола `player:canPlay`. + Timer? _readyFallback; + @override void didUpdateWidget(covariant FeedVideoPlayer oldWidget) { super.didUpdateWidget(oldWidget); @@ -44,12 +52,14 @@ class _FeedVideoPlayerState extends State { @override void dispose() { - _controller = null; + _readyFallback?.cancel(); super.dispose(); } void _reset() { - _controller = null; + _readyFallback?.cancel(); + _readyFallback = null; + _initialData = null; _isLoading = false; _isReady = false; _hasError = false; @@ -82,23 +92,24 @@ class _FeedVideoPlayerState extends State { @@ -108,12 +119,25 @@ class _FeedVideoPlayerState extends State { void _markReady() { if (!mounted || _isReady || _hasError) return; + _readyFallback?.cancel(); + _readyFallback = null; setState(() { _isReady = true; _isLoading = false; }); } + void _markError() { + if (!mounted || _isReady) return; + _readyFallback?.cancel(); + _readyFallback = null; + setState(() { + _hasError = true; + _isLoading = false; + _initialData = null; + }); + } + Future _onPlayPressed() async { if (_isLoading || _isReady || !mounted) return; @@ -126,62 +150,68 @@ class _FeedVideoPlayerState extends State { final embedUri = _embedUriForPlayback(widget.embedUrl); final baseUrl = '${embedUri.scheme}://${embedUri.host}/'; - late final PlatformWebViewControllerCreationParams params; - if (WebViewPlatform.instance is WebKitWebViewPlatform) { - // Жест play уже был — разрешаем media без дополнительного тапа. - params = WebKitWebViewControllerCreationParams(allowsInlineMediaPlayback: true, mediaTypesRequiringUserAction: const {}); - } else { - params = const PlatformWebViewControllerCreationParams(); - } - - final controller = WebViewController.fromPlatformCreationParams(params) - ..setJavaScriptMode(JavaScriptMode.unrestricted) - ..setBackgroundColor(Colors.black) - ..addJavaScriptChannel(_readyChannel, onMessageReceived: (_) => _markReady()) - ..setOnConsoleMessage((message) { - log('FeedVideoPlayer console message: ${message.message}'); - }) - ..setNavigationDelegate( - NavigationDelegate( - onWebResourceError: (error) { - if (!mounted) return; - if (error.isForMainFrame != true) return; - if (error.description.contains('ERR_BLOCKED_BY_ORB')) return; - if (_isReady) return; - - setState(() { - _hasError = true; - _isLoading = false; - _controller = null; - }); - }, - ), - ); - - final platform = controller.platform; - if (platform is AndroidWebViewController) { - // Жест уже был на кнопке play. - await platform.setMediaPlaybackRequiresUserGesture(false); - } - - await controller.loadHtmlString(_embedHtml(embedUri.toString()), baseUrl: baseUrl); - if (!mounted) return; - setState(() => _controller = controller); + setState(() { + _initialData = InAppWebViewInitialData( + data: _embedHtml(embedUri.toString()), + mimeType: 'text/html', + encoding: 'utf8', + baseUrl: WebUri(baseUrl), + ); + }); } catch (e, st) { if (kDebugMode) { debugPrint('FeedVideoPlayer load failed: $e\n$st'); } - if (mounted) { - setState(() { - _hasError = true; - _isLoading = false; - _controller = null; - }); - } + _markError(); } } + InAppWebViewSettings get _settings => InAppWebViewSettings( + // Жест play уже был на кнопке — разрешаем старта без доп. тапа. + mediaPlaybackRequiresUserGesture: false, + allowsInlineMediaPlayback: true, + iframeAllow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen', + iframeAllowFullscreen: true, + transparentBackground: false, + ); + + void _onWebViewCreated(InAppWebViewController controller) { + controller.addJavaScriptHandler(handlerName: _readyChannel, callback: (_) => _markReady()); + } + + void _onLoadStop(InAppWebViewController _, WebUri? _) { + // Если провайдер не шлёт Rutube-протокол `player:canPlay` (напр. YouTube), + // покажем WebView по таймауту после полной загрузки. + if (_isReady || _hasError || !mounted) return; + _readyFallback?.cancel(); + _readyFallback = Timer(_readyFallbackDelay, () { + if (mounted && !_isReady && !_hasError) _markReady(); + }); + } + + void _onReceivedError(InAppWebViewController _, WebResourceRequest request, WebResourceError error) { + if (!mounted) return; + if (request.isForMainFrame != true) return; + // ERR_BLOCKED_BY_ORB — cross-origin блокировки subframe, не реальная ошибка плеера. + if (error.description.contains('ERR_BLOCKED_BY_ORB')) return; + if (_isReady) return; + if (kDebugMode) debugPrint('FeedVideoPlayer error: ${error.description}'); + _markError(); + } + + void _onEnterFullscreen(InAppWebViewController _) { + SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + } + + void _onExitFullscreen(InAppWebViewController _) { + // Возвращаем системный набор ориентаций (не блокируем глобально в portrait). + SystemChrome.setPreferredOrientations([]); + } + @override Widget build(BuildContext context) { final player = SizedBox( @@ -191,12 +221,22 @@ class _FeedVideoPlayerState extends State { fit: StackFit.expand, children: [ // WebView создаётся только после play и до ready остаётся скрытым под превью. - if (_controller != null) + if (_initialData != null) Opacity( opacity: _isReady ? 1 : 0, child: IgnorePointer( ignoring: !_isReady, - child: WebViewWidget(key: ValueKey('feed-webview-${widget.embedUrl}'), controller: _controller!), + child: InAppWebView( + key: ValueKey('feed-inappwebview-${widget.embedUrl}'), + initialData: _initialData, + initialSettings: _settings, + onWebViewCreated: _onWebViewCreated, + onConsoleMessage: (_, message) => log('FeedVideoPlayer console message: ${message.message}'), + onLoadStop: _onLoadStop, + onReceivedError: _onReceivedError, + onEnterFullscreen: _onEnterFullscreen, + onExitFullscreen: _onExitFullscreen, + ), ), ), if (!_isReady) diff --git a/pubspec.yaml b/pubspec.yaml index 8c44cc5a..aed9b3e0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: eatvio_app description: A new Flutter project. version: 1.3.24+106 -publish_to: 'none' +publish_to: "none" environment: sdk: ^3.10.4 @@ -82,12 +82,11 @@ dependencies: rxdart: ^0.27.7 gpassword: ^1.0.0 webview_flutter_platform_interface: ^2.14.0 + flutter_inappwebview: ^6.1.5 intl_utils: ^2.8.13 sentry_dio: ^9.10.0 livekit_client: ^2.6.1 sbp: 0.1.0 - webview_flutter_android: ^4.12.0 - webview_flutter_wkwebview: ^3.25.1 dev_dependencies: build_runner: ^2.10.4 @@ -167,14 +166,14 @@ flutter_intl: flutter_native_splash: android: true ios: true - color: '#FFFFFF' - image_android: 'assets/splash/splash.png' - image_dark_android: 'assets/splash/splash.png' - image_ios: 'assets/splash/splash.png' - image_dark_ios: 'assets/splash/splash.png' - branding_android: 'assets/splash/branding.png' - branding_dark_android: 'assets/splash/branding.png' + color: "#FFFFFF" + image_android: "assets/splash/splash.png" + image_dark_android: "assets/splash/splash.png" + image_ios: "assets/splash/splash.png" + image_dark_ios: "assets/splash/splash.png" + branding_android: "assets/splash/branding.png" + branding_dark_android: "assets/splash/branding.png" android_12: - image: 'assets/splash/splash.png' - image_dark: 'assets/splash/splash.png' - branding: 'assets/splash/branding.png' + image: "assets/splash/splash.png" + image_dark: "assets/splash/splash.png" + branding: "assets/splash/branding.png" -- 2.49.1 From f1abea70c5264835e41670305dd3a6c123e458cc Mon Sep 17 00:00:00 2001 From: HasanKulbuzhev Date: Tue, 21 Jul 2026 14:22:45 +0300 Subject: [PATCH 59/88] fix fixtures --- test/fixtures/feed/post_feed_next_page_response.json | 2 +- test/fixtures/feed/post_feed_response.json | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/test/fixtures/feed/post_feed_next_page_response.json b/test/fixtures/feed/post_feed_next_page_response.json index daf2b0d5..56603e76 100644 --- a/test/fixtures/feed/post_feed_next_page_response.json +++ b/test/fixtures/feed/post_feed_next_page_response.json @@ -22,7 +22,7 @@ "post_url": "/ru/blog_post/4", "video_url_thumb": null, "post_state": "active", - "post_author_detailz_json": { + "post_author_detailz": { "slug": "oleg-kuznetsov", "is_consultant": false }, diff --git a/test/fixtures/feed/post_feed_response.json b/test/fixtures/feed/post_feed_response.json index 95676951..77a103d4 100644 --- a/test/fixtures/feed/post_feed_response.json +++ b/test/fixtures/feed/post_feed_response.json @@ -22,7 +22,7 @@ "orientation": "vertical" } ], - "video_embed_url": "https://rutube.ru/play/embed/08c00eb3a54c056aeca34f58d8fd1db8/", + "video_embed_url": null, "post_views_count": 120, "post_likes_count": 15, "post_comments_count": 4, @@ -30,8 +30,8 @@ "is_entity_subscribed_by_me": false, "post_url": "/ru/blog_post/1", "post_state": "active", - "video_url_thumb": "https://mike-dev-ach5iekohk4n.eatv.io/storage/3409/20260720-96266cae-5852-40c9-9692-0a4047e8c934.png", - "post_author_detailz_json": { + "video_url_thumb": null, + "post_author_detailz": { "slug": "anna-ivanova", "is_consultant": true }, @@ -59,7 +59,7 @@ "is_entity_subscribed_by_me": true, "post_url": "/ru/blog_post/2", "post_state": "active", - "post_author_detailz_json": { + "post_author_detailz": { "slug": "igor-petrov", "is_consultant": false }, @@ -92,7 +92,7 @@ "is_entity_subscribed_by_me": null, "post_url": "/ru/blog_post/3", "post_state": "active", - "post_author_detailz_json": { + "post_author_detailz": { "slug": "maria-smirnova", "is_consultant": false }, -- 2.49.1 From 2dbd4a455f51d407ac4adeb215744c6e9aad100c Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 21 Jul 2026 15:15:28 +0300 Subject: [PATCH 60/88] feat(personalized_feed): added vk player state handling, separeted player logic --- .../widgets/blog_post_widget.dart | 113 +++++-- .../feed_video_player/feed_video_embed.dart | 70 ++++ .../feed_video_embed_scripts.dart | 70 ++++ .../feed_video_player_widget.dart | 213 ++++++++++++ .../feed_video_preview_placeholder.dart | 63 ++++ .../widgets/feed_video_player_widget.dart | 313 +----------------- 6 files changed, 499 insertions(+), 343 deletions(-) create mode 100644 lib/features/feed/presentation/widgets/feed_video_player/feed_video_embed.dart create mode 100644 lib/features/feed/presentation/widgets/feed_video_player/feed_video_embed_scripts.dart create mode 100644 lib/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart create mode 100644 lib/features/feed/presentation/widgets/feed_video_player/feed_video_preview_placeholder.dart diff --git a/lib/features/feed/presentation/widgets/blog_post_widget.dart b/lib/features/feed/presentation/widgets/blog_post_widget.dart index 7ad825a5..8751798b 100644 --- a/lib/features/feed/presentation/widgets/blog_post_widget.dart +++ b/lib/features/feed/presentation/widgets/blog_post_widget.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:cached_network_image/cached_network_image.dart'; import 'package:carousel_slider_plus/carousel_slider_plus.dart'; import 'package:dismissible_page/dismissible_page.dart'; @@ -13,7 +15,7 @@ import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.da import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; -import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player_widget.dart'; +import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart'; import 'package:eatvio_app/features/feed/presentation/widgets/subscribe_button_widget.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -218,7 +220,7 @@ class _BlogPostTextContent extends StatelessWidget { } } -class _BlogPostMediaContent extends StatelessWidget { +class _BlogPostMediaContent extends StatefulWidget { const _BlogPostMediaContent({ required this.galleryUrls, required this.imageUrl, @@ -237,61 +239,110 @@ class _BlogPostMediaContent extends StatelessWidget { final void Function(List urls, int index) onOpenGallery; final ValueChanged onGalleryChanged; + @override + State<_BlogPostMediaContent> createState() => _BlogPostMediaContentState(); +} + +class _BlogPostMediaContentState extends State<_BlogPostMediaContent> { + static const _counterDisplayDuration = Duration(seconds: 2); + + bool _isCounterVisible = false; + Timer? _counterHideTimer; + + @override + void dispose() { + _counterHideTimer?.cancel(); + super.dispose(); + } + + bool _handleCarouselScroll(ScrollNotification notification) { + if (notification is ScrollStartNotification || notification is ScrollUpdateNotification) { + _counterHideTimer?.cancel(); + if (!_isCounterVisible) { + setState(() => _isCounterVisible = true); + } + } else if (notification is ScrollEndNotification && _isCounterVisible) { + _counterHideTimer?.cancel(); + _counterHideTimer = Timer(_counterDisplayDuration, () { + if (mounted) { + setState(() => _isCounterVisible = false); + } + }); + } + return false; + } + @override Widget build(BuildContext context) { - final mediaContentCount = videoEmbedUrl != null ? galleryUrls.length + 1 : galleryUrls.length; + final mediaContentCount = widget.videoEmbedUrl != null ? widget.galleryUrls.length + 1 : widget.galleryUrls.length; return Column( children: [ - if (galleryUrls.length > 1) ...[ + if (widget.galleryUrls.length > 1) ...[ Stack( children: [ - CarouselSlider.builder( - itemCount: galleryUrls.length + 1, - itemBuilder: (context, itemIndex, _) { - if (itemIndex == galleryUrls.length) { - if (videoEmbedUrl != null) return FeedVideoPlayer(embedUrl: videoEmbedUrl!, videoUrlThumb: videoUrlThumb); - return const SizedBox.shrink(); - } - final itemUrl = galleryUrls[itemIndex]; - return GestureDetector( - onTap: () => onOpenGallery(galleryUrls, itemIndex), - child: CachedNetworkImage( - fit: BoxFit.cover, - width: MediaQuery.of(context).size.width, - imageUrl: itemUrl, - errorWidget: (context, str, object) => const SizedBox.shrink(), - ), - ); - }, - options: CarouselOptions(viewportFraction: 1, height: 260, enableInfiniteScroll: false, onPageChanged: (index, _) => onGalleryChanged(index)), + NotificationListener( + onNotification: _handleCarouselScroll, + child: CarouselSlider.builder( + itemCount: widget.galleryUrls.length + 1, + itemBuilder: (context, itemIndex, _) { + if (itemIndex == widget.galleryUrls.length) { + if (widget.videoEmbedUrl != null) { + return FeedVideoPlayer(embedUrl: widget.videoEmbedUrl!, videoUrlThumb: widget.videoUrlThumb); + } + return const SizedBox.shrink(); + } + final itemUrl = widget.galleryUrls[itemIndex]; + return GestureDetector( + onTap: () => widget.onOpenGallery(widget.galleryUrls, itemIndex), + child: CachedNetworkImage( + fit: BoxFit.cover, + width: MediaQuery.of(context).size.width, + imageUrl: itemUrl, + errorWidget: (context, str, object) => const SizedBox.shrink(), + ), + ); + }, + options: CarouselOptions( + viewportFraction: 1, + height: 260, + enableInfiniteScroll: false, + onPageChanged: (index, _) => widget.onGalleryChanged(index), + ), + ), ), Positioned( top: 12, right: 12, - child: _GalleryPhotoCounter(currentIndex: currentGalleryIndex, total: mediaContentCount), + child: IgnorePointer( + child: AnimatedOpacity( + opacity: _isCounterVisible ? 1 : 0, + duration: const Duration(milliseconds: 150), + child: _GalleryPhotoCounter(currentIndex: widget.currentGalleryIndex, total: mediaContentCount), + ), + ), ), ], ), - ] else if (galleryUrls.length == 1) ...[ + ] else if (widget.galleryUrls.length == 1) ...[ GestureDetector( - onTap: () => onOpenGallery(galleryUrls, 0), + onTap: () => widget.onOpenGallery(widget.galleryUrls, 0), child: CachedNetworkImage( fit: BoxFit.cover, width: MediaQuery.of(context).size.width, height: 260, - imageUrl: galleryUrls.first, + imageUrl: widget.galleryUrls.first, errorWidget: (context, str, object) => const SizedBox.shrink(), ), ), - ] else if (imageUrl.isNotEmpty) ...[ + ] else if (widget.imageUrl.isNotEmpty) ...[ GestureDetector( - onTap: () => onOpenGallery([imageUrl], 0), + onTap: () => widget.onOpenGallery([widget.imageUrl], 0), child: Hero( - tag: imageUrl, + tag: widget.imageUrl, child: CachedNetworkImage( fit: BoxFit.fitWidth, width: MediaQuery.of(context).size.width, - imageUrl: imageUrl, + imageUrl: widget.imageUrl, errorWidget: (context, str, object) => const SizedBox.shrink(), ), ), diff --git a/lib/features/feed/presentation/widgets/feed_video_player/feed_video_embed.dart b/lib/features/feed/presentation/widgets/feed_video_player/feed_video_embed.dart new file mode 100644 index 00000000..056d0e37 --- /dev/null +++ b/lib/features/feed/presentation/widgets/feed_video_player/feed_video_embed.dart @@ -0,0 +1,70 @@ +import 'dart:convert'; + +import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_embed_scripts.dart'; + +enum FeedVideoEmbedProvider { vk, other } + +/// Сборка URL и HTML для embed-плеера ленты. +abstract final class FeedVideoEmbed { + static FeedVideoEmbedProvider detectProvider(Uri uri) { + final host = uri.host.toLowerCase(); + final isVk = host == 'vk.com' || + host.endsWith('.vk.com') || + host == 'vk.ru' || + host.endsWith('.vk.ru') || + host == 'vkvideo.ru' || + host.endsWith('.vkvideo.ru'); + return isVk ? FeedVideoEmbedProvider.vk : FeedVideoEmbedProvider.other; + } + + /// После жеста пользователя включаем autoplay, чтобы стартнуть сразу после загрузки. + static Uri uriForPlayback(String url) { + final uri = Uri.parse(url); + final params = Map.from(uri.queryParameters); + params['autoplay'] = '1'; + params['mute'] = '1'; + params.putIfAbsent('playsinline', () => '1'); + + // VK Video JS API: без js_api=1 события/методы плеера недоступны. + if (detectProvider(uri) == FeedVideoEmbedProvider.vk) { + params['js_api'] = '1'; + } + + return uri.replace(queryParameters: params); + } + + static String buildHtml(String embedUrl) { + final uri = Uri.parse(embedUrl); + final isVk = detectProvider(uri) == FeedVideoEmbedProvider.vk; + final src = const HtmlEscape(HtmlEscapeMode.attribute).convert(embedUrl); + final headScripts = isVk ? '' : ''; + final bootstrap = FeedVideoEmbedScripts.bootstrap(includeVk: isVk); + + return ''' + + + + + + + $headScripts + + + + + + +'''; + } +} diff --git a/lib/features/feed/presentation/widgets/feed_video_player/feed_video_embed_scripts.dart b/lib/features/feed/presentation/widgets/feed_video_player/feed_video_embed_scripts.dart new file mode 100644 index 00000000..5d661bde --- /dev/null +++ b/lib/features/feed/presentation/widgets/feed_video_player/feed_video_embed_scripts.dart @@ -0,0 +1,70 @@ +/// JS-фрагменты для embed-плеера ленты. +/// +/// Канал связи с Flutter: [readyChannel] через `flutter_inappwebview.callHandler`. +abstract final class FeedVideoEmbedScripts { + static const readyChannel = 'EmbedReady'; + + /// Уведомляет Flutter, что embed готов к показу. + static String get notifyReady => + ''' + function notifyReady() { + try { + window.flutter_inappwebview.callHandler('$readyChannel', 'ready'); + } catch (e) {} + } + '''; + + /// Уведомляет Flutter об ошибке воспроизведения. + static String get notifyError => + ''' + function notifyError() { + try { + window.flutter_inappwebview.callHandler('$readyChannel', 'error'); + } catch (e) {} + } + '''; + + /// Rutube: готовность через postMessage `player:canPlay`. + static String get rutubeCanPlayListener => ''' + window.addEventListener('message', function (event) { + try { + var type = JSON.parse(event.data).type; + if (type === 'player:canPlay') { + notifyReady(); + } + } catch (e) {} + }); + '''; + + /// VK Video JS API: воспроизведение началось на событии `started`. + /// Требует `js_api=1` в URL iframe и скрипт `videoplayer.js`. + static String get vkPlayerReadyHandler => ''' + try { + if (typeof VK !== 'undefined' && VK.VideoPlayer) { + var vkPlayer = VK.VideoPlayer(document.getElementById('player')); + vkPlayer.on('started', function () { + notifyReady(); + }); + vkPlayer.on('error', function () { + notifyError(); + }); + } + } catch (e) {} + '''; + + /// Внешний скрипт VK VideoPlayer API. + static const vkVideoPlayerApiSrc = 'https://vk.ru/js/api/videoplayer.js'; + + /// Собирает inline ` - - -'''; - } - - void _markReady() { - if (!mounted || _isReady || _hasError) return; - _readyFallback?.cancel(); - _readyFallback = null; - setState(() { - _isReady = true; - _isLoading = false; - }); - } - - void _markError() { - if (!mounted || _isReady) return; - _readyFallback?.cancel(); - _readyFallback = null; - setState(() { - _hasError = true; - _isLoading = false; - _initialData = null; - }); - } - - Future _onPlayPressed() async { - if (_isLoading || _isReady || !mounted) return; - - setState(() { - _isLoading = true; - _hasError = false; - }); - - try { - final embedUri = _embedUriForPlayback(widget.embedUrl); - final baseUrl = '${embedUri.scheme}://${embedUri.host}/'; - - if (!mounted) return; - setState(() { - _initialData = InAppWebViewInitialData( - data: _embedHtml(embedUri.toString()), - mimeType: 'text/html', - encoding: 'utf8', - baseUrl: WebUri(baseUrl), - ); - }); - } catch (e, st) { - if (kDebugMode) { - debugPrint('FeedVideoPlayer load failed: $e\n$st'); - } - _markError(); - } - } - - InAppWebViewSettings get _settings => InAppWebViewSettings( - // Жест play уже был на кнопке — разрешаем старта без доп. тапа. - mediaPlaybackRequiresUserGesture: false, - allowsInlineMediaPlayback: true, - iframeAllow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen', - iframeAllowFullscreen: true, - transparentBackground: false, - ); - - void _onWebViewCreated(InAppWebViewController controller) { - controller.addJavaScriptHandler(handlerName: _readyChannel, callback: (_) => _markReady()); - } - - void _onLoadStop(InAppWebViewController _, WebUri? _) { - // Если провайдер не шлёт Rutube-протокол `player:canPlay` (напр. YouTube), - // покажем WebView по таймауту после полной загрузки. - if (_isReady || _hasError || !mounted) return; - _readyFallback?.cancel(); - _readyFallback = Timer(_readyFallbackDelay, () { - if (mounted && !_isReady && !_hasError) _markReady(); - }); - } - - void _onReceivedError(InAppWebViewController _, WebResourceRequest request, WebResourceError error) { - if (!mounted) return; - if (request.isForMainFrame != true) return; - // ERR_BLOCKED_BY_ORB — cross-origin блокировки subframe, не реальная ошибка плеера. - if (error.description.contains('ERR_BLOCKED_BY_ORB')) return; - if (_isReady) return; - if (kDebugMode) debugPrint('FeedVideoPlayer error: ${error.description}'); - _markError(); - } - - void _onEnterFullscreen(InAppWebViewController _) { - SystemChrome.setPreferredOrientations([ - DeviceOrientation.landscapeLeft, - DeviceOrientation.landscapeRight, - ]); - } - - void _onExitFullscreen(InAppWebViewController _) { - // Возвращаем системный набор ориентаций (не блокируем глобально в portrait). - SystemChrome.setPreferredOrientations([]); - } - - @override - Widget build(BuildContext context) { - final player = SizedBox( - height: 220, - width: MediaQuery.sizeOf(context).width, - child: Stack( - fit: StackFit.expand, - children: [ - // WebView создаётся только после play и до ready остаётся скрытым под превью. - if (_initialData != null) - Opacity( - opacity: _isReady ? 1 : 0, - child: IgnorePointer( - ignoring: !_isReady, - child: InAppWebView( - key: ValueKey('feed-inappwebview-${widget.embedUrl}'), - initialData: _initialData, - initialSettings: _settings, - onWebViewCreated: _onWebViewCreated, - onConsoleMessage: (_, message) => log('FeedVideoPlayer console message: ${message.message}'), - onLoadStop: _onLoadStop, - onReceivedError: _onReceivedError, - onEnterFullscreen: _onEnterFullscreen, - onExitFullscreen: _onExitFullscreen, - ), - ), - ), - if (!_isReady) - _VideoPreviewPlaceholder( - previewUrl: widget.videoUrlThumb, - isLoading: _isLoading, - hasError: _hasError, - onPlay: _onPlayPressed, - onRetry: _onPlayPressed, - ), - ], - ), - ); - - // Пока превью/лоадер — не даём InkWell карточки перехватывать тап. - if (_isReady) return player; - return GestureDetector(onTap: () {}, behavior: HitTestBehavior.opaque, child: player); - } -} - -class _VideoPreviewPlaceholder extends StatelessWidget { - const _VideoPreviewPlaceholder({required this.previewUrl, required this.isLoading, required this.hasError, required this.onPlay, required this.onRetry}); - - final String? previewUrl; - final bool isLoading; - final bool hasError; - final VoidCallback onPlay; - final VoidCallback onRetry; - - @override - Widget build(BuildContext context) { - return Stack( - fit: StackFit.expand, - children: [ - if (previewUrl != null && previewUrl!.isNotEmpty) - CachedNetworkImage( - imageUrl: previewUrl!, - fit: BoxFit.cover, - errorWidget: (_, _, _) => const ColoredBox(color: Color(0xFF2C2C2C)), - ) - else - const ColoredBox(color: Color(0xFF2C2C2C)), - if (hasError) - Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.error_outline, size: 40, color: Colors.white54), - const SizedBox(height: 8), - TextButton( - onPressed: onRetry, - child: const Text('Повторить', style: TextStyle(color: Colors.white70)), - ), - ], - ), - ) - else if (isLoading) - const Center(child: CircularProgressIndicator(color: Colors.white)) - else - Center( - child: GestureDetector( - onTap: onPlay, - child: Container( - decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.45), shape: BoxShape.circle), - padding: const EdgeInsets.all(12), - child: const Icon(Icons.play_arrow_rounded, color: Colors.white, size: 40), - ), - ), - ), - ], - ); - } -} +export 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart'; -- 2.49.1 From d6b57ef34df6f4347074cf9511389bcad1b0890f Mon Sep 17 00:00:00 2001 From: HasanKulbuzhev Date: Tue, 21 Jul 2026 16:50:06 +0300 Subject: [PATCH 61/88] =?UTF-8?q?=D0=BF=D0=BE=D1=84=D0=B8=D0=BA=D1=81?= =?UTF-8?q?=D0=B8=D0=BB=D0=B8=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D1=83=D0=BD=D1=82=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=20=D0=BA=D0=B0=D1=80=D1=83=D1=81=D0=B5=D0=BB=D0=B8?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D0=B8=20=D0=BA=D0=B0=D1=80=D1=82?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=BA,=20=D0=BF=D1=80=D0=B8=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B5=20=D1=81=20=D0=BE=D0=B4=D0=BD=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=80=D1=82=D0=B8=D0=BD=D0=BA=D0=BE=D0=B9=20=D0=B8?= =?UTF-8?q?=20=D0=BE=D0=B4=D0=BD=D0=B8=D0=BC=20=D0=B2=D0=B8=D0=B4=D0=B5?= =?UTF-8?q?=D0=BE.=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=D0=B8=20?= =?UTF-8?q?=D1=82=D0=B0=D0=B9=D0=BC=D0=B5=D1=80=20=D0=BD=D0=B0=20=D0=B2?= =?UTF-8?q?=D0=B8=D0=B4=D0=B5=D0=BE.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/blog_post_widget.dart | 45 ++++++++++++++----- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/lib/features/feed/presentation/widgets/blog_post_widget.dart b/lib/features/feed/presentation/widgets/blog_post_widget.dart index 8751798b..0aa3feb2 100644 --- a/lib/features/feed/presentation/widgets/blog_post_widget.dart +++ b/lib/features/feed/presentation/widgets/blog_post_widget.dart @@ -244,46 +244,67 @@ class _BlogPostMediaContent extends StatefulWidget { } class _BlogPostMediaContentState extends State<_BlogPostMediaContent> { - static const _counterDisplayDuration = Duration(seconds: 2); + static const _counterDisplayDuration = Duration(seconds: 1); bool _isCounterVisible = false; Timer? _counterHideTimer; + bool get _isCurrentSlideVideo => + widget.videoEmbedUrl != null && widget.currentGalleryIndex == widget.galleryUrls.length; + + bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible; + @override void dispose() { _counterHideTimer?.cancel(); super.dispose(); } + @override + void didUpdateWidget(covariant _BlogPostMediaContent oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.currentGalleryIndex != widget.currentGalleryIndex) { + _scheduleCounterHideOnVideo(); + } + } + + void _scheduleCounterHideOnVideo() { + _counterHideTimer?.cancel(); + if (!_isCurrentSlideVideo) return; + + _counterHideTimer = Timer(_counterDisplayDuration, () { + if (mounted && _isCurrentSlideVideo) { + setState(() => _isCounterVisible = false); + } + }); + } + bool _handleCarouselScroll(ScrollNotification notification) { if (notification is ScrollStartNotification || notification is ScrollUpdateNotification) { _counterHideTimer?.cancel(); if (!_isCounterVisible) { setState(() => _isCounterVisible = true); } - } else if (notification is ScrollEndNotification && _isCounterVisible) { - _counterHideTimer?.cancel(); - _counterHideTimer = Timer(_counterDisplayDuration, () { - if (mounted) { - setState(() => _isCounterVisible = false); - } - }); + } else if (notification is ScrollEndNotification && _isCounterVisible && _isCurrentSlideVideo) { + _scheduleCounterHideOnVideo(); } return false; } @override Widget build(BuildContext context) { - final mediaContentCount = widget.videoEmbedUrl != null ? widget.galleryUrls.length + 1 : widget.galleryUrls.length; + final hasVideo = widget.videoEmbedUrl != null; + final mediaContentCount = hasVideo ? widget.galleryUrls.length + 1 : widget.galleryUrls.length; + final showCarousel = mediaContentCount > 1; return Column( children: [ - if (widget.galleryUrls.length > 1) ...[ + if (showCarousel) ...[ Stack( children: [ NotificationListener( onNotification: _handleCarouselScroll, child: CarouselSlider.builder( - itemCount: widget.galleryUrls.length + 1, + itemCount: mediaContentCount, itemBuilder: (context, itemIndex, _) { if (itemIndex == widget.galleryUrls.length) { if (widget.videoEmbedUrl != null) { @@ -315,7 +336,7 @@ class _BlogPostMediaContentState extends State<_BlogPostMediaContent> { right: 12, child: IgnorePointer( child: AnimatedOpacity( - opacity: _isCounterVisible ? 1 : 0, + opacity: _shouldShowCounter ? 1 : 0, duration: const Duration(milliseconds: 150), child: _GalleryPhotoCounter(currentIndex: widget.currentGalleryIndex, total: mediaContentCount), ), -- 2.49.1 From 2cccc9e25312191df4f4ad2b8d0770ea7330df06 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 22 Jul 2026 14:41:26 +0300 Subject: [PATCH 62/88] feat(personalized_feed): added video and multiple images handling to detailed blog post screen --- .../presentation/pages/blog_post_screen.dart | 39 +++- .../feed/models/feed_blog_post_model.dart | 2 +- .../models/post_feed_pagination_model.dart | 4 +- .../widgets/blog_post_media_content.dart | 215 ++++++++++++++++++ .../widgets/blog_post_widget.dart | 188 +-------------- lib/features/feed/services/feed_service.dart | 2 +- test/core/app_router_test.dart | 26 +++ test/core/routing/content_routes_test.dart | 5 +- .../cubit/blog_post_cubit_test.dart | 20 +- .../pages/blog_post_screen_smoke_test.dart | 129 ++++++++--- .../models/feed_blog_post_model_test.dart | 48 ++++ .../repositories/feed_repository_test.dart | 3 + ..._blog_post_gallery_and_video_response.json | 39 ++++ .../feed/feed_blog_post_gallery_response.json | 45 ++++ .../feed/feed_blog_post_response.json | 26 ++- .../feed_blog_post_video_only_response.json | 26 +++ test/fixtures/feed_fixtures.dart | 24 ++ 17 files changed, 595 insertions(+), 246 deletions(-) create mode 100644 lib/features/feed/presentation/widgets/blog_post_media_content.dart create mode 100644 test/fixtures/feed/feed_blog_post_gallery_and_video_response.json create mode 100644 test/fixtures/feed/feed_blog_post_gallery_response.json create mode 100644 test/fixtures/feed/feed_blog_post_video_only_response.json diff --git a/lib/features/blog_post/presentation/pages/blog_post_screen.dart b/lib/features/blog_post/presentation/pages/blog_post_screen.dart index ce691fb9..d5960c68 100644 --- a/lib/features/blog_post/presentation/pages/blog_post_screen.dart +++ b/lib/features/blog_post/presentation/pages/blog_post_screen.dart @@ -1,4 +1,4 @@ -import 'package:cached_network_image/cached_network_image.dart'; +import 'package:dismissible_page/dismissible_page.dart'; import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/enums/complained_content.dart'; import 'package:eatvio_app/core/utils/date_time_utils.dart'; @@ -9,7 +9,10 @@ import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_cubit import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_state.dart'; import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart'; import 'package:eatvio_app/core/comments/comments_cubit/comments_state.dart'; +import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart'; +import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; +import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart'; import 'package:eatvio_app/features/feed/presentation/widgets/comments_widget.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; @@ -33,6 +36,7 @@ class _BlogPostScreenState extends State { final CommentsCubit commentsCubit = sl(); final textEditingController = TextEditingController(); int? replyId; + int currentGalleryIndex = 0; @override void initState() { @@ -40,6 +44,10 @@ class _BlogPostScreenState extends State { super.initState(); } + void _openGallery(List imageUrls, int index) { + context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index)); + } + @override Widget build(BuildContext context) { return BlocProvider( @@ -242,8 +250,33 @@ class _BlogPostScreenState extends State { child: MarkdownHtmlWidget(markdownText: blogPostState.blogPostModel!.description), ), const SizedBox(height: 8), - if (blogPostState.blogPostModel?.image.isNotEmpty ?? false) - CachedNetworkImage(imageUrl: '${sl().baseUrl}/storage/${blogPostState.blogPostModel?.image ?? ''}'), + Builder( + builder: (context) { + final post = blogPostState.blogPostModel; + if (post == null) { + return const SizedBox.shrink(); + } + final imageUrl = PostFeedMapper.resolveMediaUrl(post.image); + final galleryUrls = post.images + .map((e) => PostFeedMapper.resolveMediaUrl(e.url)) + .where((e) => e.isNotEmpty) + .toList(); + final embedUrl = post.videoEmbedUrl?.trim(); + return BlogPostMediaContent( + galleryUrls: galleryUrls, + imageUrl: imageUrl, + videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null, + videoUrlThumb: post.videoUrlThumb != null ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) : null, + currentGalleryIndex: currentGalleryIndex, + onOpenGallery: _openGallery, + onGalleryChanged: (index) { + setState(() { + currentGalleryIndex = index; + }); + }, + ); + }, + ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( diff --git a/lib/features/feed/models/feed_blog_post_model.dart b/lib/features/feed/models/feed_blog_post_model.dart index 157a2da0..9ce22b7f 100644 --- a/lib/features/feed/models/feed_blog_post_model.dart +++ b/lib/features/feed/models/feed_blog_post_model.dart @@ -22,7 +22,7 @@ abstract class FeedBlogPostModel with _$FeedBlogPostModel { @JsonKey(name: "total_comments") required int totalComments, @JsonKey(name: "was_liked_by_me") required bool wasLikedByMe, @Default([]) List images, - @JsonKey(name: "video_embed_url") String? videoEmbedUrl, + @JsonKey(name: "video_url_embed") String? videoEmbedUrl, @JsonKey(name: "video_url_thumb") String? videoUrlThumb, }) = _FeedBlogPostModel; diff --git a/lib/features/feed/models/post_feed_pagination_model.dart b/lib/features/feed/models/post_feed_pagination_model.dart index c558e64e..31ade2a8 100644 --- a/lib/features/feed/models/post_feed_pagination_model.dart +++ b/lib/features/feed/models/post_feed_pagination_model.dart @@ -50,7 +50,9 @@ abstract class PostFeedItemModel with _$PostFeedItemModel { abstract class FeedGalleryImage with _$FeedGalleryImage { const factory FeedGalleryImage({ required String url, - required String orientation, + @Default('horizontal') String orientation, + int? id, + int? position, }) = _FeedGalleryImage; factory FeedGalleryImage.fromJson(Map json) => _$FeedGalleryImageFromJson(json); diff --git a/lib/features/feed/presentation/widgets/blog_post_media_content.dart b/lib/features/feed/presentation/widgets/blog_post_media_content.dart new file mode 100644 index 00000000..e2141185 --- /dev/null +++ b/lib/features/feed/presentation/widgets/blog_post_media_content.dart @@ -0,0 +1,215 @@ +import 'dart:async'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:carousel_slider_plus/carousel_slider_plus.dart'; +import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart'; +import 'package:flutter/material.dart'; + +/// Gallery + optional embed video for feed card and blog post detail. +class BlogPostMediaContent extends StatefulWidget { + const BlogPostMediaContent({ + super.key, + required this.galleryUrls, + required this.imageUrl, + required this.videoEmbedUrl, + required this.currentGalleryIndex, + required this.onOpenGallery, + required this.onGalleryChanged, + required this.videoUrlThumb, + }); + + final List galleryUrls; + final String imageUrl; + final String? videoEmbedUrl; + final String? videoUrlThumb; + final int currentGalleryIndex; + final void Function(List urls, int index) onOpenGallery; + final ValueChanged onGalleryChanged; + + @override + State createState() => _BlogPostMediaContentState(); +} + +class _BlogPostMediaContentState extends State { + static const _counterDisplayDuration = Duration(seconds: 1); + static const _carouselHeight = 260.0; + + bool _isCounterVisible = false; + Timer? _counterHideTimer; + + bool get _hasVideo => widget.videoEmbedUrl != null && widget.videoEmbedUrl!.trim().isNotEmpty; + + int get _slideCount { + if (widget.galleryUrls.isNotEmpty) { + return widget.galleryUrls.length + (_hasVideo ? 1 : 0); + } + if (_hasVideo) { + return 1; + } + return 0; + } + + bool get _isCurrentSlideVideo => _hasVideo && widget.currentGalleryIndex == widget.galleryUrls.length; + + bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible; + + @override + void dispose() { + _counterHideTimer?.cancel(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant BlogPostMediaContent oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.currentGalleryIndex != widget.currentGalleryIndex) { + _scheduleCounterHideOnVideo(); + } + } + + void _scheduleCounterHideOnVideo() { + _counterHideTimer?.cancel(); + if (!_isCurrentSlideVideo) return; + + _counterHideTimer = Timer(_counterDisplayDuration, () { + if (mounted && _isCurrentSlideVideo) { + setState(() => _isCounterVisible = false); + } + }); + } + + bool _handleCarouselScroll(ScrollNotification notification) { + if (notification is ScrollStartNotification || notification is ScrollUpdateNotification) { + _counterHideTimer?.cancel(); + if (!_isCounterVisible) { + setState(() => _isCounterVisible = true); + } + } else if (notification is ScrollEndNotification && _isCounterVisible && _isCurrentSlideVideo) { + _scheduleCounterHideOnVideo(); + } + return false; + } + + Widget _imageSlide(String itemUrl, int itemIndex) { + return GestureDetector( + onTap: () => widget.onOpenGallery(widget.galleryUrls, itemIndex), + child: CachedNetworkImage( + fit: BoxFit.cover, + width: MediaQuery.of(context).size.width, + height: _carouselHeight, + imageUrl: itemUrl, + errorWidget: (context, str, object) => const SizedBox.shrink(), + ), + ); + } + + Widget _videoSlide() { + return FeedVideoPlayer(embedUrl: widget.videoEmbedUrl!, videoUrlThumb: widget.videoUrlThumb); + } + + Widget _carousel({required int itemCount, required NullableIndexedWidgetBuilder itemBuilder}) { + return Stack( + children: [ + NotificationListener( + onNotification: _handleCarouselScroll, + child: CarouselSlider.builder( + itemCount: itemCount, + itemBuilder: (context, itemIndex, _) => itemBuilder(context, itemIndex) ?? const SizedBox.shrink(), + options: CarouselOptions( + viewportFraction: 1, + height: _carouselHeight, + enableInfiniteScroll: false, + onPageChanged: (index, _) => widget.onGalleryChanged(index), + ), + ), + ), + if (itemCount > 1) + Positioned( + top: 12, + right: 12, + child: IgnorePointer( + child: AnimatedOpacity( + opacity: _shouldShowCounter ? 1 : 0, + duration: const Duration(milliseconds: 150), + child: _GalleryPhotoCounter(currentIndex: widget.currentGalleryIndex, total: itemCount), + ), + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + if (widget.galleryUrls.length > 1 || (widget.galleryUrls.length == 1 && _hasVideo)) { + final itemCount = _slideCount; + return _carousel( + itemCount: itemCount, + itemBuilder: (context, itemIndex) { + if (itemIndex < widget.galleryUrls.length) { + return _imageSlide(widget.galleryUrls[itemIndex], itemIndex); + } + if (_hasVideo) { + return _videoSlide(); + } + return const SizedBox.shrink(); + }, + ); + } + + if (widget.galleryUrls.length == 1) { + return _imageSlide(widget.galleryUrls.first, 0); + } + + if (_hasVideo) { + return SizedBox( + height: _carouselHeight, + width: MediaQuery.of(context).size.width, + child: _videoSlide(), + ); + } + + if (widget.imageUrl.isNotEmpty) { + return GestureDetector( + onTap: () => widget.onOpenGallery([widget.imageUrl], 0), + child: Hero( + tag: widget.imageUrl, + child: CachedNetworkImage( + fit: BoxFit.fitWidth, + width: MediaQuery.of(context).size.width, + imageUrl: widget.imageUrl, + errorWidget: (context, str, object) => const SizedBox.shrink(), + ), + ), + ); + } + + return const SizedBox.shrink(); + } +} + +class _GalleryPhotoCounter extends StatelessWidget { + const _GalleryPhotoCounter({required this.currentIndex, required this.total}); + + final int currentIndex; + final int total; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(999)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.photo_library_outlined, size: 14, color: Colors.white), + const SizedBox(width: 8), + Text( + '${currentIndex + 1} / $total', + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500, height: 1.2), + ), + ], + ), + ); + } +} diff --git a/lib/features/feed/presentation/widgets/blog_post_widget.dart b/lib/features/feed/presentation/widgets/blog_post_widget.dart index 0aa3feb2..5c349db8 100644 --- a/lib/features/feed/presentation/widgets/blog_post_widget.dart +++ b/lib/features/feed/presentation/widgets/blog_post_widget.dart @@ -1,7 +1,3 @@ -import 'dart:async'; - -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:carousel_slider_plus/carousel_slider_plus.dart'; import 'package:dismissible_page/dismissible_page.dart'; import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/enums/complained_content.dart'; @@ -15,7 +11,7 @@ import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.da import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; -import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart'; +import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart'; import 'package:eatvio_app/features/feed/presentation/widgets/subscribe_button_widget.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -98,7 +94,7 @@ class _BlogPostWidgetState extends State { const SizedBox(height: 12), _BlogPostHeader(post: post), _BlogPostTextContent(post: post), - _BlogPostMediaContent( + BlogPostMediaContent( galleryUrls: galleryUrls, imageUrl: imageUrl, videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null, @@ -220,186 +216,6 @@ class _BlogPostTextContent extends StatelessWidget { } } -class _BlogPostMediaContent extends StatefulWidget { - const _BlogPostMediaContent({ - required this.galleryUrls, - required this.imageUrl, - required this.videoEmbedUrl, - required this.currentGalleryIndex, - required this.onOpenGallery, - required this.onGalleryChanged, - required this.videoUrlThumb, - }); - - final List galleryUrls; - final String imageUrl; - final String? videoEmbedUrl; - final String? videoUrlThumb; - final int currentGalleryIndex; - final void Function(List urls, int index) onOpenGallery; - final ValueChanged onGalleryChanged; - - @override - State<_BlogPostMediaContent> createState() => _BlogPostMediaContentState(); -} - -class _BlogPostMediaContentState extends State<_BlogPostMediaContent> { - static const _counterDisplayDuration = Duration(seconds: 1); - - bool _isCounterVisible = false; - Timer? _counterHideTimer; - - bool get _isCurrentSlideVideo => - widget.videoEmbedUrl != null && widget.currentGalleryIndex == widget.galleryUrls.length; - - bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible; - - @override - void dispose() { - _counterHideTimer?.cancel(); - super.dispose(); - } - - @override - void didUpdateWidget(covariant _BlogPostMediaContent oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.currentGalleryIndex != widget.currentGalleryIndex) { - _scheduleCounterHideOnVideo(); - } - } - - void _scheduleCounterHideOnVideo() { - _counterHideTimer?.cancel(); - if (!_isCurrentSlideVideo) return; - - _counterHideTimer = Timer(_counterDisplayDuration, () { - if (mounted && _isCurrentSlideVideo) { - setState(() => _isCounterVisible = false); - } - }); - } - - bool _handleCarouselScroll(ScrollNotification notification) { - if (notification is ScrollStartNotification || notification is ScrollUpdateNotification) { - _counterHideTimer?.cancel(); - if (!_isCounterVisible) { - setState(() => _isCounterVisible = true); - } - } else if (notification is ScrollEndNotification && _isCounterVisible && _isCurrentSlideVideo) { - _scheduleCounterHideOnVideo(); - } - return false; - } - - @override - Widget build(BuildContext context) { - final hasVideo = widget.videoEmbedUrl != null; - final mediaContentCount = hasVideo ? widget.galleryUrls.length + 1 : widget.galleryUrls.length; - final showCarousel = mediaContentCount > 1; - return Column( - children: [ - if (showCarousel) ...[ - Stack( - children: [ - NotificationListener( - onNotification: _handleCarouselScroll, - child: CarouselSlider.builder( - itemCount: mediaContentCount, - itemBuilder: (context, itemIndex, _) { - if (itemIndex == widget.galleryUrls.length) { - if (widget.videoEmbedUrl != null) { - return FeedVideoPlayer(embedUrl: widget.videoEmbedUrl!, videoUrlThumb: widget.videoUrlThumb); - } - return const SizedBox.shrink(); - } - final itemUrl = widget.galleryUrls[itemIndex]; - return GestureDetector( - onTap: () => widget.onOpenGallery(widget.galleryUrls, itemIndex), - child: CachedNetworkImage( - fit: BoxFit.cover, - width: MediaQuery.of(context).size.width, - imageUrl: itemUrl, - errorWidget: (context, str, object) => const SizedBox.shrink(), - ), - ); - }, - options: CarouselOptions( - viewportFraction: 1, - height: 260, - enableInfiniteScroll: false, - onPageChanged: (index, _) => widget.onGalleryChanged(index), - ), - ), - ), - Positioned( - top: 12, - right: 12, - child: IgnorePointer( - child: AnimatedOpacity( - opacity: _shouldShowCounter ? 1 : 0, - duration: const Duration(milliseconds: 150), - child: _GalleryPhotoCounter(currentIndex: widget.currentGalleryIndex, total: mediaContentCount), - ), - ), - ), - ], - ), - ] else if (widget.galleryUrls.length == 1) ...[ - GestureDetector( - onTap: () => widget.onOpenGallery(widget.galleryUrls, 0), - child: CachedNetworkImage( - fit: BoxFit.cover, - width: MediaQuery.of(context).size.width, - height: 260, - imageUrl: widget.galleryUrls.first, - errorWidget: (context, str, object) => const SizedBox.shrink(), - ), - ), - ] else if (widget.imageUrl.isNotEmpty) ...[ - GestureDetector( - onTap: () => widget.onOpenGallery([widget.imageUrl], 0), - child: Hero( - tag: widget.imageUrl, - child: CachedNetworkImage( - fit: BoxFit.fitWidth, - width: MediaQuery.of(context).size.width, - imageUrl: widget.imageUrl, - errorWidget: (context, str, object) => const SizedBox.shrink(), - ), - ), - ), - ], - ], - ); - } -} - -class _GalleryPhotoCounter extends StatelessWidget { - const _GalleryPhotoCounter({required this.currentIndex, required this.total}); - - final int currentIndex; - final int total; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(999)), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.photo_library_outlined, size: 14, color: Colors.white), - const SizedBox(width: 8), - Text( - '${currentIndex + 1} / $total', - style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500, height: 1.2), - ), - ], - ), - ); - } -} - class _BlogPostActionsRow extends StatelessWidget { const _BlogPostActionsRow({required this.post, required this.isLiked, required this.totalLikes, required this.onToggleLike}); diff --git a/lib/features/feed/services/feed_service.dart b/lib/features/feed/services/feed_service.dart index 9d88dd8f..22a453be 100644 --- a/lib/features/feed/services/feed_service.dart +++ b/lib/features/feed/services/feed_service.dart @@ -25,7 +25,7 @@ abstract class FeedService { @Query('cursor') String? cursor, }); - @GET('/api/blogPosts/show/{postId}') + @GET('/api/v2/blogPosts/show/{postId}') Future getBlogPost({@Path('postId') required int postId}); @GET('/api/recipes/index') diff --git a/test/core/app_router_test.dart b/test/core/app_router_test.dart index 648c5130..bdeef9c1 100644 --- a/test/core/app_router_test.dart +++ b/test/core/app_router_test.dart @@ -7,6 +7,32 @@ import 'package:go_router/go_router.dart'; void main() { group('AppRouter', () { + group('resolveDeepLinkTarget - blog_post', () { + test('ru/blog_post/{id} → /blog-post-screen?id=', () { + final target = AppRouter.resolveDeepLinkTarget( + Uri.parse('https://eatvio.ru/ru/blog_post/123'), + ); + + expect(target, '/blog-post-screen?id=123'); + }); + + test('en/blog_post/{id} → /blog-post-screen?id=', () { + final target = AppRouter.resolveDeepLinkTarget( + Uri.parse('https://eatvio.com/en/blog_post/456'), + ); + + expect(target, '/blog-post-screen?id=456'); + }); + + test('eatvio.com/blog_post/{id} without locale → /blog-post-screen?id=', () { + final target = AppRouter.resolveDeepLinkTarget( + Uri.parse('https://eatvio.com/blog_post/789'), + ); + + expect(target, '/blog-post-screen?id=789'); + }); + }); + group('resolveDeepLinkTarget - finance/alfa/return', () { const orderId = 'alfa_c29_k15138_t1712345678_r1234'; diff --git a/test/core/routing/content_routes_test.dart b/test/core/routing/content_routes_test.dart index ff23e2a3..385485a9 100644 --- a/test/core/routing/content_routes_test.dart +++ b/test/core/routing/content_routes_test.dart @@ -51,8 +51,11 @@ void main() { final blogPostRoute = routes.firstWhere( (route) => route is GoRoute && route.path == '/blog-post-screen', orElse: () => throw Exception('Route not found'), - ); + ) as GoRoute; expect(blogPostRoute, isA()); + expect(blogPostRoute.path, '/blog-post-screen'); + expect(blogPostRoute.name, 'blogPostScreen'); + expect(blogPostRoute.builder, isNotNull); }); test('aboutPlanScreen builder обрабатывает slug из query параметров', () { diff --git a/test/features/blog_post/presentation/cubit/blog_post_cubit_test.dart b/test/features/blog_post/presentation/cubit/blog_post_cubit_test.dart index 082c154c..4103007c 100644 --- a/test/features/blog_post/presentation/cubit/blog_post_cubit_test.dart +++ b/test/features/blog_post/presentation/cubit/blog_post_cubit_test.dart @@ -6,6 +6,8 @@ import 'package:eatvio_app/features/feed/repositories/feed_repository.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import '../../../../fixtures/feed_fixtures.dart'; + class MockFeedRepository extends Mock implements FeedRepository {} void main() { @@ -29,21 +31,7 @@ void main() { test('getBlogPost - успешная загрузка поста', () async { const postId = 1; - final mockPost = FeedBlogPostModel( - id: postId, - title: 'Test Post', - state: 'active', - image: '', - description: 'Test Content', - videoUrl: '', - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - totalLikes: 0, - totalViews: 0, - totalComments: 0, - wasLikedByMe: false, - author: const Author(authorName: 'Test Author', authorSlug: 'test-author', authorProfileIcon: '', authorIsMe: false), - ); + final mockPost = FeedFixtures.feedBlogPostModel(); when(() => mockRepository.getBlogPost(id: postId)).thenAnswer((_) async => mockPost); @@ -51,6 +39,8 @@ void main() { expect(cubit.state.isLoading, false); expect(cubit.state.blogPostModel, mockPost); + expect(cubit.state.blogPostModel?.images, hasLength(2)); + expect(cubit.state.blogPostModel?.videoEmbedUrl, contains('rutube.ru/play/embed')); verify(() => mockRepository.getBlogPost(id: postId)).called(1); }); diff --git a/test/features/blog_post/presentation/pages/blog_post_screen_smoke_test.dart b/test/features/blog_post/presentation/pages/blog_post_screen_smoke_test.dart index f194ea0c..a021157a 100644 --- a/test/features/blog_post/presentation/pages/blog_post_screen_smoke_test.dart +++ b/test/features/blog_post/presentation/pages/blog_post_screen_smoke_test.dart @@ -7,9 +7,8 @@ import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_cubit.dart'; import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_state.dart'; import 'package:eatvio_app/features/blog_post/presentation/pages/blog_post_screen.dart'; -import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; -import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; +import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -21,6 +20,7 @@ import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:network_image_mock/network_image_mock.dart'; +import '../../../../fixtures/feed_fixtures.dart'; import 'blog_post_screen_smoke_test.mocks.dart'; @GenerateNiceMocks([MockSpec(), MockSpec(), MockSpec()]) @@ -167,21 +167,7 @@ void main() { final blogPostCubit = MockBlogPostCubit(); final feedCubit = MockFeedCubit(); final commentsCubit = MockCommentsCubit(); - final blogPostModel = FeedBlogPostModel( - id: 1, - title: 'Test Post Title', - description: 'Test post description', - image: 'https://example.com/image.jpg', - state: 'published', - author: const Author(authorSlug: 'author-1', authorName: 'Author Name', authorProfileIcon: 'https://example.com/icon.jpg', authorIsMe: false), - videoUrl: '', - createdAt: DateTime(2024, 1, 15), - updatedAt: DateTime(2024, 1, 15), - totalLikes: 10, - totalViews: 100, - totalComments: 5, - wasLikedByMe: false, - ); + final blogPostModel = FeedFixtures.feedBlogPostModel(); final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null); when(blogPostCubit.state).thenReturn(state); @@ -202,7 +188,8 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(BlogPostScreen), findsOneWidget); - expect(find.text('Test Post Title'), findsOneWidget); + expect(find.text('Заголовок'), findsOneWidget); + expect(find.byType(BlogPostMediaContent), findsOneWidget); }); }); @@ -211,21 +198,7 @@ void main() { final blogPostCubit = MockBlogPostCubit(); final feedCubit = MockFeedCubit(); final commentsCubit = MockCommentsCubit(); - final blogPostModel = FeedBlogPostModel( - id: 1, - title: 'Test Post Title', - description: 'Test post description', - image: 'https://example.com/image.jpg', - state: 'published', - author: const Author(authorSlug: 'author-1', authorName: 'Author Name', authorProfileIcon: 'https://example.com/icon.jpg', authorIsMe: false), - videoUrl: '', - createdAt: DateTime(2024, 1, 15), - updatedAt: DateTime(2024, 1, 15), - totalLikes: 10, - totalViews: 100, - totalComments: 5, - wasLikedByMe: false, - ); + final blogPostModel = FeedFixtures.feedBlogPostModel(); final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null); when(blogPostCubit.state).thenReturn(state); @@ -254,5 +227,95 @@ void main() { verify(blogPostCubit.toggleLike()).called(1); }); }); + + testWidgets('renders gallery from fixture', (tester) async { + await mockNetworkImagesFor(() async { + final blogPostCubit = MockBlogPostCubit(); + final feedCubit = MockFeedCubit(); + final commentsCubit = MockCommentsCubit(); + final blogPostModel = FeedFixtures.feedBlogPostWithGallery(); + final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null); + + when(blogPostCubit.state).thenReturn(state); + when(blogPostCubit.stream).thenAnswer((_) => Stream.value(state)); + when(blogPostCubit.close()).thenAnswer((_) async => Future.value()); + when(feedCubit.viewPost(any)).thenAnswer((_) async => Future.value()); + when(feedCubit.stream).thenAnswer((_) => const Stream.empty()); + when(feedCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.state).thenReturn(const CommentsState()); + when(commentsCubit.stream).thenAnswer((_) => const Stream.empty()); + when(commentsCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future.value()); + + registerMocks(blogPostCubit, commentsCubit); + + await tester.pumpWidget(wrap(blogPostCubit, feedCubit, commentsCubit, BlogPostScreen(id: blogPostModel.id))); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.byType(BlogPostMediaContent), findsOneWidget); + expect(find.text('Галерея'), findsOneWidget); + }); + }); + + testWidgets('renders video-only post', (tester) async { + await mockNetworkImagesFor(() async { + final blogPostCubit = MockBlogPostCubit(); + final feedCubit = MockFeedCubit(); + final commentsCubit = MockCommentsCubit(); + final blogPostModel = FeedFixtures.feedBlogPostVideoOnly(); + final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null); + + when(blogPostCubit.state).thenReturn(state); + when(blogPostCubit.stream).thenAnswer((_) => Stream.value(state)); + when(blogPostCubit.close()).thenAnswer((_) async => Future.value()); + when(feedCubit.viewPost(any)).thenAnswer((_) async => Future.value()); + when(feedCubit.stream).thenAnswer((_) => const Stream.empty()); + when(feedCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.state).thenReturn(const CommentsState()); + when(commentsCubit.stream).thenAnswer((_) => const Stream.empty()); + when(commentsCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future.value()); + + registerMocks(blogPostCubit, commentsCubit); + + await tester.pumpWidget(wrap(blogPostCubit, feedCubit, commentsCubit, BlogPostScreen(id: blogPostModel.id))); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.byType(BlogPostMediaContent), findsOneWidget); + expect(find.text('Видео'), findsOneWidget); + }); + }); + + testWidgets('renders gallery and video post', (tester) async { + await mockNetworkImagesFor(() async { + final blogPostCubit = MockBlogPostCubit(); + final feedCubit = MockFeedCubit(); + final commentsCubit = MockCommentsCubit(); + final blogPostModel = FeedFixtures.feedBlogPostGalleryAndVideo(); + final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null); + + when(blogPostCubit.state).thenReturn(state); + when(blogPostCubit.stream).thenAnswer((_) => Stream.value(state)); + when(blogPostCubit.close()).thenAnswer((_) async => Future.value()); + when(feedCubit.viewPost(any)).thenAnswer((_) async => Future.value()); + when(feedCubit.stream).thenAnswer((_) => const Stream.empty()); + when(feedCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.state).thenReturn(const CommentsState()); + when(commentsCubit.stream).thenAnswer((_) => const Stream.empty()); + when(commentsCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future.value()); + + registerMocks(blogPostCubit, commentsCubit); + + await tester.pumpWidget(wrap(blogPostCubit, feedCubit, commentsCubit, BlogPostScreen(id: blogPostModel.id))); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.byType(BlogPostMediaContent), findsOneWidget); + expect(find.text('Галерея и видео'), findsWidgets); + }); + }); }); } diff --git a/test/features/feed/models/feed_blog_post_model_test.dart b/test/features/feed/models/feed_blog_post_model_test.dart index 9d84480a..55fbe181 100644 --- a/test/features/feed/models/feed_blog_post_model_test.dart +++ b/test/features/feed/models/feed_blog_post_model_test.dart @@ -2,6 +2,8 @@ import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../../../fixtures/feed_fixtures.dart'; + void main() { group('FeedBlogPostModel', () { test('creates FeedBlogPostModel with all parameters', () { @@ -102,6 +104,52 @@ void main() { expect(model.totalLikes, 10); }); + test('parses v2 show JSON with gallery images and video_url_embed', () { + final model = FeedBlogPostModel.fromJson({ + 'id': 1, + 'state': 'active', + 'image': 'media/1/cover.jpg', + 'images': [ + {'id': 10, 'url': 'media/1/a.jpg', 'position': 1, 'orientation': 'horizontal'}, + {'id': 11, 'url': 'media/1/b.jpg', 'position': 2}, + ], + 'description': 'Описание', + 'author': { + 'author_slug': 'author-slug', + 'author_name': 'Автор', + 'author_profile_icon': 'icon.jpg', + 'author_is_me': false, + }, + 'video_url': 'https://rutube.ru/video/abc/', + 'video_url_embed': 'https://rutube.ru/play/embed/abc/', + 'video_url_thumb': 'media/1/thumb.jpg', + 'created_at': '2024-01-15T12:00:00Z', + 'updated_at': '2024-01-15T12:00:00Z', + 'title': 'Заголовок', + 'total_likes': 10, + 'total_views': 100, + 'total_comments': 5, + 'was_liked_by_me': false, + }); + + expect(model.images, hasLength(2)); + expect(model.images.first.id, 10); + expect(model.images.first.url, 'media/1/a.jpg'); + expect(model.images.first.orientation, 'horizontal'); + expect(model.images[1].orientation, 'horizontal'); + expect(model.images[1].position, 2); + expect(model.videoEmbedUrl, 'https://rutube.ru/play/embed/abc/'); + expect(model.videoUrlThumb, 'media/1/thumb.jpg'); + }); + + test('parses fixture feedBlogPostModel with gallery and embed', () { + final model = FeedFixtures.feedBlogPostModel(); + + expect(model.images, hasLength(2)); + expect(model.videoEmbedUrl, contains('rutube.ru/play/embed')); + expect(model.videoUrlThumb, 'media/1/thumb.jpg'); + }); + test('creates FeedBlogPostModel with different states', () { const author = Author( authorSlug: 'author', diff --git a/test/features/feed/repositories/feed_repository_test.dart b/test/features/feed/repositories/feed_repository_test.dart index 9165ba3a..10e9f200 100644 --- a/test/features/feed/repositories/feed_repository_test.dart +++ b/test/features/feed/repositories/feed_repository_test.dart @@ -85,6 +85,9 @@ void main() { expect(result, isA()); expect(result.id, postId); + expect(result.images, hasLength(2)); + expect(result.videoEmbedUrl, contains('rutube.ru/play/embed')); + expect(result.videoUrlThumb, isNotEmpty); verify(mockFeedService.getBlogPost(postId: postId)).called(1); }); diff --git a/test/fixtures/feed/feed_blog_post_gallery_and_video_response.json b/test/fixtures/feed/feed_blog_post_gallery_and_video_response.json new file mode 100644 index 00000000..c54b04af --- /dev/null +++ b/test/fixtures/feed/feed_blog_post_gallery_and_video_response.json @@ -0,0 +1,39 @@ +{ + "id": 4, + "state": "active", + "image": "media/4/a.jpg", + "images": [ + { + "id": 40, + "url": "media/4/a.jpg", + "position": 1, + "orientation": "horizontal" + }, + { + "id": 41, + "url": "media/4/b.jpg", + "position": 2, + "orientation": "horizontal" + } + ], + "description": "Галерея и видео", + "author": { + "author_slug": "author-slug", + "author_name": "Автор", + "author_profile_icon": "https://example.com/photo.jpg", + "author_is_me": false, + "is_entity_subscribed_by_me": true, + "author_place_in_rating": null + }, + "video_url": "https://rutube.ru/video/6a62f217aa99daf00af8fa6cebebf218/", + "video_url_embed": "https://rutube.ru/play/embed/6a62f217aa99daf00af8fa6cebebf218/", + "video_url_thumb": "media/4/thumb.jpg", + "created_at": "2024-01-18T12:00:00Z", + "updated_at": "2024-01-18T12:00:00Z", + "title": "Галерея и видео", + "total_likes": 12, + "total_views": 200, + "total_comments": 4, + "was_liked_by_me": false, + "is_favorite": false +} diff --git a/test/fixtures/feed/feed_blog_post_gallery_response.json b/test/fixtures/feed/feed_blog_post_gallery_response.json new file mode 100644 index 00000000..fd23f3a4 --- /dev/null +++ b/test/fixtures/feed/feed_blog_post_gallery_response.json @@ -0,0 +1,45 @@ +{ + "id": 2, + "state": "active", + "image": "media/2/a.jpg", + "images": [ + { + "id": 20, + "url": "media/2/a.jpg", + "position": 1, + "orientation": "horizontal" + }, + { + "id": 21, + "url": "media/2/b.jpg", + "position": 2, + "orientation": "horizontal" + }, + { + "id": 22, + "url": "media/2/c.jpg", + "position": 3, + "orientation": "vertical" + } + ], + "description": "Пост только с галереей", + "author": { + "author_slug": "author-slug", + "author_name": "Автор", + "author_profile_icon": "https://example.com/photo.jpg", + "author_is_me": false, + "is_entity_subscribed_by_me": false, + "author_place_in_rating": null + }, + "video_url": "", + "video_url_embed": null, + "video_url_thumb": null, + "created_at": "2024-01-16T12:00:00Z", + "updated_at": "2024-01-16T12:00:00Z", + "title": "Галерея", + "total_likes": 3, + "total_views": 40, + "total_comments": 1, + "was_liked_by_me": false, + "is_favorite": false +} diff --git a/test/fixtures/feed/feed_blog_post_response.json b/test/fixtures/feed/feed_blog_post_response.json index 75ae180b..c5af87de 100644 --- a/test/fixtures/feed/feed_blog_post_response.json +++ b/test/fixtures/feed/feed_blog_post_response.json @@ -1,7 +1,21 @@ { "id": 1, - "state": "published", - "image": "https://example.com/image.jpg", + "state": "active", + "image": "media/1/cover.jpg", + "images": [ + { + "id": 10, + "url": "media/1/a.jpg", + "position": 1, + "orientation": "horizontal" + }, + { + "id": 11, + "url": "media/1/b.jpg", + "position": 2, + "orientation": "vertical" + } + ], "description": "Описание поста", "author": { "author_slug": "author-slug", @@ -11,13 +25,15 @@ "is_entity_subscribed_by_me": null, "author_place_in_rating": null }, - "video_url": "", + "video_url": "https://rutube.ru/video/6a62f217aa99daf00af8fa6cebebf218/", + "video_url_embed": "https://rutube.ru/play/embed/6a62f217aa99daf00af8fa6cebebf218/", + "video_url_thumb": "media/1/thumb.jpg", "created_at": "2024-01-15T12:00:00Z", "updated_at": "2024-01-15T12:00:00Z", "title": "Заголовок", "total_likes": 10, "total_views": 100, "total_comments": 5, - "was_liked_by_me": false + "was_liked_by_me": false, + "is_favorite": false } - diff --git a/test/fixtures/feed/feed_blog_post_video_only_response.json b/test/fixtures/feed/feed_blog_post_video_only_response.json new file mode 100644 index 00000000..f3025547 --- /dev/null +++ b/test/fixtures/feed/feed_blog_post_video_only_response.json @@ -0,0 +1,26 @@ +{ + "id": 3, + "state": "active", + "image": "images/element-image-placeholder.svg", + "images": [], + "description": "Пост только с видео", + "author": { + "author_slug": "author-slug", + "author_name": "Автор", + "author_profile_icon": "https://example.com/photo.jpg", + "author_is_me": false, + "is_entity_subscribed_by_me": false, + "author_place_in_rating": null + }, + "video_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "video_url_embed": "https://www.youtube.com/embed/dQw4w9WgXcQ", + "video_url_thumb": "media/3/thumb.jpg", + "created_at": "2024-01-17T12:00:00Z", + "updated_at": "2024-01-17T12:00:00Z", + "title": "Видео", + "total_likes": 8, + "total_views": 90, + "total_comments": 2, + "was_liked_by_me": true, + "is_favorite": false +} diff --git a/test/fixtures/feed_fixtures.dart b/test/fixtures/feed_fixtures.dart index 3084a69b..0beb1624 100644 --- a/test/fixtures/feed_fixtures.dart +++ b/test/fixtures/feed_fixtures.dart @@ -23,6 +23,18 @@ class FeedFixtures { return FixtureReader.readAsMap('feed/feed_blog_post_response.json'); } + static Map feedBlogPostGalleryResponseJson() { + return FixtureReader.readAsMap('feed/feed_blog_post_gallery_response.json'); + } + + static Map feedBlogPostVideoOnlyResponseJson() { + return FixtureReader.readAsMap('feed/feed_blog_post_video_only_response.json'); + } + + static Map feedBlogPostGalleryAndVideoResponseJson() { + return FixtureReader.readAsMap('feed/feed_blog_post_gallery_and_video_response.json'); + } + static PostFeedPaginationModel postFeedPaginationModel() { return PostFeedPaginationModel.fromJson(postFeedResponseJson()); } @@ -48,4 +60,16 @@ class FeedFixtures { static FeedBlogPostModel feedBlogPostModel() { return FeedBlogPostModel.fromJson(feedBlogPostResponseJson()); } + + static FeedBlogPostModel feedBlogPostWithGallery() { + return FeedBlogPostModel.fromJson(feedBlogPostGalleryResponseJson()); + } + + static FeedBlogPostModel feedBlogPostVideoOnly() { + return FeedBlogPostModel.fromJson(feedBlogPostVideoOnlyResponseJson()); + } + + static FeedBlogPostModel feedBlogPostGalleryAndVideo() { + return FeedBlogPostModel.fromJson(feedBlogPostGalleryAndVideoResponseJson()); + } } -- 2.49.1 From 037f153273bcf93c4085170bcd6335be29a4d3fb Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 22 Jul 2026 17:58:02 +0300 Subject: [PATCH 63/88] feat(consultant_scoring): adjusted data for new registration, added sbp banks endpoint --- .../models/consultant_profile_model.dart | 74 ++- .../models/consultation_request_model.dart | 52 +- .../models/sbp_bank_model.dart | 17 + .../register_consultant_cubit.dart | 162 +++++- .../register_consultant_state.dart | 3 + .../certificates_and_diplomas_screen.dart | 14 +- .../presentation/pages/last_step_screen.dart | 68 ++- .../pages/passport_data_screen.dart | 527 ++++++++++++++++++ .../pages/registration_as_screen.dart | 50 +- .../stepper_become_consultant_screen.dart | 441 ++++++++------- .../repositories/consultant_repository.dart | 13 +- .../services/consultant_service.dart | 14 +- .../models/consultant_profile_model_test.dart | 40 +- .../consultation_request_model_test.dart | 41 ++ .../bloc/register_consultant_cubit_test.dart | 84 ++- .../pages/last_step_screen_test.dart | 21 +- .../pages/passport_data_screen_test.dart | 59 ++ .../pages/registration_as_screen_test.dart | 32 +- ...r_become_consultant_screen_smoke_test.dart | 2 +- .../consultant_repository_test.dart | 13 + .../consultant_profile_request.json | 24 +- .../consultant/consultation_request.json | 26 +- test/fixtures/consultant/sbp_banks.json | 16 + 23 files changed, 1514 insertions(+), 279 deletions(-) create mode 100644 lib/features/consultant/become_consultant/models/sbp_bank_model.dart create mode 100644 lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart create mode 100644 test/features/consultant/become_consultant/presentation/pages/passport_data_screen_test.dart create mode 100644 test/fixtures/consultant/sbp_banks.json diff --git a/lib/features/consultant/become_consultant/models/consultant_profile_model.dart b/lib/features/consultant/become_consultant/models/consultant_profile_model.dart index 535b58fd..dd9e01f5 100644 --- a/lib/features/consultant/become_consultant/models/consultant_profile_model.dart +++ b/lib/features/consultant/become_consultant/models/consultant_profile_model.dart @@ -6,24 +6,80 @@ part 'consultant_profile_model.g.dart'; @freezed abstract class ConsultantProfileModel with _$ConsultantProfileModel { + const ConsultantProfileModel._(); + const factory ConsultantProfileModel({ - @JsonKey(name: 'has_medical_education', includeIfNull: false) bool? hasMedicalEducation, - @JsonKey(name: 'social_links', includeIfNull: false) SocialLinksModel? socialLinks, - @JsonKey(name: 'cert_photos', includeIfNull: false) List? photos, - @JsonKey(name: 'completed_courses', includeIfNull: false) List? certificates, + @JsonKey(name: 'has_medical_education', includeIfNull: false) bool? hasMedicalEducation, + @JsonKey(name: 'social_links', includeIfNull: false) SocialLinksModel? socialLinks, + @JsonKey(name: 'cert_photos', includeIfNull: false) List? photos, + @JsonKey(name: 'completed_courses', includeIfNull: false) List? certificates, + @JsonKey(name: 'legal_type', includeIfNull: false) String? legalType, + @JsonKey(name: 'last_name', includeIfNull: false) String? lastName, + @JsonKey(name: 'first_name', includeIfNull: false) String? firstName, + @JsonKey(name: 'middle_name', includeIfNull: false) String? middleName, + @JsonKey(name: 'inn', includeIfNull: false) String? inn, + @JsonKey(name: 'snils', includeIfNull: false) String? snils, + @JsonKey(name: 'passport_series', includeIfNull: false) String? passportSeries, + @JsonKey(name: 'passport_number', includeIfNull: false) String? passportNumber, + @JsonKey(name: 'passport_issue_date', includeIfNull: false) String? passportIssueDate, + @JsonKey(name: 'passport_issuer', includeIfNull: false) String? passportIssuer, + @JsonKey(name: 'passport_issuer_code', includeIfNull: false) String? passportIssuerCode, + @JsonKey(name: 'birth_date', includeIfNull: false) String? birthDate, + @JsonKey(name: 'birth_place', includeIfNull: false) String? birthPlace, + @JsonKey(name: 'registration_address', includeIfNull: false) String? registrationAddress, + @JsonKey(name: 'residence_address', includeIfNull: false) String? residenceAddress, + @JsonKey(name: 'citizenship', includeIfNull: false) String? citizenship, + @JsonKey(name: 'tax_resident_us', includeIfNull: false) bool? taxResidentUs, + @JsonKey(name: 'foreign_tax_resident', includeIfNull: false) bool? foreignTaxResident, + @JsonKey(name: 'sbp_phone', includeIfNull: false) String? sbpPhone, + @JsonKey(name: 'sbp_bank_member_id', includeIfNull: false) String? sbpBankMemberId, + @JsonKey(name: 'agreement_accepted', includeIfNull: false) bool? agreementAccepted, + @JsonKey(name: 'frdo_agreement_accepted', includeIfNull: false) bool? frdoAgreementAccepted, }) = _ConsultantProfileModel; factory ConsultantProfileModel.fromJson(Map json) => _$ConsultantProfileModelFromJson(json); + + bool get hasValidPassportData { + final last = lastName ?? ''; + final first = firstName ?? ''; + final innValue = inn ?? ''; + final snilsValue = snils ?? ''; + final series = passportSeries ?? ''; + final number = passportNumber ?? ''; + final issueDate = passportIssueDate ?? ''; + final issuer = passportIssuer ?? ''; + final issuerCode = passportIssuerCode ?? ''; + final birth = birthDate ?? ''; + final birthPlaceValue = birthPlace ?? ''; + final registration = registrationAddress ?? ''; + final residence = residenceAddress ?? ''; + final citizenshipValue = citizenship ?? ''; + + return last.trim().isNotEmpty && + first.trim().isNotEmpty && + RegExp(r'^\d{12}$').hasMatch(innValue) && + RegExp(r'^\d{3}-\d{3}-\d{3} \d{2}$').hasMatch(snilsValue) && + RegExp(r'^\d{4}$').hasMatch(series) && + RegExp(r'^\d{6}$').hasMatch(number) && + RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(issueDate) && + issuer.trim().isNotEmpty && + RegExp(r'^\d{3}-\d{3}$').hasMatch(issuerCode) && + RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(birth) && + birthPlaceValue.trim().isNotEmpty && + registration.trim().isNotEmpty && + residence.trim().isNotEmpty && + RegExp(r'^[A-Za-z]{2}$').hasMatch(citizenshipValue); + } } @freezed abstract class SocialLinksModel with _$SocialLinksModel { const factory SocialLinksModel({ - @JsonKey(name: 'vk_link', includeIfNull: false) String? vkLink, - @JsonKey(name: 'tg_link', includeIfNull: false) String? tgLink, - @JsonKey(name: 'fb_link', includeIfNull: false) String? fbLink, - @JsonKey(name: 'insta_link', includeIfNull: false) String? instaLink, - @JsonKey(name: 'personal_site_link', includeIfNull: false) String? personalSiteLink, + @JsonKey(name: 'vk_link', includeIfNull: false) String? vkLink, + @JsonKey(name: 'tg_link', includeIfNull: false) String? tgLink, + @JsonKey(name: 'fb_link', includeIfNull: false) String? fbLink, + @JsonKey(name: 'insta_link', includeIfNull: false) String? instaLink, + @JsonKey(name: 'personal_site_link', includeIfNull: false) String? personalSiteLink, }) = _SocialLinksModel; factory SocialLinksModel.fromJson(Map json) => _$SocialLinksModelFromJson(json); diff --git a/lib/features/consultant/become_consultant/models/consultation_request_model.dart b/lib/features/consultant/become_consultant/models/consultation_request_model.dart index 13cc0e77..051f517f 100644 --- a/lib/features/consultant/become_consultant/models/consultation_request_model.dart +++ b/lib/features/consultant/become_consultant/models/consultation_request_model.dart @@ -12,11 +12,61 @@ abstract class ConsultationRequestModel with _$ConsultationRequestModel { @JsonKey(name: 'education_resource') required EducationModel educationModel, @JsonKey(name: 'cert_photos') required List photos, @JsonKey(name: 'canceled_moderation') CanceledModerationModel? canceledModeration, + @JsonKey(name: 'legal_entity_resource') LegalEntityModel? legalEntity, }) = _ConsultationRequestModel; factory ConsultationRequestModel.fromJson(Map json) => _$ConsultationRequestModelFromJson(json); } +@freezed +abstract class LegalEntityModel with _$LegalEntityModel { + const LegalEntityModel._(); + + const factory LegalEntityModel({ + required int id, + required String type, + String? inn, + String? snils, + String? state, + @JsonKey(name: 'alfa_scoring_status') String? alfaScoringStatus, + @JsonKey(name: 'alfa_scoring_request_id') String? alfaScoringRequestId, + @JsonKey(name: 'last_name') String? lastName, + @JsonKey(name: 'first_name') String? firstName, + @JsonKey(name: 'middle_name') String? middleName, + @JsonKey(name: 'passport_series') String? passportSeries, + @JsonKey(name: 'passport_number') String? passportNumber, + @JsonKey(name: 'passport_issue_date') String? passportIssueDate, + @JsonKey(name: 'passport_issuer') String? passportIssuer, + @JsonKey(name: 'passport_issuer_code') String? passportIssuerCode, + @JsonKey(name: 'birth_date') String? birthDate, + @JsonKey(name: 'birth_place') String? birthPlace, + @JsonKey(name: 'registration_address') String? registrationAddress, + @JsonKey(name: 'residence_address') String? residenceAddress, + String? citizenship, + @JsonKey(name: 'tax_resident_us') bool? taxResidentUs, + @JsonKey(name: 'foreign_tax_resident') bool? foreignTaxResident, + @JsonKey(name: 'sbp_phone') String? sbpPhone, + @JsonKey(name: 'sbp_bank_member_id') String? sbpBankMemberId, + }) = _LegalEntityModel; + + factory LegalEntityModel.fromJson(Map json) => _$LegalEntityModelFromJson(json); + + /// Normalizes API date (`Y-m-d` or ISO datetime) to `Y-m-d`. + static String? normalizeApiDate(String? value) { + if (value == null || value.isEmpty) { + return null; + } + if (value.length >= 10 && RegExp(r'^\d{4}-\d{2}-\d{2}').hasMatch(value)) { + return value.substring(0, 10); + } + return value; + } + + String? get normalizedPassportIssueDate => normalizeApiDate(passportIssueDate); + + String? get normalizedBirthDate => normalizeApiDate(birthDate); +} + @freezed abstract class CertPhotosModel with _$CertPhotosModel { const factory CertPhotosModel({ @@ -29,7 +79,7 @@ abstract class CertPhotosModel with _$CertPhotosModel { enum ModerationStatus { approved, created, - canceled + canceled, } @freezed diff --git a/lib/features/consultant/become_consultant/models/sbp_bank_model.dart b/lib/features/consultant/become_consultant/models/sbp_bank_model.dart new file mode 100644 index 00000000..c7bf7976 --- /dev/null +++ b/lib/features/consultant/become_consultant/models/sbp_bank_model.dart @@ -0,0 +1,17 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'sbp_bank_model.freezed.dart'; +part 'sbp_bank_model.g.dart'; + +@freezed +abstract class SbpBankModel with _$SbpBankModel { + const factory SbpBankModel({ + @JsonKey(name: 'bank_member_id') required String bankMemberId, + @JsonKey(name: 'bank_name') required String bankName, + @JsonKey(name: 'short_name') String? shortName, + @JsonKey(name: 'logo_url') String? logoUrl, + String? bic, + }) = _SbpBankModel; + + factory SbpBankModel.fromJson(Map json) => _$SbpBankModelFromJson(json); +} diff --git a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart index 0410ae04..9cae8fa5 100644 --- a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart +++ b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart @@ -2,6 +2,8 @@ import 'dart:developer'; import 'dart:io'; import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -11,21 +13,60 @@ class RegisterConsultantCubit extends Cubit { final ConsultantRepository consultantRepository; bool _update = false; + static const String individualPersonLegalType = 'individualPerson'; + RegisterConsultantCubit({required this.consultantRepository}) : super( const RegisterConsultantState( - consultant: ConsultantProfileModel(socialLinks: SocialLinksModel(), hasMedicalEducation: true), + consultant: ConsultantProfileModel(socialLinks: SocialLinksModel(), hasMedicalEducation: true, legalType: individualPersonLegalType), loading: true, acceptOffer: false, + acceptFrdoAgreement: false, isUpdating: false, ), ); + Future> _loadSbpBanks() async { + try { + return await consultantRepository.getSbpBanks(); + } catch (e, st) { + await reportFrom(e, st, this); + log(e.toString()); + return const []; + } + } + + List searchSbpBanks(String query) { + final normalized = query.trim().toLowerCase(); + if (normalized.isEmpty) { + return state.sbpBanks; + } + return state.sbpBanks.where((bank) { + final name = bank.bankName.toLowerCase(); + final shortName = (bank.shortName ?? '').toLowerCase(); + final bic = (bank.bic ?? '').toLowerCase(); + return name.contains(normalized) || shortName.contains(normalized) || bic.contains(normalized); + }).toList(); + } + + SbpBankModel? findSbpBankById(String? bankMemberId) { + if (bankMemberId == null || bankMemberId.isEmpty) { + return null; + } + for (final bank in state.sbpBanks) { + if (bank.bankMemberId == bankMemberId) { + return bank; + } + } + return null; + } + Future getMyRequestStatus(String slug) async { try { _update = true; final myRequestStatus = await consultantRepository.getMyRequestStatus(slug); final certPhotos = await consultantRepository.getConsultantCertPhotos(); + final sbpBanks = await _loadSbpBanks(); List? certificates = myRequestStatus.educationModel.completedCourses?.map((courseMap) { final course = courseMap.values.first; @@ -37,6 +78,8 @@ class RegisterConsultantCubit extends Cubit { return (link != null && link.isNotEmpty) ? link : null; } + final legal = myRequestStatus.legalEntity; + emit( RegisterConsultantState( consultant: ConsultantProfileModel( @@ -49,11 +92,33 @@ class RegisterConsultantCubit extends Cubit { personalSiteLink: sanitizeLink(myRequestStatus.educationModel.personalSiteLink), ), hasMedicalEducation: myRequestStatus.educationModel.hasMedicalEducation == 1, + legalType: legal?.type ?? individualPersonLegalType, + lastName: _emptyToNull(legal?.lastName), + firstName: _emptyToNull(legal?.firstName), + middleName: _emptyToNull(legal?.middleName), + inn: _emptyToNull(legal?.inn), + snils: _emptyToNull(legal?.snils), + passportSeries: _emptyToNull(legal?.passportSeries), + passportNumber: _emptyToNull(legal?.passportNumber), + passportIssueDate: legal?.normalizedPassportIssueDate, + passportIssuer: _emptyToNull(legal?.passportIssuer), + passportIssuerCode: _emptyToNull(legal?.passportIssuerCode), + birthDate: legal?.normalizedBirthDate, + birthPlace: _emptyToNull(legal?.birthPlace), + registrationAddress: _emptyToNull(legal?.registrationAddress), + residenceAddress: _emptyToNull(legal?.residenceAddress), + citizenship: _emptyToNull(legal?.citizenship) ?? 'RU', + taxResidentUs: legal?.taxResidentUs ?? false, + foreignTaxResident: legal?.foreignTaxResident ?? false, + sbpPhone: _emptyToNull(legal?.sbpPhone), + sbpBankMemberId: _emptyToNull(legal?.sbpBankMemberId), ), acceptOffer: false, + acceptFrdoAgreement: false, loading: false, requestModel: myRequestStatus, certPhotos: certPhotos, + sbpBanks: sbpBanks, isUpdating: _update, error: null, ), @@ -65,10 +130,31 @@ class RegisterConsultantCubit extends Cubit { } } + String? _emptyToNull(String? value) { + if (value == null || value.trim().isEmpty) { + return null; + } + return value.trim(); + } + Future disableLoading() async { try { final certPhotos = await consultantRepository.getConsultantCertPhotos(); - emit(state.copyWith(loading: false, certPhotos: certPhotos, error: null)); + final sbpBanks = await _loadSbpBanks(); + emit( + state.copyWith( + loading: false, + certPhotos: certPhotos, + sbpBanks: sbpBanks, + error: null, + consultant: state.consultant.copyWith( + legalType: individualPersonLegalType, + citizenship: state.consultant.citizenship ?? 'RU', + taxResidentUs: state.consultant.taxResidentUs ?? false, + foreignTaxResident: state.consultant.foreignTaxResident ?? false, + ), + ), + ); } catch (e, st) { await reportFrom(e, st, this); log(e.toString()); @@ -86,13 +172,79 @@ class RegisterConsultantCubit extends Cubit { } } - void updateConsultantProfileModel(ConsultantProfileModel consultant, [bool acceptOffer = false]) { - emit(state.copyWith(consultant: consultant, acceptOffer: acceptOffer, loading: false, error: null)); + void updateConsultantProfileModel(ConsultantProfileModel consultant, [bool? acceptOffer, bool? acceptFrdoAgreement]) { + emit( + state.copyWith( + consultant: consultant, + acceptOffer: acceptOffer ?? state.acceptOffer, + acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement, + loading: false, + error: null, + ), + ); + } + + void updateAgreements({bool? acceptOffer, bool? acceptFrdoAgreement}) { + emit(state.copyWith(acceptOffer: acceptOffer ?? state.acceptOffer, acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement, error: null)); + } + + bool get hasAcceptedRequiredAgreements { + if (_update) { + return state.acceptOffer; + } + return state.acceptOffer && state.acceptFrdoAgreement; + } + + ConsultantProfileModel buildSubmitPayload() { + final withLegalType = state.consultant.copyWith( + legalType: individualPersonLegalType, + citizenship: state.consultant.citizenship ?? 'RU', + taxResidentUs: state.consultant.taxResidentUs ?? false, + foreignTaxResident: state.consultant.foreignTaxResident ?? false, + certificates: state.consultant.certificates + ?.map((course) => CertificatesModel(title: course.title, date: LegalEntityModel.normalizeApiDate(course.date) ?? course.date)) + .toList(), + ); + + if (_update) { + return ConsultantProfileModel( + hasMedicalEducation: withLegalType.hasMedicalEducation, + socialLinks: withLegalType.socialLinks, + photos: withLegalType.photos, + certificates: withLegalType.certificates, + legalType: withLegalType.legalType, + lastName: withLegalType.lastName, + firstName: withLegalType.firstName, + middleName: withLegalType.middleName, + inn: withLegalType.inn, + snils: withLegalType.snils, + passportSeries: withLegalType.passportSeries, + passportNumber: withLegalType.passportNumber, + passportIssueDate: withLegalType.passportIssueDate, + passportIssuer: withLegalType.passportIssuer, + passportIssuerCode: withLegalType.passportIssuerCode, + birthDate: withLegalType.birthDate, + birthPlace: withLegalType.birthPlace, + registrationAddress: withLegalType.registrationAddress, + residenceAddress: withLegalType.residenceAddress, + citizenship: withLegalType.citizenship, + taxResidentUs: withLegalType.taxResidentUs, + foreignTaxResident: withLegalType.foreignTaxResident, + sbpPhone: withLegalType.sbpPhone, + sbpBankMemberId: withLegalType.sbpBankMemberId, + ); + } + + return withLegalType.copyWith(agreementAccepted: state.acceptOffer, frdoAgreementAccepted: state.acceptFrdoAgreement); } Future registerConsultant() async { emit(state.copyWith(error: null)); try { + if (!state.consultant.hasValidPassportData) { + emit(state.copyWith(error: 'Заполните паспортные данные')); + return; + } if (state.newAddedPhotos.isNotEmpty) { await consultantRepository.storeConsultantCertPhotos(files: state.newAddedPhotos); } @@ -101,7 +253,7 @@ class RegisterConsultantCubit extends Cubit { await consultantRepository.deletePhoto(id); } } - await consultantRepository.registerConsultant(consultant: state.consultant, update: _update); + await consultantRepository.registerConsultant(consultant: buildSubmitPayload(), update: _update); } catch (e, st) { await reportFrom(e, st, this); log(e.toString()); diff --git a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart index e9635eb6..a39a1313 100644 --- a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart +++ b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'register_consultant_state.freezed.dart'; @@ -12,10 +13,12 @@ abstract class RegisterConsultantState with _$RegisterConsultantState { const factory RegisterConsultantState({ required ConsultantProfileModel consultant, required bool acceptOffer, + @Default(false) bool acceptFrdoAgreement, required bool loading, required bool isUpdating, ConsultationRequestModel? requestModel, @Default([]) List certPhotos, + @Default([]) List sbpBanks, @Default([]) List newAddedPhotos, @Default([]) List deletedPhotoIds, String? error, diff --git a/lib/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart index b2b26c0e..afff462e 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:eatvio_app/core/utils/date_time_utils.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; class CertificatesAndDiplomasScreen extends StatefulWidget { const CertificatesAndDiplomasScreen({super.key}); @@ -231,10 +232,15 @@ class _CertificatesAndDiplomasScreenState extends State().updateConsultantProfileModel( context.read().state.consultant.copyWith( - certificates: diplomasList.map((e) => CertificatesModel(title: e.textEditingController.text, date: e.date.toString())).toList().sublist( - 0, - diplomasList.length - 1, - ), + certificates: diplomasList + .map( + (e) => CertificatesModel( + title: e.textEditingController.text, + date: e.date == null ? '' : DateFormat('yyyy-MM-dd').format(e.date!), + ), + ) + .toList() + .sublist(0, diplomasList.length - 1), ), ); } diff --git a/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart index 734387f1..7fd501a6 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart @@ -3,6 +3,7 @@ import 'package:eatvio_app/core/const.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -15,10 +16,28 @@ class LastStepScreen extends StatefulWidget { } class _LastStepScreenState extends State { - bool checkboxState = false; + static const _frdoUrl = + 'https://obrnadzor.gov.ru/gosudarstvennye-uslugi-i-funkczii/7701537808-gosfunction/formirovanie-i-vedenie-federalnogo-reestra-svedenij-o-dokumentah-ob-obrazovanii-i-ili-o-kvalifikaczii-dokumentah-ob-obuchenii/'; + + late final TapGestureRecognizer _frdoTapRecognizer; + + @override + void initState() { + super.initState(); + _frdoTapRecognizer = TapGestureRecognizer()..onTap = () async => await launchUrl(Uri.parse(_frdoUrl)); + } + + @override + void dispose() { + _frdoTapRecognizer.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { + final cubit = context.watch(); + final state = cubit.state; + return SingleChildScrollView( padding: const EdgeInsets.only(bottom: 80, top: 16), child: Column( @@ -31,29 +50,56 @@ class _LastStepScreenState extends State { ), const SizedBox(height: 16), CheckboxListTile( - value: checkboxState, + value: state.acceptOffer, title: Text( hidePlans ? 'Я ознакомлен и принимаю политику конфиденциальности и защиту персональных данных' - : 'Я ознакомлен и принимаю договор публичной офёрты', - style: const TextStyle(fontSize: 18, height: 1), + : 'Я ознакомлен и принимаю публичную оферту и политику конфиденциальности', + style: const TextStyle(fontSize: 18, height: 1.2), ), - onChanged: (_) { - setState(() { - checkboxState = !checkboxState; - }); - context.read().updateConsultantProfileModel(context.read().state.consultant, checkboxState); + onChanged: (value) { + cubit.updateAgreements(acceptOffer: value ?? false); }, activeColor: appTheme.blueA400, contentPadding: const EdgeInsets.symmetric(horizontal: 16), + controlAffinity: ListTileControlAffinity.leading, ), + if (!state.isUpdating) ...[ + CheckboxListTile( + value: state.acceptFrdoAgreement, + title: Text.rich( + TextSpan( + style: const TextStyle(fontSize: 18, height: 1.2, color: Color(0xFF323F4B)), + children: [ + const TextSpan(text: 'Обязательное требование: Один из моих документов об образовании есть в реестре '), + TextSpan( + text: 'ФРДО', + style: TextStyle( + color: appTheme.blueA400, + decoration: TextDecoration.underline, + ), + recognizer: _frdoTapRecognizer, + ), + ], + ), + ), + onChanged: (value) { + cubit.updateAgreements(acceptFrdoAgreement: value ?? false); + }, + activeColor: appTheme.blueA400, + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + controlAffinity: ListTileControlAffinity.leading, + ), + ], Padding(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: _divider(context)), Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: TextButton( - onPressed: () async => await launchUrl(Uri.parse(hidePlans ? '${sl().baseUrl}/docs/privacy.pdf' : '${sl().baseUrl}/oferta_bs.pdf')), + onPressed: () async => await launchUrl( + Uri.parse(hidePlans ? '${sl().baseUrl}/docs/privacy.pdf' : '${sl().baseUrl}/oferta_bs.pdf'), + ), child: Text( - hidePlans ? 'Политика конфиденциальности и пользовательское соглашение' : 'Договор офёрты', + hidePlans ? 'Политика конфиденциальности и пользовательское соглашение' : 'Договор оферты', style: const TextStyle(height: 1, fontSize: 16, color: Color(0xFF323F4B), decoration: TextDecoration.underline), ), ), diff --git a/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart new file mode 100644 index 00000000..f3eb774b --- /dev/null +++ b/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart @@ -0,0 +1,527 @@ +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/widgets/custom_text_form_field.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_typeahead/flutter_typeahead.dart'; +import 'package:mask_text_input_formatter/mask_text_input_formatter.dart'; + +class PassportDataScreen extends StatefulWidget { + const PassportDataScreen({super.key}); + + @override + State createState() => _PassportDataScreenState(); +} + +class _PassportDataScreenState extends State { + late final TextEditingController lastNameController; + late final TextEditingController firstNameController; + late final TextEditingController middleNameController; + late final TextEditingController innController; + late final TextEditingController snilsController; + late final TextEditingController seriesController; + late final TextEditingController numberController; + late final TextEditingController issueDateController; + late final TextEditingController issuerController; + late final TextEditingController issuerCodeController; + late final TextEditingController birthDateController; + late final TextEditingController birthPlaceController; + late final TextEditingController citizenshipController; + late final TextEditingController registrationAddressController; + late final TextEditingController residenceAddressController; + late final TextEditingController sbpPhoneController; + late final TextEditingController sbpBankController; + final sbpBankSuggestionsController = SuggestionsController(); + + bool taxResidentUs = false; + bool foreignTaxResident = false; + String? selectedSbpBankMemberId; + String? selectedSbpBankName; + + final snilsMask = MaskTextInputFormatter( + mask: '###-###-### ##', + filter: {'#': RegExp(r'[0-9]')}, + type: MaskAutoCompletionType.lazy, + ); + final dateMaskIssue = MaskTextInputFormatter( + mask: '##.##.####', + filter: {'#': RegExp(r'[0-9]')}, + type: MaskAutoCompletionType.lazy, + ); + final dateMaskBirth = MaskTextInputFormatter( + mask: '##.##.####', + filter: {'#': RegExp(r'[0-9]')}, + type: MaskAutoCompletionType.lazy, + ); + final issuerCodeMask = MaskTextInputFormatter( + mask: '###-###', + filter: {'#': RegExp(r'[0-9]')}, + type: MaskAutoCompletionType.lazy, + ); + final phoneMask = MaskTextInputFormatter( + mask: '+7 (###) ### ## ##', + filter: {'#': RegExp(r'[0-9]')}, + type: MaskAutoCompletionType.lazy, + ); + + @override + void initState() { + super.initState(); + final consultant = context.read().state.consultant; + lastNameController = TextEditingController(text: consultant.lastName ?? ''); + firstNameController = TextEditingController( + text: consultant.firstName ?? '', + ); + middleNameController = TextEditingController( + text: consultant.middleName ?? '', + ); + innController = TextEditingController(text: consultant.inn ?? ''); + snilsController = TextEditingController(text: consultant.snils ?? ''); + seriesController = TextEditingController( + text: consultant.passportSeries ?? '', + ); + numberController = TextEditingController( + text: consultant.passportNumber ?? '', + ); + issueDateController = TextEditingController( + text: _apiDateToUi(consultant.passportIssueDate), + ); + issuerController = TextEditingController( + text: consultant.passportIssuer ?? '', + ); + issuerCodeController = TextEditingController( + text: consultant.passportIssuerCode ?? '', + ); + birthDateController = TextEditingController( + text: _apiDateToUi(consultant.birthDate), + ); + birthPlaceController = TextEditingController( + text: consultant.birthPlace ?? '', + ); + citizenshipController = TextEditingController( + text: consultant.citizenship ?? 'RU', + ); + registrationAddressController = TextEditingController( + text: consultant.registrationAddress ?? '', + ); + residenceAddressController = TextEditingController( + text: consultant.residenceAddress ?? '', + ); + sbpPhoneController = TextEditingController(text: consultant.sbpPhone ?? ''); + selectedSbpBankMemberId = consultant.sbpBankMemberId; + final selectedBank = context + .read() + .findSbpBankById(consultant.sbpBankMemberId); + selectedSbpBankName = selectedBank?.bankName; + sbpBankController = TextEditingController(text: selectedSbpBankName ?? ''); + taxResidentUs = consultant.taxResidentUs ?? false; + foreignTaxResident = consultant.foreignTaxResident ?? false; + } + + void _hydrateSbpBankNameIfNeeded() { + if (selectedSbpBankMemberId == null || selectedSbpBankMemberId!.isEmpty) { + return; + } + if (sbpBankController.text.trim().isNotEmpty) { + return; + } + final selectedBank = context + .read() + .findSbpBankById(selectedSbpBankMemberId); + if (selectedBank == null) { + return; + } + selectedSbpBankName = selectedBank.bankName; + sbpBankController.text = selectedBank.bankName; + } + + @override + void dispose() { + lastNameController.dispose(); + firstNameController.dispose(); + middleNameController.dispose(); + innController.dispose(); + snilsController.dispose(); + seriesController.dispose(); + numberController.dispose(); + issueDateController.dispose(); + issuerController.dispose(); + issuerCodeController.dispose(); + birthDateController.dispose(); + birthPlaceController.dispose(); + citizenshipController.dispose(); + registrationAddressController.dispose(); + residenceAddressController.dispose(); + sbpPhoneController.dispose(); + sbpBankController.dispose(); + sbpBankSuggestionsController.dispose(); + super.dispose(); + } + + String _apiDateToUi(String? apiDate) { + if (apiDate == null || apiDate.isEmpty) { + return ''; + } + final parts = apiDate.split('-'); + if (parts.length != 3) { + return apiDate; + } + return '${parts[2]}.${parts[1]}.${parts[0]}'; + } + + String? _uiDateToApi(String uiDate) { + final digits = uiDate.replaceAll(RegExp(r'\D'), ''); + if (digits.length != 8) { + return null; + } + return '${digits.substring(4, 8)}-${digits.substring(2, 4)}-${digits.substring(0, 2)}'; + } + + String? _uiPhoneToApi(String uiPhone) { + final digits = uiPhone.replaceAll(RegExp(r'\D'), ''); + if (digits.isEmpty) { + return null; + } + if (digits.length == 11 && digits.startsWith('7')) { + return '+$digits'; + } + if (digits.length == 10) { + return '+7$digits'; + } + return uiPhone.trim().isEmpty ? null : uiPhone.trim(); + } + + void _syncToCubit() { + final cubit = context.read(); + final current = cubit.state.consultant; + cubit.updateConsultantProfileModel( + current.copyWith( + legalType: RegisterConsultantCubit.individualPersonLegalType, + lastName: lastNameController.text.trim().isEmpty + ? null + : lastNameController.text.trim(), + firstName: firstNameController.text.trim().isEmpty + ? null + : firstNameController.text.trim(), + middleName: middleNameController.text.trim().isEmpty + ? null + : middleNameController.text.trim(), + inn: innController.text.trim().isEmpty + ? null + : innController.text.trim(), + snils: snilsController.text.trim().isEmpty + ? null + : snilsController.text.trim(), + passportSeries: seriesController.text.trim().isEmpty + ? null + : seriesController.text.trim(), + passportNumber: numberController.text.trim().isEmpty + ? null + : numberController.text.trim(), + passportIssueDate: _uiDateToApi(issueDateController.text), + passportIssuer: issuerController.text.trim().isEmpty + ? null + : issuerController.text.trim(), + passportIssuerCode: issuerCodeController.text.trim().isEmpty + ? null + : issuerCodeController.text.trim(), + birthDate: _uiDateToApi(birthDateController.text), + birthPlace: birthPlaceController.text.trim().isEmpty + ? null + : birthPlaceController.text.trim(), + citizenship: citizenshipController.text.trim().isEmpty + ? 'RU' + : citizenshipController.text.trim().toUpperCase(), + registrationAddress: registrationAddressController.text.trim().isEmpty + ? null + : registrationAddressController.text.trim(), + residenceAddress: residenceAddressController.text.trim().isEmpty + ? null + : residenceAddressController.text.trim(), + taxResidentUs: taxResidentUs, + foreignTaxResident: foreignTaxResident, + sbpPhone: _uiPhoneToApi(sbpPhoneController.text), + sbpBankMemberId: selectedSbpBankMemberId, + ), + cubit.state.acceptOffer, + cubit.state.acceptFrdoAgreement, + ); + } + + void _selectSbpBank(SbpBankModel bank) { + setState(() { + selectedSbpBankMemberId = bank.bankMemberId; + selectedSbpBankName = bank.bankName; + sbpBankController.text = bank.bankName; + }); + _syncToCubit(); + } + + void _clearSbpBankIfNeeded(String value) { + if (selectedSbpBankMemberId != null && value != selectedSbpBankName) { + setState(() { + selectedSbpBankMemberId = null; + selectedSbpBankName = null; + }); + _syncToCubit(); + } + } + + Widget _sectionTitle(String title) { + return Padding( + padding: const EdgeInsets.only(top: 8, bottom: 12), + child: Text( + title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + height: 1.2, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listenWhen: (previous, current) => previous.sbpBanks != current.sbpBanks, + listener: (context, state) => _hydrateSbpBankNameIfNeeded(), + child: SingleChildScrollView( + padding: const EdgeInsets.only( + bottom: 80, + right: 16, + left: 16, + top: 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Паспортные данные', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + height: 1.1, + ), + ), + _sectionTitle('Персональные данные (как в паспорте)'), + CustomTextFormField( + hintText: 'Фамилия', + controller: lastNameController, + onChanged: (_) => _syncToCubit(), + ), + const SizedBox(height: 12), + CustomTextFormField( + hintText: 'Имя', + controller: firstNameController, + onChanged: (_) => _syncToCubit(), + ), + const SizedBox(height: 12), + CustomTextFormField( + hintText: 'Отчество', + controller: middleNameController, + onChanged: (_) => _syncToCubit(), + ), + _sectionTitle('Идентификация'), + CustomTextFormField( + hintText: 'ИНН (12 цифр)', + controller: innController, + keyboardType: TextInputType.number, + maxLength: 12, + onChanged: (_) => _syncToCubit(), + ), + const SizedBox(height: 12), + CustomTextFormField( + hintText: 'СНИЛС', + controller: snilsController, + keyboardType: TextInputType.number, + inputFormatters: [snilsMask], + onChanged: (_) => _syncToCubit(), + ), + const SizedBox(height: 12), + CustomTextFormField( + hintText: 'Дата рождения (ДД.ММ.ГГГГ)', + controller: birthDateController, + keyboardType: TextInputType.number, + inputFormatters: [dateMaskBirth], + onChanged: (_) => _syncToCubit(), + ), + const SizedBox(height: 12), + CustomTextFormField( + hintText: 'Место рождения', + controller: birthPlaceController, + onChanged: (_) => _syncToCubit(), + ), + const SizedBox(height: 12), + CustomTextFormField( + hintText: 'Гражданство (ISO, например RU)', + controller: citizenshipController, + maxLength: 2, + onChanged: (_) => _syncToCubit(), + ), + const SizedBox(height: 12), + CustomTextFormField( + hintText: 'Адрес регистрации', + controller: registrationAddressController, + onChanged: (_) => _syncToCubit(), + ), + const SizedBox(height: 12), + CustomTextFormField( + hintText: 'Адрес проживания', + controller: residenceAddressController, + onChanged: (_) => _syncToCubit(), + ), + const SizedBox(height: 8), + CheckboxListTile( + value: taxResidentUs, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + activeColor: appTheme.blueA400, + title: const Text( + 'Я являюсь налоговым резидентом США', + style: TextStyle(fontSize: 16, height: 1.2), + ), + onChanged: (value) { + setState(() => taxResidentUs = value ?? false); + _syncToCubit(); + }, + ), + CheckboxListTile( + value: foreignTaxResident, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + activeColor: appTheme.blueA400, + title: const Text( + 'Я являюсь иностранным налоговым резидентом', + style: TextStyle(fontSize: 16, height: 1.2), + ), + onChanged: (value) { + setState(() => foreignTaxResident = value ?? false); + _syncToCubit(); + }, + ), + _sectionTitle('Паспорт'), + Row( + children: [ + Expanded( + child: CustomTextFormField( + hintText: 'Серия', + controller: seriesController, + keyboardType: TextInputType.number, + maxLength: 4, + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(width: 8), + Expanded( + child: CustomTextFormField( + hintText: 'Номер', + controller: numberController, + keyboardType: TextInputType.number, + maxLength: 6, + onChanged: (_) => _syncToCubit(), + ), + ), + ], + ), + const SizedBox(height: 12), + CustomTextFormField( + hintText: 'Дата выдачи (ДД.ММ.ГГГГ)', + controller: issueDateController, + keyboardType: TextInputType.number, + inputFormatters: [dateMaskIssue], + onChanged: (_) => _syncToCubit(), + ), + const SizedBox(height: 12), + CustomTextFormField( + hintText: 'Код подразделения', + controller: issuerCodeController, + keyboardType: TextInputType.number, + inputFormatters: [issuerCodeMask], + onChanged: (_) => _syncToCubit(), + ), + const SizedBox(height: 12), + CustomTextFormField( + hintText: 'Кем выдан', + controller: issuerController, + onChanged: (_) => _syncToCubit(), + ), + _sectionTitle('Реквизиты СБП'), + CustomTextFormField( + hintText: 'Телефон для выплат через СБП', + controller: sbpPhoneController, + keyboardType: TextInputType.phone, + inputFormatters: [phoneMask], + onChanged: (_) => _syncToCubit(), + ), + const SizedBox(height: 12), + TypeAheadField( + debounceDuration: const Duration(milliseconds: 250), + controller: sbpBankController, + suggestionsController: sbpBankSuggestionsController, + builder: (context, controller, focusNode) => CustomTextFormField( + controller: controller, + focusNode: focusNode, + hintText: 'Банк получатель (поиск)', + onChanged: (value) { + _clearSbpBankIfNeeded(value); + }, + ), + suggestionsCallback: (query) async { + return context.read().searchSbpBanks( + query, + ); + }, + itemBuilder: (context, bank) { + return ListTile( + dense: true, + title: Text( + bank.bankName, + style: const TextStyle(fontSize: 16, color: Colors.black), + ), + subtitle: bank.shortName == null || bank.shortName!.isEmpty + ? null + : Text( + bank.shortName!, + style: const TextStyle( + fontSize: 13, + color: Colors.black54, + ), + ), + ); + }, + decorationBuilder: (context, child) => Material( + type: MaterialType.card, + elevation: 4, + borderRadius: BorderRadius.circular(8), + child: child, + ), + emptyBuilder: (context) => const Padding( + padding: EdgeInsets.all(12), + child: Text( + 'Ничего не найдено', + style: TextStyle(color: Colors.black54), + ), + ), + onSelected: _selectSbpBank, + ), + const SizedBox(height: 16), + const Divider(height: 0.4, thickness: 0.4, color: Colors.black26), + const SizedBox(height: 16), + Text( + 'Данные используются для проверки и скоринга. Заполните поля в соответствии с паспортом РФ.', + style: TextStyle( + fontSize: 14, + height: 1.2, + color: appTheme.blueA400, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart index b8fed20b..a754de27 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart @@ -1,5 +1,7 @@ import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; class RegistrationAsScreen extends StatefulWidget { const RegistrationAsScreen({super.key}); @@ -11,7 +13,23 @@ class RegistrationAsScreen extends StatefulWidget { enum RegistrationAsType { physical, selfBusy, iP, ooo } class _RegistrationAsScreenState extends State { - RegistrationAsType registrationAsType = RegistrationAsType.physical; + static const RegistrationAsType registrationAsType = RegistrationAsType.physical; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + final cubit = context.read(); + cubit.updateConsultantProfileModel( + cubit.state.consultant.copyWith(legalType: RegisterConsultantCubit.individualPersonLegalType), + cubit.state.acceptOffer, + cubit.state.acceptFrdoAgreement, + ); + }); + } @override Widget build(BuildContext context) { @@ -36,11 +54,7 @@ class _RegistrationAsScreenState extends State { groupValue: registrationAsType, activeColor: appTheme.blueA400, // ignore: deprecated_member_use - onChanged: (value) { - setState(() { - registrationAsType = value!; - }); - }, + onChanged: (_) {}, ), const Expanded( child: Padding( @@ -58,16 +72,12 @@ class _RegistrationAsScreenState extends State { groupValue: registrationAsType, activeColor: appTheme.blueA400, // ignore: deprecated_member_use - onChanged: (value) { - setState(() { - registrationAsType = value!; - }); - }, + onChanged: null, ), const Expanded( child: Padding( padding: EdgeInsets.symmetric(horizontal: 8), - child: Text('Самозанятого', style: TextStyle(fontSize: 18, height: 1)), + child: Text('Самозанятого', style: TextStyle(fontSize: 18, height: 1, color: Colors.black38)), ), ), ], @@ -80,16 +90,12 @@ class _RegistrationAsScreenState extends State { groupValue: registrationAsType, activeColor: appTheme.blueA400, // ignore: deprecated_member_use - onChanged: (value) { - setState(() { - registrationAsType = value!; - }); - }, + onChanged: null, ), const Expanded( child: Padding( padding: EdgeInsets.symmetric(horizontal: 8), - child: Text('Индивидуального предпринимателя', style: TextStyle(fontSize: 18, height: 1)), + child: Text('Индивидуального предпринимателя', style: TextStyle(fontSize: 18, height: 1, color: Colors.black38)), ), ), ], @@ -102,16 +108,12 @@ class _RegistrationAsScreenState extends State { groupValue: registrationAsType, activeColor: appTheme.blueA400, // ignore: deprecated_member_use - onChanged: (value) { - setState(() { - registrationAsType = value!; - }); - }, + onChanged: null, ), const Expanded( child: Padding( padding: EdgeInsets.symmetric(horizontal: 8), - child: Text('Общества с ограниченной ответственностью', style: TextStyle(fontSize: 18, height: 1)), + child: Text('Общества с ограниченной ответственностью', style: TextStyle(fontSize: 18, height: 1, color: Colors.black38)), ), ), ], diff --git a/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart index a27e5c67..69f88361 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart @@ -6,6 +6,7 @@ import 'package:eatvio_app/features/consultant/become_consultant/presentation/bl import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/last_step_screen.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart'; @@ -28,7 +29,7 @@ class StepperBecomeConsultantScreen extends StatefulWidget { } class _StepperBecomeConsultantScreenState extends State { - double progressValue = 1 / 6; + double progressValue = 0; int step = 1; final pageController = PageController(); late final RegisterConsultantCubit registrationConsultantCubit; @@ -44,6 +45,20 @@ class _StepperBecomeConsultantScreenState extends State _pages() { + return const [ + CertificatesAndDiplomasScreen(), + PhotoOrScansScreen(), + AdditionalSettingsScreen(), + SocialNetworksScreen(), + RegistrationAsScreen(), + PassportDataScreen(), + LastStepScreen(), + ]; + } + @override Widget build(BuildContext context) { return BlocProvider( @@ -53,7 +68,7 @@ class _StepperBecomeConsultantScreenState extends State AlertDialog( - title: Text(state.requestModel!.canceledModeration!.title), - content: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - for (int i = 0; i < state.requestModel!.canceledModeration!.additional!.length; i++) ...[ - Text('• ${state.requestModel!.canceledModeration!.additional![i]}'), - ], + ), + if (widget.isCanceled == true) + InkWell( + onTap: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(state.requestModel!.canceledModeration!.title), + content: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + for (int i = 0; i < state.requestModel!.canceledModeration!.additional!.length; i++) ...[ + Text('• ${state.requestModel!.canceledModeration!.additional![i]}'), ], - ), - actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))], - ), - ); - }, - borderRadius: BorderRadius.circular(20), - child: Ink( - height: 48, - width: MediaQuery.of(context).size.width * 0.6, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0x33FFCC00)), - child: const Center( - child: Text( - 'Комментарий', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF323F4B), height: 1), + ], ), + actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))], + ), + ); + }, + borderRadius: BorderRadius.circular(20), + child: Ink( + height: 48, + width: MediaQuery.of(context).size.width * 0.6, + decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0x33FFCC00)), + child: const Center( + child: Text( + 'Комментарий', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF323F4B), height: 1), ), ), ), - Container( - height: 48, - width: widget.isCanceled == true - ? MediaQuery.of(context).size.width * 0.2 - : MediaQuery.of(context).size.width * 0.8, - padding: const EdgeInsets.only(left: 4, right: 16), - child: FloatingActionButton.extended( - heroTag: 'nextButtonRegistration', - highlightElevation: 0, - elevation: 0, - focusElevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - backgroundColor: isLoading - ? Colors.grey - : widget.isCanceled == true - ? const Color(0xFFEAF2FD) - : appTheme.blueA400, - onPressed: isLoading - ? null - : () async { - if (step == 6) { - if (widget.isCanceled == true) { - return; - } - if (registrationConsultantCubit.state.acceptOffer) { - setState(() { - isLoading = true; - }); - try { - await registrationConsultantCubit.registerConsultant(); - if (!context.mounted) return; - if (registrationConsultantCubit.state.error != null) { - return; - } - await showModalBottomSheet( - useRootNavigator: true, - showDragHandle: true, - context: context, - builder: (context) => Container( - padding: const EdgeInsets.all(16), - width: MediaQuery.of(context).size.width, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - 'Спасибо!\nВаши данные отправлены на модерацию', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 20, height: 1, fontWeight: FontWeight.w600), - ), - const SizedBox(height: 16), - Lottie.asset('assets/lottie_animations/success.json', width: 160, height: 160), - const SizedBox(height: 16), - const Text( - 'В течении трех рабочих дней Вам придет уведомление о смене статуса', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 16, height: 1, fontWeight: FontWeight.w500), - ), - const SizedBox(height: 16), - InkWell( - onTap: () { - Navigator.of(context).pop(); - GoRouter.of(context).pop(); - }, - child: Ink( - width: MediaQuery.of(context).size.width - 32, - height: 52, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400), - child: const Center( - child: Text( - 'Закрыть', - style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600), - ), + ), + Container( + height: 48, + width: widget.isCanceled == true + ? MediaQuery.of(context).size.width * 0.2 + : MediaQuery.of(context).size.width * 0.8, + padding: const EdgeInsets.only(left: 4, right: 16), + child: FloatingActionButton.extended( + heroTag: 'nextButtonRegistration', + highlightElevation: 0, + elevation: 0, + focusElevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + backgroundColor: isLoading + ? Colors.grey + : widget.isCanceled == true + ? const Color(0xFFEAF2FD) + : appTheme.blueA400, + onPressed: isLoading + ? null + : () async { + if (step == totalSteps) { + if (widget.isCanceled == true) { + return; + } + if (!state.consultant.hasValidPassportData) { + showDialog( + context: context, + builder: (context) => AlertDialog( + actionsPadding: const EdgeInsets.symmetric(horizontal: 4), + content: const Text('Заполните паспортные данные'), + actions: [ + TextButton( + child: const Text('Ок'), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ); + return; + } + if (registrationConsultantCubit.hasAcceptedRequiredAgreements) { + setState(() { + isLoading = true; + }); + try { + await registrationConsultantCubit.registerConsultant(); + if (!context.mounted) return; + if (registrationConsultantCubit.state.error != null) { + return; + } + await showModalBottomSheet( + useRootNavigator: true, + showDragHandle: true, + context: context, + builder: (context) => Container( + padding: const EdgeInsets.all(16), + width: MediaQuery.of(context).size.width, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Спасибо!\nВаши данные отправлены на модерацию', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 20, height: 1, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 16), + Lottie.asset('assets/lottie_animations/success.json', width: 160, height: 160), + const SizedBox(height: 16), + const Text( + 'В течении трех рабочих дней Вам придет уведомление о смене статуса', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 16, height: 1, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 16), + InkWell( + onTap: () { + Navigator.of(context).pop(); + GoRouter.of(context).pop(); + }, + child: Ink( + width: MediaQuery.of(context).size.width - 32, + height: 52, + decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400), + child: const Center( + child: Text( + 'Закрыть', + style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600), ), ), ), - ], - ), - ), - ); - } catch (e) { - if (!context.mounted) return; - showDialog( - context: context, - builder: (context) => AlertDialog( - actionsPadding: const EdgeInsets.symmetric(horizontal: 4), - content: Text(e.toString()), - actions: [ - TextButton( - child: const Text('Ок'), - onPressed: () => Navigator.of(context).pop(), ), ], ), - ); - } finally { - setState(() { - isLoading = false; - }); - } - } else { - showDialog( + ), + ); + } catch (e) { + if (!context.mounted) return; + showDialog( context: context, builder: (context) => AlertDialog( actionsPadding: const EdgeInsets.symmetric(horizontal: 4), - content: const Text('Примите договор публичной офёрты'), + content: Text(e.toString()), actions: [ TextButton( child: const Text('Ок'), - onPressed: () { - Navigator.of(context).pop(); - }, + onPressed: () => Navigator.of(context).pop(), ), ], ), ); + } finally { + setState(() { + isLoading = false; + }); } } else { - FocusManager.instance.primaryFocus?.unfocus(); - setState(() { - pageController.nextPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut); - }); + showDialog( + context: context, + builder: (context) => AlertDialog( + actionsPadding: const EdgeInsets.symmetric(horizontal: 4), + content: Text( + state.isUpdating + ? 'Примите договор публичной оферты' + : 'Примите оферту и согласие ФРДО', + ), + actions: [ + TextButton( + child: const Text('Ок'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ), + ); } - }, - label: isLoading - ? const CircularProgressIndicator() - : widget.isCanceled == true - ? Icon(Icons.arrow_forward_ios_outlined, color: appTheme.blueA400) - : Text('Продолжить', style: theme.textTheme.titleSmall!.copyWith(color: Colors.white)), - ), + } else { + FocusManager.instance.primaryFocus?.unfocus(); + setState(() { + pageController.nextPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut); + }); + } + }, + label: isLoading + ? const CircularProgressIndicator() + : widget.isCanceled == true + ? Icon(Icons.arrow_forward_ios_outlined, color: appTheme.blueA400) + : Text('Продолжить', style: theme.textTheme.titleSmall!.copyWith(color: Colors.white)), ), - ], - ), - appBar: AppBar( - automaticallyImplyLeading: false, - leading: Builder( - builder: (context) => IconButton(icon: const Icon(Icons.close_rounded), onPressed: () => GoRouter.of(context).pop()), ), - centerTitle: true, - title: SvgPicture.asset(ImageConstant.imgLogoEatvio, height: 48, fit: BoxFit.contain), - actions: [Padding(padding: const EdgeInsets.only(right: 16), child: Text('шаг $step из 6'))], - bottom: PreferredSize( - preferredSize: Size(MediaQuery.of(context).size.width, 32), - child: Padding( - padding: const EdgeInsets.all(16), - child: TweenAnimationBuilder( - duration: const Duration(milliseconds: 350), - curve: Curves.easeInOut, - tween: Tween(begin: 1 / 6, end: progressValue), - builder: (context, value, _) => LinearProgressIndicator( - value: value, - borderRadius: BorderRadius.circular(16), - minHeight: 8, - backgroundColor: Colors.grey.shade100, - valueColor: AlwaysStoppedAnimation(appTheme.blueA400), - ), + ], + ), + appBar: AppBar( + automaticallyImplyLeading: false, + leading: Builder( + builder: (context) => IconButton(icon: const Icon(Icons.close_rounded), onPressed: () => GoRouter.of(context).pop()), + ), + centerTitle: true, + title: SvgPicture.asset(ImageConstant.imgLogoEatvio, height: 48, fit: BoxFit.contain), + actions: [Padding(padding: const EdgeInsets.only(right: 16), child: Text('шаг $step из $totalSteps'))], + bottom: PreferredSize( + preferredSize: Size(MediaQuery.of(context).size.width, 32), + child: Padding( + padding: const EdgeInsets.all(16), + child: TweenAnimationBuilder( + duration: const Duration(milliseconds: 350), + curve: Curves.easeInOut, + tween: Tween(begin: 1 / totalSteps, end: progressValue), + builder: (context, value, _) => LinearProgressIndicator( + value: value, + borderRadius: BorderRadius.circular(16), + minHeight: 8, + backgroundColor: Colors.grey.shade100, + valueColor: AlwaysStoppedAnimation(appTheme.blueA400), ), ), ), ), - body: PageView( - physics: const NeverScrollableScrollPhysics(), - controller: pageController, - onPageChanged: (index) { - setState(() { - step = index + 1; - progressValue = step / 6; - }); - }, - children: const [ - CertificatesAndDiplomasScreen(), - PhotoOrScansScreen(), - AdditionalSettingsScreen(), - SocialNetworksScreen(), - RegistrationAsScreen(), - LastStepScreen(), - ], - ), - ); + ), + body: PageView( + physics: const NeverScrollableScrollPhysics(), + controller: pageController, + onPageChanged: (index) { + setState(() { + step = index + 1; + progressValue = step / totalSteps; + }); + }, + children: _pages(), + ), + ); }, ), ); diff --git a/lib/features/consultant/become_consultant/repositories/consultant_repository.dart b/lib/features/consultant/become_consultant/repositories/consultant_repository.dart index c0e1c57a..b8d55b5a 100644 --- a/lib/features/consultant/become_consultant/repositories/consultant_repository.dart +++ b/lib/features/consultant/become_consultant/repositories/consultant_repository.dart @@ -3,6 +3,7 @@ import 'package:dio/dio.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/services/consultant_service.dart'; import 'package:http_parser/http_parser.dart'; @@ -17,22 +18,24 @@ class ConsultantRepository { } else { return await consultantService.consultantRegistration(consultantModel: consultant); } - } + /// Профиль консультанта (свой или чужой) — публичные данные без паспорта. Future getConsultantRequestStatus(String slug) async { return await consultantService.getConsultantRequestStatus(slug: slug); - } + /// Своя заявка — education + legal/passport для регистрации и update. Future getMyRequestStatus(String slug) async { return await consultantService.getMyRequestStatus(slug: slug); + } + Future> getSbpBanks() async { + return await consultantService.getSbpBanks(); } Future> getConsultantCertPhotos() async { return await consultantService.getConsultantCertPhotos(); - } Future> storeConsultantCertPhotos({required List files}) async { @@ -49,21 +52,17 @@ class ConsultantRepository { } return await consultantService.storeConsultantCertPhotos(photos: multipartFiles); - } Future hideConsultantCertPhoto(int id) async { await consultantService.hideConsultantCertPhoto(payload: {'cert_photo_id': id}); - } Future unHideConsultantCertPhoto(int id) async { await consultantService.unHideConsultantCertPhoto(payload: {'cert_photo_id': id}); - } Future deletePhoto(int id) async { await consultantService.deletePhoto(id: id); - } } diff --git a/lib/features/consultant/become_consultant/services/consultant_service.dart b/lib/features/consultant/become_consultant/services/consultant_service.dart index 9a0757ad..6482c667 100644 --- a/lib/features/consultant/become_consultant/services/consultant_service.dart +++ b/lib/features/consultant/become_consultant/services/consultant_service.dart @@ -2,6 +2,7 @@ import 'package:dio/dio.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart'; import 'package:retrofit/retrofit.dart'; part 'consultant_service.g.dart'; @@ -10,22 +11,29 @@ part 'consultant_service.g.dart'; abstract class ConsultantService { factory ConsultantService(Dio dio, {String baseUrl}) = _ConsultantService; - @POST('/api/consultant_request/registration') + @POST('/api/v2/consultant_request/registration') Future consultantRegistration({@Body() required ConsultantProfileModel consultantModel}); @POST('/api/consultant_request/photos/upload') @MultiPart() Future> uploadPhotos({@Part(name: 'photos[]', contentType: 'multipart/form-data') List? photos}); + /// Публичный статус консультанта для просмотра профиля. + /// Без паспортных данных: `/api/profile/consultant/{slug}/status`. @GET('/api/profile/consultant/{slug}/status') Future getConsultantRequestStatus({@Path() required String slug}); - @GET('/api/consultant_request/{slug}/status') + /// Свой статус заявки для редактирования/модерации. + /// С паспортными полями в `legal_entity_resource`: `/api/v2/consultant_request/{slug}/status`. + @GET('/api/v2/consultant_request/{slug}/status') Future getMyRequestStatus({@Path() required String slug}); - @PUT('/api/consultant_request/update') + @PUT('/api/v2/consultant_request/update') Future consultantUpdate({@Body() required ConsultantProfileModel consultantModel}); + @GET('/api/finances/sbp_banks') + Future> getSbpBanks(); + @GET('/api/v2/consultant_request/cert_photos') Future> getConsultantCertPhotos(); diff --git a/test/features/consultant/become_consultant/models/consultant_profile_model_test.dart b/test/features/consultant/become_consultant/models/consultant_profile_model_test.dart index 3b008a3f..fb88c9c5 100644 --- a/test/features/consultant/become_consultant/models/consultant_profile_model_test.dart +++ b/test/features/consultant/become_consultant/models/consultant_profile_model_test.dart @@ -106,7 +106,17 @@ void main() { test('toJson serializes all fields correctly', () { const socialLinks = SocialLinksModel(vkLink: 'https://vk.com/user'); const certificates = CertificatesModel(title: 'Course 1', date: '2024-01-01'); - const model = ConsultantProfileModel(hasMedicalEducation: true, socialLinks: socialLinks, photos: ['photo1.jpg'], certificates: [certificates]); + const model = ConsultantProfileModel( + hasMedicalEducation: true, + socialLinks: socialLinks, + photos: ['photo1.jpg'], + certificates: [certificates], + legalType: 'individualPerson', + inn: '123456789012', + snils: '123-456-789 00', + agreementAccepted: true, + frdoAgreementAccepted: true, + ); final json = model.toJson(); @@ -114,6 +124,34 @@ void main() { expect(json['social_links'], isNotNull); expect(json['cert_photos'], ['photo1.jpg']); expect(json['completed_courses'], isNotNull); + expect(json['legal_type'], 'individualPerson'); + expect(json['inn'], '123456789012'); + expect(json['snils'], '123-456-789 00'); + expect(json['agreement_accepted'], isTrue); + expect(json['frdo_agreement_accepted'], isTrue); + }); + + test('hasValidPassportData validates required individualPerson fields', () { + const valid = ConsultantProfileModel( + lastName: 'Иванов', + firstName: 'Иван', + inn: '123456789012', + snils: '123-456-789 00', + passportSeries: '1234', + passportNumber: '567890', + passportIssueDate: '2020-01-15', + passportIssuer: 'ОВД', + passportIssuerCode: '772-053', + birthDate: '1990-05-20', + birthPlace: 'Москва', + registrationAddress: 'Адрес 1', + residenceAddress: 'Адрес 2', + citizenship: 'RU', + ); + + expect(valid.hasValidPassportData, isTrue); + expect(valid.copyWith(inn: '123').hasValidPassportData, isFalse); + expect(valid.copyWith(lastName: '').hasValidPassportData, isFalse); }); }); } diff --git a/test/features/consultant/become_consultant/models/consultation_request_model_test.dart b/test/features/consultant/become_consultant/models/consultation_request_model_test.dart index 7dd05d98..f11423e4 100644 --- a/test/features/consultant/become_consultant/models/consultation_request_model_test.dart +++ b/test/features/consultant/become_consultant/models/consultation_request_model_test.dart @@ -122,6 +122,47 @@ void main() { expect(model.canceledModeration!.status, ModerationStatus.canceled); }); + test('fromJson parses legal_entity_resource', () { + final json = { + 'rating_place': 5, + 'education_resource': {'request_state': 'approved'}, + 'cert_photos': [], + 'legal_entity_resource': { + 'id': 1, + 'type': 'individualPerson', + 'inn': '123456789012', + 'snils': '123-456-789 00', + 'state': 'created', + 'alfa_scoring_status': 'pending', + 'alfa_scoring_request_id': 'abc', + 'passport_series': '1234', + 'passport_number': '567890', + 'passport_issue_date': '2020-01-15T00:00:00.000000Z', + 'passport_issuer': 'ОВД', + 'passport_issuer_code': '772-053', + 'birth_date': '1990-05-20', + 'birth_place': 'Москва', + 'registration_address': 'Адрес 1', + 'residence_address': 'Адрес 2', + }, + }; + + final model = ConsultationRequestModel.fromJson(json); + + expect(model.legalEntity, isNotNull); + expect(model.legalEntity!.type, 'individualPerson'); + expect(model.legalEntity!.inn, '123456789012'); + expect(model.legalEntity!.passportSeries, '1234'); + expect(model.legalEntity!.normalizedPassportIssueDate, '2020-01-15'); + expect(model.legalEntity!.alfaScoringStatus, 'pending'); + }); + + test('normalizeApiDate strips time from DateTime.toString format', () { + expect(LegalEntityModel.normalizeApiDate('2023-09-22 00:00:00.000'), '2023-09-22'); + expect(LegalEntityModel.normalizeApiDate('2023-09-22'), '2023-09-22'); + expect(LegalEntityModel.normalizeApiDate(null), isNull); + }); + test('toJson serializes ConsultationRequestModel correctly', () { const educationModel = EducationModel(requestState: ModerationStatus.approved); const photos = [CertPhotosModel(url: 'https://example.com/photo1.jpg'), CertPhotosModel(url: 'https://example.com/photo2.jpg')]; diff --git a/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart b/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart index a721050b..9eeeb304 100644 --- a/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart +++ b/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart @@ -1,6 +1,7 @@ import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -23,6 +24,7 @@ void main() { late RegisterConsultantCubit cubit; late ConsultationRequestModel requestModel; late List certPhotosFromFixture; + late List sbpBanksFromFixture; late ConsultantProfileModel consultantProfile; setUp(() { @@ -32,7 +34,11 @@ void main() { certPhotosFromFixture = FixtureReader.readAsList( 'consultant/consultant_cert_photos.json', ).map((item) => ConsultantCertPhotoModel.fromJson(item as Map)).toList(); + sbpBanksFromFixture = FixtureReader.readAsList( + 'consultant/sbp_banks.json', + ).map((item) => SbpBankModel.fromJson(item as Map)).toList(); consultantProfile = ConsultantProfileModel.fromJson(FixtureReader.readAsMap('consultant/consultant_profile_request.json')); + when(() => repository.getSbpBanks()).thenAnswer((_) async => sbpBanksFromFixture); }); tearDown(() async { @@ -61,11 +67,19 @@ void main() { expect(state.consultant.certificates!.first.title, 'Нутрициология PRO'); expect(state.consultant.socialLinks?.instaLink, isNull, reason: 'empty links are sanitized'); expect(state.consultant.socialLinks?.vkLink, 'https://vk.com/consultant'); + expect(state.consultant.legalType, 'individualPerson'); + expect(state.consultant.inn, '123456789012'); + expect(state.consultant.passportSeries, '1234'); + expect(state.consultant.passportIssueDate, '2020-01-15'); + expect(state.consultant.birthPlace, 'г. Москва'); // All cert photos from backend are kept in state (visible + hidden). expect(state.certPhotos, hasLength(3)); expect(state.certPhotos.map((e) => e.id), containsAll([101, 102, 103])); + expect(state.sbpBanks, hasLength(2)); + expect(state.sbpBanks.first.bankName, 'Сбербанк'); verify(() => repository.getMyRequestStatus('my-consultant-slug')).called(1); verify(() => repository.getConsultantCertPhotos()).called(1); + verify(() => repository.getSbpBanks()).called(1); }); test('disableLoading stops spinner and loads cert photos', () async { @@ -76,7 +90,28 @@ void main() { expect(cubit.state.loading, isFalse); // disableLoading now keeps all photos from repository. expect(cubit.state.certPhotos, hasLength(3)); + expect(cubit.state.sbpBanks, hasLength(2)); verify(() => repository.getConsultantCertPhotos()).called(1); + verify(() => repository.getSbpBanks()).called(1); + }); + + test('searchSbpBanks filters banks by name', () async { + when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); + await cubit.disableLoading(); + + final results = cubit.searchSbpBanks('сбер'); + + expect(results, hasLength(1)); + expect(results.first.bankMemberId, '100000000111'); + }); + + test('findSbpBankById returns matching bank', () async { + when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); + await cubit.disableLoading(); + + final bank = cubit.findSbpBankById('100000000004'); + + expect(bank?.bankName, 'Т-Банк'); }); test('updateConsultantProfileModel replaces consultant data and offer flag', () { @@ -95,12 +130,24 @@ void main() { ), ).thenAnswer((_) async {}); + cubit.updateConsultantProfileModel(consultantProfile); + cubit.updateAgreements(acceptOffer: true, acceptFrdoAgreement: true); await cubit.registerConsultant(); - verify(() => repository.registerConsultant(consultant: cubit.state.consultant, update: false)).called(1); + final captured = verify( + () => repository.registerConsultant( + consultant: captureAny(named: 'consultant'), + update: false, + ), + ).captured.single as ConsultantProfileModel; + + expect(captured.legalType, 'individualPerson'); + expect(captured.agreementAccepted, isTrue); + expect(captured.frdoAgreementAccepted, isTrue); + expect(captured.inn, isNotNull); }); - test('registerConsultant sends update=true after getMyRequestStatus call', () async { + test('registerConsultant sends legal payload with update=true', () async { when(() => repository.getMyRequestStatus(any())).thenAnswer((_) async => requestModel); when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); when( @@ -113,7 +160,38 @@ void main() { await cubit.getMyRequestStatus('my-consultant-slug'); await cubit.registerConsultant(); - verify(() => repository.registerConsultant(consultant: cubit.state.consultant, update: true)).called(1); + final captured = verify( + () => repository.registerConsultant( + consultant: captureAny(named: 'consultant'), + update: true, + ), + ).captured.single as ConsultantProfileModel; + + final json = captured.toJson(); + expect(json['legal_type'], 'individualPerson'); + expect(json['inn'], '123456789012'); + expect(json['passport_series'], '1234'); + expect(json.containsKey('agreement_accepted'), isFalse); + expect(json.containsKey('frdo_agreement_accepted'), isFalse); + }); + + test('registerConsultant blocks submit without passport data', () async { + when( + () => repository.registerConsultant( + consultant: any(named: 'consultant'), + update: any(named: 'update'), + ), + ).thenAnswer((_) async {}); + + await cubit.registerConsultant(); + + expect(cubit.state.error, 'Заполните паспортные данные'); + verifyNever( + () => repository.registerConsultant( + consultant: any(named: 'consultant'), + update: any(named: 'update'), + ), + ); }); test('removeCertPhoto marks cert as deleted locally', () async { diff --git a/test/features/consultant/become_consultant/presentation/pages/last_step_screen_test.dart b/test/features/consultant/become_consultant/presentation/pages/last_step_screen_test.dart index cff449e8..c95b21c1 100644 --- a/test/features/consultant/become_consultant/presentation/pages/last_step_screen_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/last_step_screen_test.dart @@ -36,7 +36,7 @@ void main() { ); } - testWidgets('renders last step screen', (tester) async { + testWidgets('renders last step screen with both agreements for new registration', (tester) async { final cubit = MockRegisterConsultantCubit(); const consultant = ConsultantProfileModel( hasMedicalEducation: null, @@ -49,13 +49,30 @@ void main() { when(cubit.state).thenReturn(state); when(cubit.stream).thenAnswer((_) => Stream.value(state)); when(cubit.close()).thenAnswer((_) async => Future.value()); - when(cubit.updateConsultantProfileModel(any, any)).thenReturn(null); await tester.pumpWidget(wrap(const LastStepScreen(), cubit)); await tester.pump(); expect(find.byType(LastStepScreen), findsOneWidget); expect(find.text('Последний шаг'), findsOneWidget); + expect(find.byType(CheckboxListTile), findsNWidgets(2)); + expect(find.textContaining('ФРДО'), findsOneWidget); + }); + + testWidgets('hides FRDO checkbox in update mode', (tester) async { + final cubit = MockRegisterConsultantCubit(); + const consultant = ConsultantProfileModel(); + const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: true); + + when(cubit.state).thenReturn(state); + when(cubit.stream).thenAnswer((_) => Stream.value(state)); + when(cubit.close()).thenAnswer((_) async => Future.value()); + + await tester.pumpWidget(wrap(const LastStepScreen(), cubit)); + await tester.pump(); + + expect(find.byType(CheckboxListTile), findsOneWidget); + expect(find.textContaining('ФРДО'), findsNothing); }); }); } diff --git a/test/features/consultant/become_consultant/presentation/pages/passport_data_screen_test.dart b/test/features/consultant/become_consultant/presentation/pages/passport_data_screen_test.dart new file mode 100644 index 00000000..3344e719 --- /dev/null +++ b/test/features/consultant/become_consultant/presentation/pages/passport_data_screen_test.dart @@ -0,0 +1,59 @@ +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockConsultantRepository extends Mock implements ConsultantRepository {} + +void main() { + group('PassportDataScreen smoke', () { + late RegisterConsultantCubit cubit; + + setUp(() { + cubit = RegisterConsultantCubit(consultantRepository: MockConsultantRepository()); + cubit.emit( + const RegisterConsultantState( + consultant: ConsultantProfileModel(socialLinks: SocialLinksModel()), + acceptOffer: false, + loading: false, + isUpdating: false, + ), + ); + }); + + tearDown(() async { + await cubit.close(); + }); + + Widget wrap(Widget child) { + return MaterialApp( + theme: ThemeHelper().themeData(), + home: Scaffold( + body: BlocProvider.value(value: cubit, child: child), + ), + ); + } + + testWidgets('renders passport data fields', (tester) async { + await tester.pumpWidget(wrap(const PassportDataScreen())); + await tester.pump(); + + expect(find.byType(PassportDataScreen), findsOneWidget); + expect(find.text('Паспортные данные'), findsOneWidget); + expect(find.text('Фамилия'), findsOneWidget); + expect(find.text('Имя'), findsOneWidget); + expect(find.text('ИНН (12 цифр)'), findsOneWidget); + expect(find.text('СНИЛС'), findsOneWidget); + expect(find.text('Гражданство (ISO, например RU)'), findsOneWidget); + expect(find.text('Телефон для выплат через СБП'), findsOneWidget); + expect(find.text('Банк получатель (поиск)'), findsOneWidget); + expect(find.text('Я являюсь налоговым резидентом США'), findsOneWidget); + }); + }); +} diff --git a/test/features/consultant/become_consultant/presentation/pages/registration_as_screen_test.dart b/test/features/consultant/become_consultant/presentation/pages/registration_as_screen_test.dart index df170daa..d4818dd8 100644 --- a/test/features/consultant/become_consultant/presentation/pages/registration_as_screen_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/registration_as_screen_test.dart @@ -1,24 +1,54 @@ import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockConsultantRepository extends Mock implements ConsultantRepository {} void main() { group('RegistrationAsScreen smoke', () { + late RegisterConsultantCubit cubit; + + setUp(() { + cubit = RegisterConsultantCubit(consultantRepository: MockConsultantRepository()); + cubit.emit( + const RegisterConsultantState( + consultant: ConsultantProfileModel(socialLinks: SocialLinksModel()), + acceptOffer: false, + loading: false, + isUpdating: false, + ), + ); + }); + + tearDown(() async { + await cubit.close(); + }); + Widget wrap(Widget child) { return MaterialApp( theme: ThemeHelper().themeData(), - home: Scaffold(body: child), + home: Scaffold( + body: BlocProvider.value(value: cubit, child: child), + ), ); } testWidgets('renders registration as screen', (tester) async { await tester.pumpWidget(wrap(const RegistrationAsScreen())); await tester.pump(); + await tester.pump(); expect(find.byType(RegistrationAsScreen), findsOneWidget); expect(find.text('Регистрация в качестве'), findsOneWidget); expect(find.text('Физического лица'), findsOneWidget); + expect(cubit.state.consultant.legalType, 'individualPerson'); }); }); } diff --git a/test/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen_smoke_test.dart b/test/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen_smoke_test.dart index 34c2db42..0f742c33 100644 --- a/test/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen_smoke_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen_smoke_test.dart @@ -86,7 +86,7 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); - expect(find.text('шаг 1 из 6'), findsOneWidget); + expect(find.text('шаг 1 из 7'), findsOneWidget); }); testWidgets('displays progress bar', (tester) async { diff --git a/test/features/consultant/become_consultant/repositories/consultant_repository_test.dart b/test/features/consultant/become_consultant/repositories/consultant_repository_test.dart index c695cc5b..468e638c 100644 --- a/test/features/consultant/become_consultant/repositories/consultant_repository_test.dart +++ b/test/features/consultant/become_consultant/repositories/consultant_repository_test.dart @@ -5,6 +5,7 @@ import 'package:dio/dio.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; import 'package:eatvio_app/features/consultant/become_consultant/services/consultant_service.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -88,6 +89,18 @@ void main() { verify(() => mockService.getConsultantCertPhotos()).called(1); }); + test('getSbpBanks returns banks from service', () async { + final banks = FixtureReader.readAsList('consultant/sbp_banks.json') + .map((item) => SbpBankModel.fromJson(item as Map)) + .toList(); + when(() => mockService.getSbpBanks()).thenAnswer((_) async => banks); + + final result = await repository.getSbpBanks(); + + expect(result, banks); + verify(() => mockService.getSbpBanks()).called(1); + }); + test('storeConsultantCertPhotos returns empty list when no files provided', () async { final result = await repository.storeConsultantCertPhotos(files: []); diff --git a/test/fixtures/consultant/consultant_profile_request.json b/test/fixtures/consultant/consultant_profile_request.json index fc05c731..270d4a20 100644 --- a/test/fixtures/consultant/consultant_profile_request.json +++ b/test/fixtures/consultant/consultant_profile_request.json @@ -20,6 +20,26 @@ "title": "Психология питания", "date": "2023-06-05" } - ] + ], + "legal_type": "individualPerson", + "last_name": "Иванов", + "first_name": "Иван", + "middle_name": "Иванович", + "inn": "123456789012", + "snils": "123-456-789 00", + "passport_series": "1234", + "passport_number": "567890", + "passport_issue_date": "2020-01-15", + "passport_issuer": "ОВД Пресненского района г. Москвы", + "passport_issuer_code": "772-053", + "birth_date": "1990-05-20", + "birth_place": "г. Москва", + "registration_address": "г. Москва, ул. Тверская, д. 1, кв. 1", + "residence_address": "г. Москва, ул. Тверская, д. 1, кв. 1", + "citizenship": "RU", + "tax_resident_us": false, + "foreign_tax_resident": false, + "sbp_phone": "+79991234567", + "agreement_accepted": true, + "frdo_agreement_accepted": true } - diff --git a/test/fixtures/consultant/consultation_request.json b/test/fixtures/consultant/consultation_request.json index f4065e15..bf7ae187 100644 --- a/test/fixtures/consultant/consultation_request.json +++ b/test/fixtures/consultant/consultation_request.json @@ -23,6 +23,31 @@ "personal_site_link": "https://consultant.eatvio.app", "request_state": "approved" }, + "legal_entity_resource": { + "id": 1, + "type": "individualPerson", + "last_name": "Иванов", + "first_name": "Иван", + "middle_name": "Иванович", + "inn": "123456789012", + "snils": "123-456-789 00", + "state": "created", + "alfa_scoring_status": "pending", + "alfa_scoring_request_id": "abc-123", + "passport_series": "1234", + "passport_number": "567890", + "passport_issue_date": "2020-01-15", + "passport_issuer": "ОВД Пресненского района г. Москвы", + "passport_issuer_code": "772-053", + "birth_date": "1990-05-20", + "birth_place": "г. Москва", + "registration_address": "г. Москва, ул. Тверская, д. 1, кв. 1", + "residence_address": "г. Москва, ул. Тверская, д. 1, кв. 1", + "citizenship": "RU", + "tax_resident_us": false, + "foreign_tax_resident": false, + "sbp_phone": "+79991234567" + }, "cert_photos": [ { "url": "https://cdn.eatvio.app/certificates/visible.jpg" @@ -40,4 +65,3 @@ ] } } - diff --git a/test/fixtures/consultant/sbp_banks.json b/test/fixtures/consultant/sbp_banks.json new file mode 100644 index 00000000..b725de52 --- /dev/null +++ b/test/fixtures/consultant/sbp_banks.json @@ -0,0 +1,16 @@ +[ + { + "bank_member_id": "100000000111", + "bank_name": "Сбербанк", + "short_name": "Сбер", + "logo_url": "https://example.com/sber.png", + "bic": "044525225" + }, + { + "bank_member_id": "100000000004", + "bank_name": "Т-Банк", + "short_name": "ТБанк", + "logo_url": "https://example.com/tbank.png", + "bic": "044525974" + } +] -- 2.49.1 From a6fe6afb80430e1d55932a6528b17a215f62a5dd Mon Sep 17 00:00:00 2001 From: misha Date: Thu, 23 Jul 2026 15:17:01 +0300 Subject: [PATCH 64/88] feat(personalized_feed): fixed photos rendering and fixed profile posts rendering --- .../presentation/pages/blog_post_screen.dart | 6 +- .../widgets/blog_post_media_content.dart | 81 +++- .../widgets/blog_post_widget.dart | 3 +- .../feed_video_player_widget.dart | 5 +- .../feed_video_preview_placeholder.dart | 5 +- .../widgets/profile_blog_post_widget.dart | 392 +++++++++--------- pubspec.yaml | 22 +- .../models/feed_blog_post_model_test.dart | 13 + .../profile_cubit/profile_cubit_test.dart | 15 +- .../profile_blog_post_widget_smoke_test.dart | 251 +++++------ .../services/profile_service_test.dart | 18 +- test/fixtures/feed_fixtures.dart | 6 + .../blog_posts_by_author_response.json | 80 ++++ 13 files changed, 491 insertions(+), 406 deletions(-) create mode 100644 test/fixtures/profile/blog_posts_by_author_response.json diff --git a/lib/features/blog_post/presentation/pages/blog_post_screen.dart b/lib/features/blog_post/presentation/pages/blog_post_screen.dart index d5960c68..9067738c 100644 --- a/lib/features/blog_post/presentation/pages/blog_post_screen.dart +++ b/lib/features/blog_post/presentation/pages/blog_post_screen.dart @@ -257,13 +257,9 @@ class _BlogPostScreenState extends State { return const SizedBox.shrink(); } final imageUrl = PostFeedMapper.resolveMediaUrl(post.image); - final galleryUrls = post.images - .map((e) => PostFeedMapper.resolveMediaUrl(e.url)) - .where((e) => e.isNotEmpty) - .toList(); final embedUrl = post.videoEmbedUrl?.trim(); return BlogPostMediaContent( - galleryUrls: galleryUrls, + galleryImages: post.images, imageUrl: imageUrl, videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null, videoUrlThumb: post.videoUrlThumb != null ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) : null, diff --git a/lib/features/feed/presentation/widgets/blog_post_media_content.dart b/lib/features/feed/presentation/widgets/blog_post_media_content.dart index e2141185..a0d8ddc8 100644 --- a/lib/features/feed/presentation/widgets/blog_post_media_content.dart +++ b/lib/features/feed/presentation/widgets/blog_post_media_content.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:carousel_slider_plus/carousel_slider_plus.dart'; +import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart'; +import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart'; import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart'; import 'package:flutter/material.dart'; @@ -9,7 +11,7 @@ import 'package:flutter/material.dart'; class BlogPostMediaContent extends StatefulWidget { const BlogPostMediaContent({ super.key, - required this.galleryUrls, + required this.galleryImages, required this.imageUrl, required this.videoEmbedUrl, required this.currentGalleryIndex, @@ -18,7 +20,7 @@ class BlogPostMediaContent extends StatefulWidget { required this.videoUrlThumb, }); - final List galleryUrls; + final List galleryImages; final String imageUrl; final String? videoEmbedUrl; final String? videoUrlThumb; @@ -32,16 +34,36 @@ class BlogPostMediaContent extends StatefulWidget { class _BlogPostMediaContentState extends State { static const _counterDisplayDuration = Duration(seconds: 1); - static const _carouselHeight = 260.0; + + /// Landscape / video slide aspect (width / height). + static const _horizontalAspectRatio = 16 / 9; + + /// Portrait slide aspect (width / height). + static const _verticalAspectRatio = 3 / 4; bool _isCounterVisible = false; Timer? _counterHideTimer; bool get _hasVideo => widget.videoEmbedUrl != null && widget.videoEmbedUrl!.trim().isNotEmpty; + List get _gallery { + return [ + for (final image in widget.galleryImages) + if (PostFeedMapper.resolveMediaUrl(image.url) case final url when url.isNotEmpty) + FeedGalleryImage( + url: url, + orientation: image.orientation, + id: image.id, + position: image.position, + ), + ]; + } + + List get _galleryUrls => _gallery.map((e) => e.url).toList(); + int get _slideCount { - if (widget.galleryUrls.isNotEmpty) { - return widget.galleryUrls.length + (_hasVideo ? 1 : 0); + if (_gallery.isNotEmpty) { + return _gallery.length + (_hasVideo ? 1 : 0); } if (_hasVideo) { return 1; @@ -49,10 +71,24 @@ class _BlogPostMediaContentState extends State { return 0; } - bool get _isCurrentSlideVideo => _hasVideo && widget.currentGalleryIndex == widget.galleryUrls.length; + bool get _isCurrentSlideVideo => _hasVideo && widget.currentGalleryIndex == _gallery.length; bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible; + /// Height is fixed for the whole post from the first gallery image (web-like). + String get _postOrientation { + if (_gallery.isNotEmpty) { + return _gallery.first.orientation; + } + return 'horizontal'; + } + + double _heightForOrientation(String orientation, double width) { + final aspectRatio = orientation == 'vertical' ? _verticalAspectRatio : _horizontalAspectRatio; + final maxHeight = MediaQuery.sizeOf(context).height * 0.7; + return (width / aspectRatio).clamp(180.0, maxHeight); + } + @override void dispose() { _counterHideTimer?.cancel(); @@ -90,13 +126,13 @@ class _BlogPostMediaContentState extends State { return false; } - Widget _imageSlide(String itemUrl, int itemIndex) { + Widget _imageSlide(String itemUrl, int itemIndex, double height) { return GestureDetector( - onTap: () => widget.onOpenGallery(widget.galleryUrls, itemIndex), + onTap: () => widget.onOpenGallery(_galleryUrls, itemIndex), child: CachedNetworkImage( fit: BoxFit.cover, - width: MediaQuery.of(context).size.width, - height: _carouselHeight, + width: MediaQuery.sizeOf(context).width, + height: height, imageUrl: itemUrl, errorWidget: (context, str, object) => const SizedBox.shrink(), ), @@ -107,7 +143,7 @@ class _BlogPostMediaContentState extends State { return FeedVideoPlayer(embedUrl: widget.videoEmbedUrl!, videoUrlThumb: widget.videoUrlThumb); } - Widget _carousel({required int itemCount, required NullableIndexedWidgetBuilder itemBuilder}) { + Widget _carousel({required int itemCount, required NullableIndexedWidgetBuilder itemBuilder, required double height}) { return Stack( children: [ NotificationListener( @@ -117,7 +153,7 @@ class _BlogPostMediaContentState extends State { itemBuilder: (context, itemIndex, _) => itemBuilder(context, itemIndex) ?? const SizedBox.shrink(), options: CarouselOptions( viewportFraction: 1, - height: _carouselHeight, + height: height, enableInfiniteScroll: false, onPageChanged: (index, _) => widget.onGalleryChanged(index), ), @@ -141,13 +177,18 @@ class _BlogPostMediaContentState extends State { @override Widget build(BuildContext context) { - if (widget.galleryUrls.length > 1 || (widget.galleryUrls.length == 1 && _hasVideo)) { + final gallery = _gallery; + final width = MediaQuery.sizeOf(context).width; + final mediaHeight = _heightForOrientation(_postOrientation, width); + + if (gallery.length > 1 || (gallery.length == 1 && _hasVideo)) { final itemCount = _slideCount; return _carousel( itemCount: itemCount, + height: mediaHeight, itemBuilder: (context, itemIndex) { - if (itemIndex < widget.galleryUrls.length) { - return _imageSlide(widget.galleryUrls[itemIndex], itemIndex); + if (itemIndex < gallery.length) { + return _imageSlide(gallery[itemIndex].url, itemIndex, mediaHeight); } if (_hasVideo) { return _videoSlide(); @@ -157,14 +198,14 @@ class _BlogPostMediaContentState extends State { ); } - if (widget.galleryUrls.length == 1) { - return _imageSlide(widget.galleryUrls.first, 0); + if (gallery.length == 1) { + return _imageSlide(gallery.first.url, 0, mediaHeight); } if (_hasVideo) { return SizedBox( - height: _carouselHeight, - width: MediaQuery.of(context).size.width, + height: mediaHeight, + width: width, child: _videoSlide(), ); } @@ -176,7 +217,7 @@ class _BlogPostMediaContentState extends State { tag: widget.imageUrl, child: CachedNetworkImage( fit: BoxFit.fitWidth, - width: MediaQuery.of(context).size.width, + width: width, imageUrl: widget.imageUrl, errorWidget: (context, str, object) => const SizedBox.shrink(), ), diff --git a/lib/features/feed/presentation/widgets/blog_post_widget.dart b/lib/features/feed/presentation/widgets/blog_post_widget.dart index 5c349db8..3977d462 100644 --- a/lib/features/feed/presentation/widgets/blog_post_widget.dart +++ b/lib/features/feed/presentation/widgets/blog_post_widget.dart @@ -78,7 +78,6 @@ class _BlogPostWidgetState extends State { @override Widget build(BuildContext context) { final imageUrl = PostFeedMapper.resolveMediaUrl(post.image); - final galleryUrls = post.images.map((e) => PostFeedMapper.resolveMediaUrl(e.url)).where((e) => e.isNotEmpty).toList(); final embedUrl = post.videoEmbedUrl?.trim(); return InkWell( @@ -95,7 +94,7 @@ class _BlogPostWidgetState extends State { _BlogPostHeader(post: post), _BlogPostTextContent(post: post), BlogPostMediaContent( - galleryUrls: galleryUrls, + galleryImages: post.images, imageUrl: imageUrl, videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null, videoUrlThumb: post.videoUrlThumb, diff --git a/lib/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart b/lib/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart index 6778e94b..2f850923 100644 --- a/lib/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart +++ b/lib/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart @@ -169,9 +169,8 @@ class _FeedVideoPlayerState extends State { @override Widget build(BuildContext context) { - final player = SizedBox( - height: 220, - width: MediaQuery.sizeOf(context).width, + final player = ColoredBox( + color: Colors.black, child: Stack( fit: StackFit.expand, children: [ diff --git a/lib/features/feed/presentation/widgets/feed_video_player/feed_video_preview_placeholder.dart b/lib/features/feed/presentation/widgets/feed_video_player/feed_video_preview_placeholder.dart index 86036dc6..1721e45b 100644 --- a/lib/features/feed/presentation/widgets/feed_video_player/feed_video_preview_placeholder.dart +++ b/lib/features/feed/presentation/widgets/feed_video_player/feed_video_preview_placeholder.dart @@ -22,10 +22,13 @@ class FeedVideoPreviewPlaceholder extends StatelessWidget { return Stack( fit: StackFit.expand, children: [ + const ColoredBox(color: Colors.black), if (previewUrl != null && previewUrl!.isNotEmpty) CachedNetworkImage( imageUrl: previewUrl!, - fit: BoxFit.cover, + fit: BoxFit.contain, + width: double.infinity, + height: double.infinity, errorWidget: (_, _, _) => const ColoredBox(color: Color(0xFF2C2C2C)), ) else diff --git a/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart b/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart index e0e5cd01..9ae218cc 100644 --- a/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart +++ b/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart @@ -1,19 +1,21 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:dismissible_page/dismissible_page.dart'; import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/enums/complained_content.dart'; import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/utils/date_time_utils.dart'; import 'package:eatvio_app/core/widgets/custom_popup.dart'; import 'package:eatvio_app/core/widgets/feed_icon_button.dart'; +import 'package:eatvio_app/core/widgets/markdown_html_widget.dart'; import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart'; +import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; +import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; -import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart'; import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart'; import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_state.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:eatvio_app/core/widgets/markdown_html_widget.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; import 'package:share_plus/share_plus.dart'; @@ -31,221 +33,213 @@ class ProfileBlogPostWidget extends StatefulWidget { class _ProfileBlogPostWidgetState extends State { late bool isLiked; late int totalLikes; + int currentGalleryIndex = 0; + + void _openGallery(List imageUrls, int index) { + context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index)); + } @override Widget build(BuildContext context) { return BlocBuilder( bloc: widget.profileCubit, builder: (BuildContext context, ProfileState state) { - if (state is ProfileStateLoaded) { - isLiked = state.blogPosts[widget.index].wasLikedByMe; - totalLikes = state.blogPosts[widget.index].totalLikes; - return InkWell( - borderRadius: BorderRadius.circular(12), - onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.blogPosts[widget.index].id), - child: Ink( - decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: Colors.white), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ + if (state is! ProfileStateLoaded) { + return const SizedBox.shrink(); + } + + final FeedBlogPostModel post = state.blogPosts[widget.index]; + isLiked = post.wasLikedByMe; + totalLikes = post.totalLikes; + final imageUrl = PostFeedMapper.resolveMediaUrl(post.image); + final embedUrl = post.videoEmbedUrl?.trim(); + + return InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id), + child: Ink( + decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: Colors.white), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + CircleAvatar( + radius: 20, + backgroundColor: Colors.black12, + foregroundImage: NetworkImage( + post.author.authorProfileIcon.contains('https') + ? post.author.authorProfileIcon + : sl().baseUrl + post.author.authorProfileIcon, + ), + backgroundImage: const AssetImage('assets/images/no_avatar.png'), + ), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + post.author.authorName, + style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 4), + Text( + '${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}', + style: const TextStyle(height: 1, color: Colors.black45), + ), + ], + ), + const Spacer(), + CustomPopup( + isAuthor: post.author.authorIsMe, + complainedContentType: ComplainedContent.posts, + path: 'blog_post/${post.id}', + id: post.id, + feedBlogPostModel: post, + userSlug: post.author.authorSlug, + ), + ], + ), + ), + if (post.title.isNotEmpty) ...[ const SizedBox(height: 12), Padding( padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( - children: [ - CircleAvatar( - radius: 20, - backgroundColor: Colors.black12, - foregroundImage: NetworkImage( - state.blogPosts[widget.index].author.authorProfileIcon.contains('https') - ? state.blogPosts[widget.index].author.authorProfileIcon - : sl().baseUrl + state.blogPosts[widget.index].author.authorProfileIcon, - ), - backgroundImage: const AssetImage('assets/images/no_avatar.png'), - ), - const SizedBox(width: 8), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - state.blogPosts[widget.index].author.authorName, - style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500), - ), - const SizedBox(height: 4), - Text( - '${calculateDifference(state.blogPosts[widget.index].createdAt)} в ${state.blogPosts[widget.index].createdAt.hour.toString().padLeft(2, '0')}:${state.blogPosts[widget.index].createdAt.minute.toString().padLeft(2, '0')}', - style: const TextStyle(height: 1, color: Colors.black45), - ), - ], - ), - const Spacer(), - CustomPopup( - isAuthor: state.blogPosts[widget.index].author.authorIsMe, - complainedContentType: ComplainedContent.posts, - path: 'blog_post/${(state.blogPosts[widget.index].id)}', - id: state.blogPosts[widget.index].id, - feedBlogPostModel: state.blogPosts[widget.index], - userSlug: state.blogPosts[widget.index].author.authorSlug, - ), - ], + child: Text( + post.title, + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18, color: Colors.black87), ), ), - if (state.blogPosts[widget.index].title.isNotEmpty) ...[ - const SizedBox(height: 12), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Text( - state.blogPosts[widget.index].title, - style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18, color: Colors.black87), - ), + ], + if (post.title.isEmpty) const SizedBox(height: 8), + if (post.title.isNotEmpty && post.description.isNotEmpty) const SizedBox(height: 8), + if (post.description.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: MarkdownHtmlWidget( + markdownText: _truncateDescription(post.description), + fontSize: 16, + textColor: Colors.black87, ), - ], - if (state.blogPosts[widget.index].title.isEmpty) const SizedBox(height: 8), - if (state.blogPosts[widget.index].title.isNotEmpty && state.blogPosts[widget.index].description.isNotEmpty) const SizedBox(height: 8), - if (state.blogPosts[widget.index].description.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Builder( - builder: (context) { - String description = state.blogPosts[widget.index].description; - final isLong = description.length > 200; - - if (isLong) { - // Умная обрезка: обрезаем markdown текст, не ломая синтаксис - int cutPoint = 200; - - // Проверяем, есть ли незакрытые markdown теги - String preview = description.substring(0, cutPoint); - - // Считаем открывающие и закрывающие теги - int boldOpen = (preview.split('**').length - 1) ~/ 2; - int boldClose = preview.split('**').length - 1 - boldOpen * 2; - bool hasUnclosedBold = boldOpen > boldClose; - - // Если есть незакрытый bold, ищем закрытие - if (hasUnclosedBold) { - int searchEnd = (cutPoint + 100).clamp(0, description.length); - int boldClosePos = description.indexOf('**', cutPoint); - if (boldClosePos != -1 && boldClosePos < searchEnd) { - cutPoint = boldClosePos + 2; - } else { - // Если не нашли, обрезаем до последнего пробела - cutPoint = description.lastIndexOf(' ', 200); - if (cutPoint == -1 || cutPoint < 150) cutPoint = 200; - } - } else { - // Обрезаем до последнего пробела для красоты - cutPoint = description.lastIndexOf(' ', cutPoint); - if (cutPoint == -1 || cutPoint < 150) cutPoint = 200; - } - - description = description.substring(0, cutPoint); - } - - return MarkdownHtmlWidget( - markdownText: description, - fontSize: 16, - textColor: Colors.black87, + ), + if (post.description.length > 200) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 12), + child: Text( + 'Показать ещё', + style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600), + ), + ), + if (post.title.isNotEmpty || post.description.isNotEmpty) const SizedBox(height: 8), + BlogPostMediaContent( + galleryImages: post.images, + imageUrl: imageUrl, + videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null, + videoUrlThumb: post.videoUrlThumb != null ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) : null, + currentGalleryIndex: currentGalleryIndex, + onOpenGallery: _openGallery, + onGalleryChanged: (index) { + setState(() { + currentGalleryIndex = index; + }); + }, + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + FeedIconButton( + icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded, + text: totalLikes.toString(), + iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null, + backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null, + textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null, + onTap: () async { + setState(() { + isLiked = !isLiked; + isLiked ? totalLikes++ : totalLikes--; + }); + isLiked ? await context.read().likePost(post.id) : await context.read().unlikedPost(post.id); + widget.profileCubit.updateFeedBlogPostList(post, isLiked ? false : true); + }, + ), + const SizedBox(width: 8), + FeedIconButton( + svgIcon: SvgPicture.asset( + 'assets/icons/comment.svg', + height: 20, + width: 20, + colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn), + ), + text: post.totalComments.toString(), + onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id), + ), + const SizedBox(width: 8), + FeedIconButton( + svgIcon: SvgPicture.asset( + height: 20, + width: 20, + 'assets/icons/share.svg', + colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn), + ), + onTap: () { + final box = context.findRenderObject() as RenderBox?; + SharePlus.instance.share( + ShareParams( + uri: Uri.parse('${sl().baseUrl}/ru/blog_post/${post.id}'), + sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, + ), ); }, ), - ), - if (state.blogPosts[widget.index].description.length > 200) - const Padding( - padding: EdgeInsets.symmetric(horizontal: 12), - child: Text( - 'Показать ещё', - style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600), + const Spacer(), + Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500), + const SizedBox(width: 4), + Text( + post.totalViews.toString(), + style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400), ), - ), - if (state.blogPosts[widget.index].title.isNotEmpty || state.blogPosts[widget.index].description.isNotEmpty) const SizedBox(height: 8), - GestureDetector( - onTap: () { - context.pushTransparentRoute( - rootNavigator: true, - PhotoViewScreen(imageUrls: ['${sl().baseUrl}/storage/${state.blogPosts[widget.index].image}'], index: 0), - ); - }, - child: Hero( - tag: '${sl().baseUrl}/storage/${state.blogPosts[widget.index].image}', - child: CachedNetworkImage( - fit: BoxFit.fitWidth, - width: MediaQuery.of(context).size.width, - imageUrl: '${sl().baseUrl}/storage/${state.blogPosts[widget.index].image}', - errorWidget: (context, str, object) => const SizedBox.shrink(), - ), - ), + ], ), - const SizedBox(height: 12), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( - children: [ - FeedIconButton( - icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded, - text: totalLikes.toString(), - iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null, - backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null, - textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null, - onTap: () async { - setState(() { - isLiked = !isLiked; - isLiked ? totalLikes++ : totalLikes--; - }); - isLiked - ? await context.read().likePost(state.blogPosts[widget.index].id) - : await context.read().unlikedPost(state.blogPosts[widget.index].id); - widget.profileCubit.updateFeedBlogPostList(state.blogPosts[widget.index], isLiked ? false : true); - }, - ), - const SizedBox(width: 8), - FeedIconButton( - svgIcon: SvgPicture.asset( - 'assets/icons/comment.svg', - height: 20, - width: 20, - colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn), - ), - text: state.blogPosts[widget.index].totalComments.toString(), - onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.blogPosts[widget.index].id), - ), - const SizedBox(width: 8), - FeedIconButton( - svgIcon: SvgPicture.asset( - height: 20, - width: 20, - 'assets/icons/share.svg', - colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn), - ), - onTap: () { - final box = context.findRenderObject() as RenderBox?; - SharePlus.instance.share( - ShareParams( - uri: Uri.parse('${sl().baseUrl}/ru/blog_post/${state.blogPosts[widget.index].id}'), - sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, - ), - ); - }, - ), - const Spacer(), - Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500), - const SizedBox(width: 4), - Text( - state.blogPosts[widget.index].totalViews.toString(), - style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400), - ), - ], - ), - ), - const SizedBox(height: 8), - ], - ), + ), + const SizedBox(height: 8), + ], ), - ); - } - return const SizedBox.shrink(); + ), + ); }, ); } } + +String _truncateDescription(String description) { + final isLong = description.length > 200; + if (!isLong) return description; + + var cutPoint = 200; + final preview = description.substring(0, cutPoint); + final boldOpen = (preview.split('**').length - 1) ~/ 2; + final boldClose = preview.split('**').length - 1 - boldOpen * 2; + final hasUnclosedBold = boldOpen > boldClose; + + if (hasUnclosedBold) { + final searchEnd = (cutPoint + 100).clamp(0, description.length); + final boldClosePos = description.indexOf('**', cutPoint); + if (boldClosePos != -1 && boldClosePos < searchEnd) { + cutPoint = boldClosePos + 2; + } else { + cutPoint = description.lastIndexOf(' ', 200); + if (cutPoint == -1 || cutPoint < 150) cutPoint = 200; + } + } else { + cutPoint = description.lastIndexOf(' ', cutPoint); + if (cutPoint == -1 || cutPoint < 150) cutPoint = 200; + } + + return description.substring(0, cutPoint); +} diff --git a/pubspec.yaml b/pubspec.yaml index 01ec1618..ae4effdd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.35+124 +version: 1.3.36+125 publish_to: 'none' environment: @@ -171,14 +171,14 @@ flutter_intl: flutter_native_splash: android: true ios: true - color: "#FFFFFF" - image_android: "assets/splash/splash.png" - image_dark_android: "assets/splash/splash.png" - image_ios: "assets/splash/splash.png" - image_dark_ios: "assets/splash/splash.png" - branding_android: "assets/splash/branding.png" - branding_dark_android: "assets/splash/branding.png" + color: '#FFFFFF' + image_android: 'assets/splash/splash.png' + image_dark_android: 'assets/splash/splash.png' + image_ios: 'assets/splash/splash.png' + image_dark_ios: 'assets/splash/splash.png' + branding_android: 'assets/splash/branding.png' + branding_dark_android: 'assets/splash/branding.png' android_12: - image: "assets/splash/splash.png" - image_dark: "assets/splash/splash.png" - branding: "assets/splash/branding.png" + image: 'assets/splash/splash.png' + image_dark: 'assets/splash/splash.png' + branding: 'assets/splash/branding.png' diff --git a/test/features/feed/models/feed_blog_post_model_test.dart b/test/features/feed/models/feed_blog_post_model_test.dart index 55fbe181..140266ba 100644 --- a/test/features/feed/models/feed_blog_post_model_test.dart +++ b/test/features/feed/models/feed_blog_post_model_test.dart @@ -150,6 +150,19 @@ void main() { expect(model.videoUrlThumb, 'media/1/thumb.jpg'); }); + test('parses profile search_by_author fixture with gallery and video', () { + final posts = FeedFixtures.blogPostsByAuthor(); + + expect(posts, hasLength(2)); + expect(posts.first.images, hasLength(2)); + expect(posts.first.images.first.orientation, 'horizontal'); + expect(posts.first.images[1].orientation, 'vertical'); + expect(posts.first.videoEmbedUrl, contains('rutube.ru/play/embed')); + expect(posts.first.videoUrlThumb, 'media/1/thumb.jpg'); + expect(posts[1].images, hasLength(2)); + expect(posts[1].videoEmbedUrl, isNull); + }); + test('creates FeedBlogPostModel with different states', () { const author = Author( authorSlug: 'author', diff --git a/test/features/profile/presentation/bloc/profile_cubit/profile_cubit_test.dart b/test/features/profile/presentation/bloc/profile_cubit/profile_cubit_test.dart index 9b109014..9e23ea16 100644 --- a/test/features/profile/presentation/bloc/profile_cubit/profile_cubit_test.dart +++ b/test/features/profile/presentation/bloc/profile_cubit/profile_cubit_test.dart @@ -10,6 +10,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; +import '../../../../../fixtures/feed_fixtures.dart'; import 'profile_cubit_test.mocks.dart'; @GenerateNiceMocks([ @@ -267,20 +268,10 @@ void main() { authorPlaceInRating: null, ); - final blogPost = FeedBlogPostModel( - id: 1, - state: 'published', - title: 'Test Post', - description: 'Test Content', - image: 'https://example.com/image.jpg', + final blogPost = FeedFixtures.feedBlogPostGalleryAndVideo().copyWith( author: author, - videoUrl: '', - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - totalLikes: 10, - totalComments: 5, - totalViews: 100, wasLikedByMe: false, + totalLikes: 10, ); const profileDetails = ProfileDetailsModel( diff --git a/test/features/profile/presentation/widgets/profile_blog_post_widget_smoke_test.dart b/test/features/profile/presentation/widgets/profile_blog_post_widget_smoke_test.dart index 8f6fe8ed..44ab062a 100644 --- a/test/features/profile/presentation/widgets/profile_blog_post_widget_smoke_test.dart +++ b/test/features/profile/presentation/widgets/profile_blog_post_widget_smoke_test.dart @@ -5,20 +5,20 @@ import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_initializer.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; -import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; -import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; +import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart'; import 'package:eatvio_app/features/profile/models/profile_model.dart'; import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart'; import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_state.dart'; import 'package:eatvio_app/features/profile/presentation/widgets/profile_blog_post_widget.dart'; +import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; -import 'package:eatvio_app/generated/l10n.dart'; +import '../../../../fixtures/feed_fixtures.dart'; import 'profile_blog_post_widget_smoke_test.mocks.dart'; class _FakeHttpClient implements HttpClient { @@ -35,7 +35,6 @@ class _FakeHttpClient implements HttpClient { @override Future getUrl(Uri url) async => _FakeHttpClientRequest(); - @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } @@ -51,73 +50,10 @@ class _FakeHttpClientRequest implements HttpClientRequest { class _FakeHttpClientResponse extends Stream> implements HttpClientResponse { // 1x1 transparent PNG. static final List _png = [ - 0x89, - 0x50, - 0x4E, - 0x47, - 0x0D, - 0x0A, - 0x1A, - 0x0A, - 0x00, - 0x00, - 0x00, - 0x0D, - 0x49, - 0x48, - 0x44, - 0x52, - 0x00, - 0x00, - 0x00, - 0x01, - 0x00, - 0x00, - 0x00, - 0x01, - 0x08, - 0x06, - 0x00, - 0x00, - 0x00, - 0x1F, - 0x15, - 0xC4, - 0x89, - 0x00, - 0x00, - 0x00, - 0x0A, - 0x49, - 0x44, - 0x41, - 0x54, - 0x78, - 0x9C, - 0x63, - 0x00, - 0x01, - 0x00, - 0x00, - 0x05, - 0x00, - 0x01, - 0x0D, - 0x0A, - 0x2D, - 0xB4, - 0x00, - 0x00, - 0x00, - 0x00, - 0x49, - 0x45, - 0x4E, - 0x44, - 0xAE, - 0x42, - 0x60, - 0x82, + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, + 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, + 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49, + 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, ]; @override @@ -150,81 +86,53 @@ void main() { sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); }); - testWidgets('renders blog post card', (tester) async { - final post = FeedBlogPostModel( - id: 1, - state: 'published', - image: 'test.png', - description: 'Hello', - author: const Author( - authorSlug: 'author', - authorName: 'Author', - authorProfileIcon: 'https://example.com/icon.png', - authorIsMe: false, - isEntitySubscribedByMe: false, - ), - videoUrl: '', - createdAt: DateTime(2025, 1, 1), - updatedAt: DateTime(2025, 1, 1), - title: 'Title', - totalLikes: 0, - totalViews: 0, - totalComments: 0, - wasLikedByMe: false, - ); + const profile = ProfileModel( + id: 1, + name: 'User', + email: 'u@e.com', + phoneConfirmationState: 'confirmed', + slug: 'user', + isPublicProfile: true, + isBlockedByMe: false, + isBlockedByAuthor: false, + isBlockedByUser: false, + isEntitySubscribedByMe: false, + profilePhotoUrl: '', + roles: [], + badges: [], + subscriptions: [], + subscribers: [], + ); - const profile = ProfileModel( - id: 1, - name: 'User', - email: 'u@e.com', - phoneConfirmationState: 'confirmed', - slug: 'user', - isPublicProfile: true, - isBlockedByMe: false, - isBlockedByAuthor: false, - isBlockedByUser: false, - isEntitySubscribedByMe: false, - profilePhotoUrl: '', - roles: [], - badges: [], - subscriptions: [], - subscribers: [], - ); - - const details = ProfileDetailsModel( - firstName: 'Test', - lastName: 'User', - 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, - ); - - final loadedState = ProfileState.loaded(profile: profile, profileDetails: details, blogPosts: [post], isMyProfile: true, certPhotos: []); - final cubit = MockProfileCubit(); - when(cubit.state).thenReturn(loadedState); - when(cubit.stream).thenAnswer((_) => const Stream.empty()); - when(cubit.close()).thenAnswer((_) async => Future.value()); + const details = ProfileDetailsModel( + firstName: 'Test', + lastName: 'User', + 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, + ); + Future pumpPost(WidgetTester tester, ProfileCubit cubit) async { await HttpOverrides.runZoned(() async { await tester.pumpWidget( MaterialApp( @@ -236,15 +144,60 @@ void main() { GlobalCupertinoLocalizations.delegate, ], supportedLocales: const [Locale('ru')], - home: Scaffold(body: ProfileBlogPostWidget(index: 0, profileCubit: cubit)), + home: Scaffold( + body: MediaQuery( + data: const MediaQueryData(size: Size(800, 1600)), + child: SingleChildScrollView( + child: ProfileBlogPostWidget(index: 0, profileCubit: cubit), + ), + ), + ), ), ); await tester.pumpAndSettle(const Duration(milliseconds: 200)); - }, createHttpClient: (context) => _FakeHttpClient()); + }, createHttpClient: (_) => _FakeHttpClient()); + } - expect(find.text('Регистрация'), findsNothing); - expect(find.text('Title'), findsOneWidget); - expect(find.text('Author'), findsOneWidget); + testWidgets('renders gallery and video media content', (tester) async { + final post = FeedFixtures.feedBlogPostGalleryAndVideo(); + final loadedState = ProfileState.loaded(profile: profile, profileDetails: details, blogPosts: [post], isMyProfile: true, certPhotos: []); + final cubit = MockProfileCubit(); + when(cubit.state).thenReturn(loadedState); + when(cubit.stream).thenAnswer((_) => const Stream.empty()); + when(cubit.close()).thenAnswer((_) async => Future.value()); + + await pumpPost(tester, cubit); + + expect(find.byType(BlogPostMediaContent), findsOneWidget); + expect(find.textContaining('Галерея и видео'), findsWidgets); + }); + + testWidgets('renders gallery-only media content', (tester) async { + final post = FeedFixtures.feedBlogPostWithGallery(); + final loadedState = ProfileState.loaded(profile: profile, profileDetails: details, blogPosts: [post], isMyProfile: true, certPhotos: []); + final cubit = MockProfileCubit(); + when(cubit.state).thenReturn(loadedState); + when(cubit.stream).thenAnswer((_) => const Stream.empty()); + when(cubit.close()).thenAnswer((_) async => Future.value()); + + await pumpPost(tester, cubit); + + expect(find.byType(BlogPostMediaContent), findsOneWidget); + expect(find.text('Галерея'), findsOneWidget); + }); + + testWidgets('renders video-only media content', (tester) async { + final post = FeedFixtures.feedBlogPostVideoOnly(); + final loadedState = ProfileState.loaded(profile: profile, profileDetails: details, blogPosts: [post], isMyProfile: true, certPhotos: []); + final cubit = MockProfileCubit(); + when(cubit.state).thenReturn(loadedState); + when(cubit.stream).thenAnswer((_) => const Stream.empty()); + when(cubit.close()).thenAnswer((_) async => Future.value()); + + await pumpPost(tester, cubit); + + expect(find.byType(BlogPostMediaContent), findsOneWidget); + expect(find.text('Видео'), findsOneWidget); }); }); } diff --git a/test/features/profile/services/profile_service_test.dart b/test/features/profile/services/profile_service_test.dart index 5c773bc3..9d02811a 100644 --- a/test/features/profile/services/profile_service_test.dart +++ b/test/features/profile/services/profile_service_test.dart @@ -3,9 +3,11 @@ import 'package:eatvio_app/features/profile/models/circumference_model.dart'; import 'package:eatvio_app/features/profile/models/profile_model.dart'; import 'package:eatvio_app/features/profile/models/weight_model.dart'; import 'package:eatvio_app/features/profile/services/profile_service.dart'; +import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; -import 'package:flutter_test/flutter_test.dart'; + +import '../../../fixtures/feed_fixtures.dart'; import 'profile_service_test.mocks.dart'; @GenerateNiceMocks([MockSpec()]) @@ -72,9 +74,17 @@ void main() { expect(await mockProfileService.getProfileDetailsBySlug(slug: 'slug'), isA()); }); - test('getBlogPostByAuthorSlug', () async { - when(mockProfileService.getBlogPostByAuthorSlug(slug: '')).thenAnswer((_) async => []); - expect(await mockProfileService.getBlogPostByAuthorSlug(slug: 'slug'), isA>()); + test('getBlogPostByAuthorSlug returns posts with gallery and video fields', () async { + final posts = FeedFixtures.blogPostsByAuthor(); + when(mockProfileService.getBlogPostByAuthorSlug(slug: 'slug')).thenAnswer((_) async => posts); + + final result = await mockProfileService.getBlogPostByAuthorSlug(slug: 'slug'); + + expect(result, isA>()); + expect(result, hasLength(2)); + expect(result.first.images, isNotEmpty); + expect(result.first.videoEmbedUrl, isNotNull); + expect(result.first.videoUrlThumb, isNotNull); }); test('getWeight', () async { diff --git a/test/fixtures/feed_fixtures.dart b/test/fixtures/feed_fixtures.dart index 0beb1624..e2fa950a 100644 --- a/test/fixtures/feed_fixtures.dart +++ b/test/fixtures/feed_fixtures.dart @@ -72,4 +72,10 @@ class FeedFixtures { static FeedBlogPostModel feedBlogPostGalleryAndVideo() { return FeedBlogPostModel.fromJson(feedBlogPostGalleryAndVideoResponseJson()); } + + static List blogPostsByAuthor() { + return FixtureReader.readAsList('profile/blog_posts_by_author_response.json') + .map((item) => FeedBlogPostModel.fromJson(item as Map)) + .toList(); + } } diff --git a/test/fixtures/profile/blog_posts_by_author_response.json b/test/fixtures/profile/blog_posts_by_author_response.json new file mode 100644 index 00000000..a97d2e8d --- /dev/null +++ b/test/fixtures/profile/blog_posts_by_author_response.json @@ -0,0 +1,80 @@ +[ + { + "id": 1, + "state": "active", + "image": "media/1/cover.jpg", + "images": [ + { + "id": 10, + "url": "media/1/a.jpg", + "position": 1, + "orientation": "horizontal" + }, + { + "id": 11, + "url": "media/1/b.jpg", + "position": 2, + "orientation": "vertical" + } + ], + "description": "Описание поста профиля", + "author": { + "author_slug": "test-user", + "author_name": "Test User", + "author_profile_icon": "https://example.com/photo.jpg", + "author_is_me": true, + "is_entity_subscribed_by_me": false, + "author_place_in_rating": null + }, + "video_url": "https://rutube.ru/video/6a62f217aa99daf00af8fa6cebebf218/", + "video_url_embed": "https://rutube.ru/play/embed/6a62f217aa99daf00af8fa6cebebf218/", + "video_url_thumb": "media/1/thumb.jpg", + "created_at": "2024-01-15T12:00:00Z", + "updated_at": "2024-01-15T12:00:00Z", + "title": "Пост профиля", + "total_likes": 10, + "total_views": 100, + "total_comments": 5, + "was_liked_by_me": false, + "is_favorite": false + }, + { + "id": 2, + "state": "active", + "image": "media/2/a.jpg", + "images": [ + { + "id": 20, + "url": "media/2/a.jpg", + "position": 1, + "orientation": "horizontal" + }, + { + "id": 21, + "url": "media/2/b.jpg", + "position": 2, + "orientation": "horizontal" + } + ], + "description": "Галерея без видео", + "author": { + "author_slug": "test-user", + "author_name": "Test User", + "author_profile_icon": "https://example.com/photo.jpg", + "author_is_me": true, + "is_entity_subscribed_by_me": false, + "author_place_in_rating": null + }, + "video_url": "", + "video_url_embed": null, + "video_url_thumb": null, + "created_at": "2024-01-16T12:00:00Z", + "updated_at": "2024-01-16T12:00:00Z", + "title": "Галерея", + "total_likes": 3, + "total_views": 40, + "total_comments": 1, + "was_liked_by_me": false, + "is_favorite": false + } +] -- 2.49.1 From 69f0f1e91956d605b5ca77f6933ea7d96ed13aad Mon Sep 17 00:00:00 2001 From: misha Date: Thu, 23 Jul 2026 15:27:49 +0300 Subject: [PATCH 65/88] fix: removed unused info plist key --- ios/Podfile | 2 -- ios/Runner/Info.plist | 2 -- 2 files changed, 4 deletions(-) diff --git a/ios/Podfile b/ios/Podfile index 4fe34ec9..833e2940 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -47,8 +47,6 @@ post_install do |installer| ## dart: PermissionGroup.photos 'PERMISSION_PHOTOS=1', 'PERMISSION_NOTIFICATIONS=1', - ## dart: PermissionGroup.mediaLibrary - 'PERMISSION_MEDIA_LIBRARY=1', ] end diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index c5522c3b..e18a8b4c 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -31,8 +31,6 @@ NSAllowsArbitraryLoads - NSAppleMusicUsageDescription - This permission is not needed by the app, but it is required by an underlying API. If you see this dialog, contact us. NSCameraUsageDescription Предоставление доступа позволит Вам делать фотографии и добавлять к записям NSMicrophoneUsageDescription -- 2.49.1 From 1dc60eec641e8829b05094b6ceac1afb7c4daeaa Mon Sep 17 00:00:00 2001 From: misha Date: Thu, 23 Jul 2026 15:29:47 +0300 Subject: [PATCH 66/88] version changed --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index e6badd50..3e99888f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.24+106 +version: 1.3.26+125 publish_to: 'none' environment: -- 2.49.1 From cc9aa6bffb49354d1a57b3feecc04e8d7dbc1d0d Mon Sep 17 00:00:00 2001 From: misha Date: Thu, 23 Jul 2026 15:36:02 +0300 Subject: [PATCH 67/88] version changed --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 8545cda6..29b1100a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.26+125 +version: 1.3.36+125 publish_to: 'none' environment: -- 2.49.1 From 2d6b042d45aea06b6f0ad548a0fb87ce33095560 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 28 Jul 2026 12:00:03 +0300 Subject: [PATCH 68/88] fix: removed unused import --- .../presentation/bloc/profile_cubit/profile_cubit_test.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/test/features/profile/presentation/bloc/profile_cubit/profile_cubit_test.dart b/test/features/profile/presentation/bloc/profile_cubit/profile_cubit_test.dart index 9e23ea16..c61bd465 100644 --- a/test/features/profile/presentation/bloc/profile_cubit/profile_cubit_test.dart +++ b/test/features/profile/presentation/bloc/profile_cubit/profile_cubit_test.dart @@ -1,7 +1,6 @@ import 'package:eatvio_app/features/chat/repositories/chat_repository.dart'; import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; -import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; import 'package:eatvio_app/features/profile/models/profile_model.dart'; import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart'; import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_state.dart'; -- 2.49.1 From 8c5fc8d82795ce64dd616af6c083aee49d80261f Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 29 Jul 2026 14:08:13 +0300 Subject: [PATCH 69/88] feat(consultant_scoring): fixed registration update flow --- .../register_consultant_cubit.dart | 6 ++-- .../presentation/pages/last_step_screen.dart | 29 +++++++++++++++++++ .../pages/passport_data_screen.dart | 2 +- .../stepper_become_consultant_screen.dart | 27 +++++++++-------- 4 files changed, 48 insertions(+), 16 deletions(-) diff --git a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart index 9cae8fa5..d82ff2bb 100644 --- a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart +++ b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart @@ -107,7 +107,7 @@ class RegisterConsultantCubit extends Cubit { birthPlace: _emptyToNull(legal?.birthPlace), registrationAddress: _emptyToNull(legal?.registrationAddress), residenceAddress: _emptyToNull(legal?.residenceAddress), - citizenship: _emptyToNull(legal?.citizenship) ?? 'RU', + citizenship: _emptyToNull(legal?.citizenship), taxResidentUs: legal?.taxResidentUs ?? false, foreignTaxResident: legal?.foreignTaxResident ?? false, sbpPhone: _emptyToNull(legal?.sbpPhone), @@ -149,7 +149,7 @@ class RegisterConsultantCubit extends Cubit { error: null, consultant: state.consultant.copyWith( legalType: individualPersonLegalType, - citizenship: state.consultant.citizenship ?? 'RU', + citizenship: state.consultant.citizenship, taxResidentUs: state.consultant.taxResidentUs ?? false, foreignTaxResident: state.consultant.foreignTaxResident ?? false, ), @@ -198,7 +198,7 @@ class RegisterConsultantCubit extends Cubit { ConsultantProfileModel buildSubmitPayload() { final withLegalType = state.consultant.copyWith( legalType: individualPersonLegalType, - citizenship: state.consultant.citizenship ?? 'RU', + citizenship: state.consultant.citizenship, taxResidentUs: state.consultant.taxResidentUs ?? false, foreignTaxResident: state.consultant.foreignTaxResident ?? false, certificates: state.consultant.certificates diff --git a/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart index 7fd501a6..afdcbf83 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart @@ -48,6 +48,35 @@ class _LastStepScreenState extends State { padding: EdgeInsets.symmetric(horizontal: 16), child: Text('Последний шаг', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)), ), + if (state.requestModel?.canceledModeration != null) ...[ + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0x33FFCC00), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + state.requestModel!.canceledModeration!.title, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, height: 1.2), + ), + if (state.requestModel!.canceledModeration!.additional != null) ...[ + const SizedBox(height: 8), + for (final item in state.requestModel!.canceledModeration!.additional!) ...[ + Text('• $item', style: const TextStyle(fontSize: 14, height: 1.3)), + ], + ], + ], + ), + ), + ), + ], const SizedBox(height: 16), CheckboxListTile( value: state.acceptOffer, diff --git a/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart index f3eb774b..814f2139 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart @@ -101,7 +101,7 @@ class _PassportDataScreenState extends State { text: consultant.birthPlace ?? '', ); citizenshipController = TextEditingController( - text: consultant.citizenship ?? 'RU', + text: consultant.citizenship, ); registrationAddressController = TextEditingController( text: consultant.registrationAddress ?? '', diff --git a/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart index 69f88361..73359e25 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart @@ -123,20 +123,23 @@ class _StepperBecomeConsultantScreenState extends State AlertDialog( - title: Text(state.requestModel!.canceledModeration!.title), + title: Text(canceledModeration.title), content: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - for (int i = 0; i < state.requestModel!.canceledModeration!.additional!.length; i++) ...[ - Text('• ${state.requestModel!.canceledModeration!.additional![i]}'), - ], + if (canceledModeration.additional != null) + for (final item in canceledModeration.additional!) ...[ + Text('• $item'), + ], ], ), actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))], @@ -158,7 +161,7 @@ class _StepperBecomeConsultantScreenState extends State Date: Thu, 30 Jul 2026 17:41:30 +0300 Subject: [PATCH 70/88] feat(consultant_scoring): added validation errors, fixed scoring flow, updating flow --- lib/core/routing/other_routes.dart | 17 +- .../enums/alfa_scoring_status.dart | 51 ++ .../models/consultant_cert_photo_model.dart | 3 +- .../models/consultant_profile_model.dart | 54 +- .../models/consultation_request_model.dart | 37 +- .../models/sbp_bank_model.dart | 3 +- .../register_consultant_cubit.dart | 369 ++++++++++-- .../register_consultant_state.dart | 11 +- .../pages/additional_settings_screen.dart | 27 +- .../pages/become_consultant_screen.dart | 26 +- .../certificates_and_diplomas_screen.dart | 67 ++- .../presentation/pages/last_step_screen.dart | 82 ++- .../pages/passport_data_screen.dart | 540 ++++++++++-------- .../pages/photo_or_scans_screen.dart | 88 ++- .../pages/registration_as_screen.dart | 62 +- .../pages/social_networks_screen.dart | 111 ++-- .../stepper_become_consultant_screen.dart | 310 +++++++--- .../widgets/passport_field_with_error.dart | 28 + .../widgets/passport_section_title.dart | 34 ++ .../widgets/scoring_status_widget.dart | 218 +++++++ .../repositories/consultant_repository.dart | 39 +- .../services/consultant_service.dart | 32 +- .../utils/laravel_validation_errors.dart | 167 ++++++ .../presentation/pages/settings_screen.dart | 5 +- .../entities/diploma_entity_test.dart | 1 - .../consultant_cert_photo_model_test.dart | 1 - .../models/consultant_profile_model_test.dart | 10 +- .../consultation_request_model_test.dart | 41 +- .../register_consultant_state_test.dart | 21 +- .../bloc/register_consultant_cubit_test.dart | 493 +++++++++++++--- .../additional_settings_screen_test.dart | 19 +- ...icates_and_diplomas_screen_smoke_test.dart | 49 +- .../pages/last_step_screen_test.dart | 74 ++- .../pages/passport_data_screen_test.dart | 9 +- .../photo_or_scans_screen_smoke_test.dart | 50 +- .../pages/registration_as_screen_test.dart | 9 +- .../social_networks_screen_smoke_test.dart | 54 +- ...r_become_consultant_screen_smoke_test.dart | 100 +++- .../consultant_repository_test.dart | 181 ++++-- .../services/consultant_service_test.dart | 62 +- .../utils/laravel_validation_errors_test.dart | 94 +++ .../consultant/consultation_request.json | 2 +- 42 files changed, 2894 insertions(+), 757 deletions(-) create mode 100644 lib/features/consultant/become_consultant/enums/alfa_scoring_status.dart create mode 100644 lib/features/consultant/become_consultant/presentation/widgets/passport_field_with_error.dart create mode 100644 lib/features/consultant/become_consultant/presentation/widgets/passport_section_title.dart create mode 100644 lib/features/consultant/become_consultant/presentation/widgets/scoring_status_widget.dart create mode 100644 lib/features/consultant/become_consultant/utils/laravel_validation_errors.dart create mode 100644 test/features/consultant/become_consultant/utils/laravel_validation_errors_test.dart diff --git a/lib/core/routing/other_routes.dart b/lib/core/routing/other_routes.dart index 91b6fab3..9c1ed729 100644 --- a/lib/core/routing/other_routes.dart +++ b/lib/core/routing/other_routes.dart @@ -258,7 +258,15 @@ class OtherRoutes { ), // Consultant routes - GoRoute(path: RouteConstants.becomeConsultantScreen, name: 'becomeConsultantScreen', builder: (context, state) => const BecomeConsultantScreen()), + GoRoute( + path: RouteConstants.becomeConsultantScreen, + name: 'becomeConsultantScreen', + builder: (context, state) { + final data = state.extra as Map?; + final slug = data?['slug'] as String?; + return BecomeConsultantScreen(slug: slug); + }, + ), GoRoute( path: RouteConstants.stepperBecomeConsultantScreen, name: 'stepperBecomeConsultantScreen', @@ -266,7 +274,12 @@ class OtherRoutes { final data = state.extra as Map; final slug = data['slug'] as String?; final isCanceled = data['isCanceled'] as bool?; - return StepperBecomeConsultantScreen(slug: slug, isCanceled: isCanceled); + final isNewRegistration = data['isNewRegistration'] as bool? ?? false; + return StepperBecomeConsultantScreen( + slug: slug, + isCanceled: isCanceled, + isNewRegistration: isNewRegistration, + ); }, ), GoRoute(path: RouteConstants.calendarScreen, name: 'calendarScreen', builder: (context, state) => const CalendarScreen()), diff --git a/lib/features/consultant/become_consultant/enums/alfa_scoring_status.dart b/lib/features/consultant/become_consultant/enums/alfa_scoring_status.dart new file mode 100644 index 00000000..60d5b86c --- /dev/null +++ b/lib/features/consultant/become_consultant/enums/alfa_scoring_status.dart @@ -0,0 +1,51 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +enum AlfaScoringStatus { + @JsonValue('none') + none, + @JsonValue('beneficiary_creating') + beneficiaryCreating, + @JsonValue('beneficiary_failed') + beneficiaryFailed, + @JsonValue('scoring_pending') + scoringPending, + @JsonValue('scoring_complete') + scoringComplete, + @JsonValue('scoring_failed') + scoringFailed; + + static AlfaScoringStatus fromString(String? value) { + switch (value) { + case 'beneficiary_creating': + return AlfaScoringStatus.beneficiaryCreating; + case 'beneficiary_failed': + return AlfaScoringStatus.beneficiaryFailed; + case 'scoring_pending': + return AlfaScoringStatus.scoringPending; + case 'scoring_complete': + return AlfaScoringStatus.scoringComplete; + case 'scoring_failed': + return AlfaScoringStatus.scoringFailed; + case 'none': + case null: + case '': + return AlfaScoringStatus.none; + default: + return AlfaScoringStatus.none; + } + } + + bool get isProcessing => + this == AlfaScoringStatus.beneficiaryCreating || + this == AlfaScoringStatus.scoringPending; + + bool get isFailed => + this == AlfaScoringStatus.beneficiaryFailed || + this == AlfaScoringStatus.scoringFailed; + + bool get canRetry => isFailed; + + bool get isComplete => this == AlfaScoringStatus.scoringComplete; + + bool get isTerminal => isComplete || isFailed; +} diff --git a/lib/features/consultant/become_consultant/models/consultant_cert_photo_model.dart b/lib/features/consultant/become_consultant/models/consultant_cert_photo_model.dart index aaec9b68..79735be5 100644 --- a/lib/features/consultant/become_consultant/models/consultant_cert_photo_model.dart +++ b/lib/features/consultant/become_consultant/models/consultant_cert_photo_model.dart @@ -18,7 +18,8 @@ abstract class ConsultantCertPhotoModel with _$ConsultantCertPhotoModel { const ConsultantCertPhotoModel._(); - factory ConsultantCertPhotoModel.fromJson(Map json) => _$ConsultantCertPhotoModelFromJson(json); + factory ConsultantCertPhotoModel.fromJson(Map json) => + _$ConsultantCertPhotoModelFromJson(json); static const String visibleGroup = 'user_certs'; static const String hiddenGroup = 'hidden_user_certs'; diff --git a/lib/features/consultant/become_consultant/models/consultant_profile_model.dart b/lib/features/consultant/become_consultant/models/consultant_profile_model.dart index dd9e01f5..5d454ff2 100644 --- a/lib/features/consultant/become_consultant/models/consultant_profile_model.dart +++ b/lib/features/consultant/become_consultant/models/consultant_profile_model.dart @@ -9,35 +9,50 @@ abstract class ConsultantProfileModel with _$ConsultantProfileModel { const ConsultantProfileModel._(); const factory ConsultantProfileModel({ - @JsonKey(name: 'has_medical_education', includeIfNull: false) bool? hasMedicalEducation, - @JsonKey(name: 'social_links', includeIfNull: false) SocialLinksModel? socialLinks, + @JsonKey(name: 'has_medical_education', includeIfNull: false) + bool? hasMedicalEducation, + @JsonKey(name: 'social_links', includeIfNull: false) + SocialLinksModel? socialLinks, @JsonKey(name: 'cert_photos', includeIfNull: false) List? photos, - @JsonKey(name: 'completed_courses', includeIfNull: false) List? certificates, + @JsonKey(name: 'completed_courses', includeIfNull: false) + List? certificates, @JsonKey(name: 'legal_type', includeIfNull: false) String? legalType, @JsonKey(name: 'last_name', includeIfNull: false) String? lastName, @JsonKey(name: 'first_name', includeIfNull: false) String? firstName, @JsonKey(name: 'middle_name', includeIfNull: false) String? middleName, @JsonKey(name: 'inn', includeIfNull: false) String? inn, @JsonKey(name: 'snils', includeIfNull: false) String? snils, - @JsonKey(name: 'passport_series', includeIfNull: false) String? passportSeries, - @JsonKey(name: 'passport_number', includeIfNull: false) String? passportNumber, - @JsonKey(name: 'passport_issue_date', includeIfNull: false) String? passportIssueDate, - @JsonKey(name: 'passport_issuer', includeIfNull: false) String? passportIssuer, - @JsonKey(name: 'passport_issuer_code', includeIfNull: false) String? passportIssuerCode, + @JsonKey(name: 'passport_series', includeIfNull: false) + String? passportSeries, + @JsonKey(name: 'passport_number', includeIfNull: false) + String? passportNumber, + @JsonKey(name: 'passport_issue_date', includeIfNull: false) + String? passportIssueDate, + @JsonKey(name: 'passport_issuer', includeIfNull: false) + String? passportIssuer, + @JsonKey(name: 'passport_issuer_code', includeIfNull: false) + String? passportIssuerCode, @JsonKey(name: 'birth_date', includeIfNull: false) String? birthDate, @JsonKey(name: 'birth_place', includeIfNull: false) String? birthPlace, - @JsonKey(name: 'registration_address', includeIfNull: false) String? registrationAddress, - @JsonKey(name: 'residence_address', includeIfNull: false) String? residenceAddress, + @JsonKey(name: 'registration_address', includeIfNull: false) + String? registrationAddress, + @JsonKey(name: 'residence_address', includeIfNull: false) + String? residenceAddress, @JsonKey(name: 'citizenship', includeIfNull: false) String? citizenship, @JsonKey(name: 'tax_resident_us', includeIfNull: false) bool? taxResidentUs, - @JsonKey(name: 'foreign_tax_resident', includeIfNull: false) bool? foreignTaxResident, + @JsonKey(name: 'foreign_tax_resident', includeIfNull: false) + bool? foreignTaxResident, @JsonKey(name: 'sbp_phone', includeIfNull: false) String? sbpPhone, - @JsonKey(name: 'sbp_bank_member_id', includeIfNull: false) String? sbpBankMemberId, - @JsonKey(name: 'agreement_accepted', includeIfNull: false) bool? agreementAccepted, - @JsonKey(name: 'frdo_agreement_accepted', includeIfNull: false) bool? frdoAgreementAccepted, + @JsonKey(name: 'sbp_bank_member_id', includeIfNull: false) + String? sbpBankMemberId, + @JsonKey(name: 'agreement_accepted', includeIfNull: false) + bool? agreementAccepted, + @JsonKey(name: 'frdo_agreement_accepted', includeIfNull: false) + bool? frdoAgreementAccepted, }) = _ConsultantProfileModel; - factory ConsultantProfileModel.fromJson(Map json) => _$ConsultantProfileModelFromJson(json); + factory ConsultantProfileModel.fromJson(Map json) => + _$ConsultantProfileModelFromJson(json); bool get hasValidPassportData { final last = lastName ?? ''; @@ -79,10 +94,12 @@ abstract class SocialLinksModel with _$SocialLinksModel { @JsonKey(name: 'tg_link', includeIfNull: false) String? tgLink, @JsonKey(name: 'fb_link', includeIfNull: false) String? fbLink, @JsonKey(name: 'insta_link', includeIfNull: false) String? instaLink, - @JsonKey(name: 'personal_site_link', includeIfNull: false) String? personalSiteLink, + @JsonKey(name: 'personal_site_link', includeIfNull: false) + String? personalSiteLink, }) = _SocialLinksModel; - factory SocialLinksModel.fromJson(Map json) => _$SocialLinksModelFromJson(json); + factory SocialLinksModel.fromJson(Map json) => + _$SocialLinksModelFromJson(json); } @freezed @@ -92,5 +109,6 @@ abstract class CertificatesModel with _$CertificatesModel { required String date, }) = _CertificatesModel; - factory CertificatesModel.fromJson(Map json) => _$CertificatesModelFromJson(json); + factory CertificatesModel.fromJson(Map json) => + _$CertificatesModelFromJson(json); } diff --git a/lib/features/consultant/become_consultant/models/consultation_request_model.dart b/lib/features/consultant/become_consultant/models/consultation_request_model.dart index 051f517f..c15649fd 100644 --- a/lib/features/consultant/become_consultant/models/consultation_request_model.dart +++ b/lib/features/consultant/become_consultant/models/consultation_request_model.dart @@ -1,3 +1,4 @@ +import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart'; import 'package:eatvio_app/features/profile/models/profile_model.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -11,11 +12,13 @@ abstract class ConsultationRequestModel with _$ConsultationRequestModel { @JsonKey(name: 'rating_place') int? ratingPlace, @JsonKey(name: 'education_resource') required EducationModel educationModel, @JsonKey(name: 'cert_photos') required List photos, - @JsonKey(name: 'canceled_moderation') CanceledModerationModel? canceledModeration, + @JsonKey(name: 'canceled_moderation') + CanceledModerationModel? canceledModeration, @JsonKey(name: 'legal_entity_resource') LegalEntityModel? legalEntity, }) = _ConsultationRequestModel; - factory ConsultationRequestModel.fromJson(Map json) => _$ConsultationRequestModelFromJson(json); + factory ConsultationRequestModel.fromJson(Map json) => + _$ConsultationRequestModelFromJson(json); } @freezed @@ -28,7 +31,13 @@ abstract class LegalEntityModel with _$LegalEntityModel { String? inn, String? snils, String? state, - @JsonKey(name: 'alfa_scoring_status') String? alfaScoringStatus, + @JsonKey( + name: 'alfa_scoring_status', + unknownEnumValue: AlfaScoringStatus.none, + defaultValue: AlfaScoringStatus.none, + ) + @Default(AlfaScoringStatus.none) + AlfaScoringStatus alfaScoringStatus, @JsonKey(name: 'alfa_scoring_request_id') String? alfaScoringRequestId, @JsonKey(name: 'last_name') String? lastName, @JsonKey(name: 'first_name') String? firstName, @@ -49,7 +58,8 @@ abstract class LegalEntityModel with _$LegalEntityModel { @JsonKey(name: 'sbp_bank_member_id') String? sbpBankMemberId, }) = _LegalEntityModel; - factory LegalEntityModel.fromJson(Map json) => _$LegalEntityModelFromJson(json); + factory LegalEntityModel.fromJson(Map json) => + _$LegalEntityModelFromJson(json); /// Normalizes API date (`Y-m-d` or ISO datetime) to `Y-m-d`. static String? normalizeApiDate(String? value) { @@ -62,25 +72,21 @@ abstract class LegalEntityModel with _$LegalEntityModel { return value; } - String? get normalizedPassportIssueDate => normalizeApiDate(passportIssueDate); + String? get normalizedPassportIssueDate => + normalizeApiDate(passportIssueDate); String? get normalizedBirthDate => normalizeApiDate(birthDate); } @freezed abstract class CertPhotosModel with _$CertPhotosModel { - const factory CertPhotosModel({ - required String url, - }) = _CertPhotosModel; + const factory CertPhotosModel({required String url}) = _CertPhotosModel; - factory CertPhotosModel.fromJson(Map json) => _$CertPhotosModelFromJson(json); + factory CertPhotosModel.fromJson(Map json) => + _$CertPhotosModelFromJson(json); } -enum ModerationStatus { - approved, - created, - canceled, -} +enum ModerationStatus { approved, created, canceled } @freezed abstract class CanceledModerationModel with _$CanceledModerationModel { @@ -90,5 +96,6 @@ abstract class CanceledModerationModel with _$CanceledModerationModel { required List? additional, }) = _CanceledModerationModel; - factory CanceledModerationModel.fromJson(Map json) => _$CanceledModerationModelFromJson(json); + factory CanceledModerationModel.fromJson(Map json) => + _$CanceledModerationModelFromJson(json); } diff --git a/lib/features/consultant/become_consultant/models/sbp_bank_model.dart b/lib/features/consultant/become_consultant/models/sbp_bank_model.dart index c7bf7976..1ffb0c5f 100644 --- a/lib/features/consultant/become_consultant/models/sbp_bank_model.dart +++ b/lib/features/consultant/become_consultant/models/sbp_bank_model.dart @@ -13,5 +13,6 @@ abstract class SbpBankModel with _$SbpBankModel { String? bic, }) = _SbpBankModel; - factory SbpBankModel.fromJson(Map json) => _$SbpBankModelFromJson(json); + factory SbpBankModel.fromJson(Map json) => + _$SbpBankModelFromJson(json); } diff --git a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart index d82ff2bb..9f835e5c 100644 --- a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart +++ b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart @@ -1,30 +1,52 @@ +import 'dart:async'; import 'dart:developer'; import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:eatvio_app/core/error_reporting/report_from.dart'; import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/utils/laravel_validation_errors.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:eatvio_app/core/error_reporting/report_from.dart'; class RegisterConsultantCubit extends Cubit { final ConsultantRepository consultantRepository; + final Duration scoringPollInterval; bool _update = false; + String? _userSlug; + Timer? _scoringPollTimer; static const String individualPersonLegalType = 'individualPerson'; - RegisterConsultantCubit({required this.consultantRepository}) - : super( - const RegisterConsultantState( - consultant: ConsultantProfileModel(socialLinks: SocialLinksModel(), hasMedicalEducation: true, legalType: individualPersonLegalType), - loading: true, - acceptOffer: false, - acceptFrdoAgreement: false, - isUpdating: false, - ), - ); + RegisterConsultantCubit({ + required this.consultantRepository, + this.scoringPollInterval = const Duration(seconds: 3), + }) : super( + const RegisterConsultantState( + consultant: ConsultantProfileModel( + socialLinks: SocialLinksModel(), + hasMedicalEducation: true, + legalType: individualPersonLegalType, + ), + loading: true, + acceptOffer: false, + acceptFrdoAgreement: false, + isUpdating: false, + ), + ); + + String? get userSlug => _userSlug; + + void setUserSlug(String? slug) { + if (slug != null && slug.isNotEmpty) { + _userSlug = slug; + } + } Future> _loadSbpBanks() async { try { @@ -45,7 +67,9 @@ class RegisterConsultantCubit extends Cubit { final name = bank.bankName.toLowerCase(); final shortName = (bank.shortName ?? '').toLowerCase(); final bic = (bank.bic ?? '').toLowerCase(); - return name.contains(normalized) || shortName.contains(normalized) || bic.contains(normalized); + return name.contains(normalized) || + shortName.contains(normalized) || + bic.contains(normalized); }).toList(); } @@ -64,21 +88,29 @@ class RegisterConsultantCubit extends Cubit { Future getMyRequestStatus(String slug) async { try { _update = true; - final myRequestStatus = await consultantRepository.getMyRequestStatus(slug); + _userSlug = slug; + final myRequestStatus = await consultantRepository.getMyRequestStatus( + slug, + ); final certPhotos = await consultantRepository.getConsultantCertPhotos(); final sbpBanks = await _loadSbpBanks(); - List? certificates = myRequestStatus.educationModel.completedCourses?.map((courseMap) { - final course = courseMap.values.first; + List? certificates = myRequestStatus + .educationModel + .completedCourses + ?.map((courseMap) { + final course = courseMap.values.first; - return CertificatesModel(title: course.title, date: course.date); - }).toList(); + return CertificatesModel(title: course.title, date: course.date); + }) + .toList(); String? sanitizeLink(String? link) { return (link != null && link.isNotEmpty) ? link : null; } final legal = myRequestStatus.legalEntity; + final scoringStatus = legal?.alfaScoringStatus ?? AlfaScoringStatus.none; emit( RegisterConsultantState( @@ -89,9 +121,12 @@ class RegisterConsultantCubit extends Cubit { tgLink: sanitizeLink(myRequestStatus.educationModel.tgLink), fbLink: sanitizeLink(myRequestStatus.educationModel.fbLink), instaLink: sanitizeLink(myRequestStatus.educationModel.instaLink), - personalSiteLink: sanitizeLink(myRequestStatus.educationModel.personalSiteLink), + personalSiteLink: sanitizeLink( + myRequestStatus.educationModel.personalSiteLink, + ), ), - hasMedicalEducation: myRequestStatus.educationModel.hasMedicalEducation == 1, + hasMedicalEducation: + myRequestStatus.educationModel.hasMedicalEducation == 1, legalType: legal?.type ?? individualPersonLegalType, lastName: _emptyToNull(legal?.lastName), firstName: _emptyToNull(legal?.firstName), @@ -120,13 +155,30 @@ class RegisterConsultantCubit extends Cubit { certPhotos: certPhotos, sbpBanks: sbpBanks, isUpdating: _update, + alfaScoringStatus: scoringStatus, + isScoringPolling: scoringStatus.isProcessing, + scoringRetryInProgress: false, + awaitingScoringOutcome: false, + fieldErrors: const {}, error: null, ), ); + + if (scoringStatus.isProcessing) { + startScoringPoll(); + } } catch (e, st) { await reportFrom(e, st, this); log(e.toString()); - emit(state.copyWith(loading: false, error: e.toString())); + final parsed = parseLaravelValidationErrors(e); + emit( + state.copyWith( + loading: false, + error: parsed.summary, + fieldErrors: parsed.fieldErrors, + awaitingScoringOutcome: false, + ), + ); } } @@ -137,8 +189,9 @@ class RegisterConsultantCubit extends Cubit { return value.trim(); } - Future disableLoading() async { + Future disableLoading({String? slug}) async { try { + setUserSlug(slug); final certPhotos = await consultantRepository.getConsultantCertPhotos(); final sbpBanks = await _loadSbpBanks(); emit( @@ -147,6 +200,8 @@ class RegisterConsultantCubit extends Cubit { certPhotos: certPhotos, sbpBanks: sbpBanks, error: null, + fieldErrors: const {}, + awaitingScoringOutcome: false, consultant: state.consultant.copyWith( legalType: individualPersonLegalType, citizenship: state.consultant.citizenship, @@ -158,7 +213,15 @@ class RegisterConsultantCubit extends Cubit { } catch (e, st) { await reportFrom(e, st, this); log(e.toString()); - emit(state.copyWith(loading: false, error: e.toString())); + final parsed = parseLaravelValidationErrors(e); + emit( + state.copyWith( + loading: false, + error: parsed.summary, + fieldErrors: parsed.fieldErrors, + awaitingScoringOutcome: false, + ), + ); } } @@ -168,11 +231,15 @@ class RegisterConsultantCubit extends Cubit { if (slug != null) { await getMyRequestStatus(slug); } else { - await disableLoading(); + await disableLoading(slug: _userSlug); } } - void updateConsultantProfileModel(ConsultantProfileModel consultant, [bool? acceptOffer, bool? acceptFrdoAgreement]) { + void updateConsultantProfileModel( + ConsultantProfileModel consultant, [ + bool? acceptOffer, + bool? acceptFrdoAgreement, + ]) { emit( state.copyWith( consultant: consultant, @@ -180,12 +247,19 @@ class RegisterConsultantCubit extends Cubit { acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement, loading: false, error: null, + fieldErrors: const {}, ), ); } void updateAgreements({bool? acceptOffer, bool? acceptFrdoAgreement}) { - emit(state.copyWith(acceptOffer: acceptOffer ?? state.acceptOffer, acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement, error: null)); + emit( + state.copyWith( + acceptOffer: acceptOffer ?? state.acceptOffer, + acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement, + error: null, + ), + ); } bool get hasAcceptedRequiredAgreements { @@ -202,7 +276,13 @@ class RegisterConsultantCubit extends Cubit { taxResidentUs: state.consultant.taxResidentUs ?? false, foreignTaxResident: state.consultant.foreignTaxResident ?? false, certificates: state.consultant.certificates - ?.map((course) => CertificatesModel(title: course.title, date: LegalEntityModel.normalizeApiDate(course.date) ?? course.date)) + ?.map( + (course) => CertificatesModel( + title: course.title, + date: + LegalEntityModel.normalizeApiDate(course.date) ?? course.date, + ), + ) .toList(), ); @@ -235,40 +315,241 @@ class RegisterConsultantCubit extends Cubit { ); } - return withLegalType.copyWith(agreementAccepted: state.acceptOffer, frdoAgreementAccepted: state.acceptFrdoAgreement); + return withLegalType.copyWith( + agreementAccepted: state.acceptOffer, + frdoAgreementAccepted: state.acceptFrdoAgreement, + ); } Future registerConsultant() async { - emit(state.copyWith(error: null)); + if (!state.consultant.hasValidPassportData) { + emit(state.copyWith(error: 'Заполните паспортные данные')); + return; + } + + // Show scoring widget immediately while request / job are in progress. + emit( + state.copyWith( + error: null, + fieldErrors: const {}, + alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating, + awaitingScoringOutcome: true, + scoringRetryInProgress: false, + ), + ); + try { - if (!state.consultant.hasValidPassportData) { - emit(state.copyWith(error: 'Заполните паспортные данные')); - return; - } if (state.newAddedPhotos.isNotEmpty) { - await consultantRepository.storeConsultantCertPhotos(files: state.newAddedPhotos); + await consultantRepository.storeConsultantCertPhotos( + files: state.newAddedPhotos, + ); } if (state.deletedPhotoIds.isNotEmpty) { for (final int id in state.deletedPhotoIds) { await consultantRepository.deletePhoto(id); } } - await consultantRepository.registerConsultant(consultant: buildSubmitPayload(), update: _update); + await consultantRepository.registerConsultant( + consultant: buildSubmitPayload(), + update: _update, + ); + _update = true; + emit( + state.copyWith( + isUpdating: true, + alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating, + isScoringPolling: true, + scoringRetryInProgress: false, + awaitingScoringOutcome: true, + newAddedPhotos: const [], + deletedPhotoIds: const [], + fieldErrors: const {}, + error: null, + ), + ); + startScoringPoll(); + } on DioException catch (e, st) { + await reportFrom(e, st, this); + log(e.toString()); + final parsed = parseLaravelValidationErrors(e); + emit( + state.copyWith( + error: parsed.summary, + fieldErrors: parsed.fieldErrors, + scoringRetryInProgress: false, + awaitingScoringOutcome: false, + isScoringPolling: false, + alfaScoringStatus: AlfaScoringStatus.none, + ), + ); } catch (e, st) { await reportFrom(e, st, this); log(e.toString()); - emit(state.copyWith(error: e.toString())); + emit( + state.copyWith( + error: e.toString(), + scoringRetryInProgress: false, + awaitingScoringOutcome: false, + isScoringPolling: false, + alfaScoringStatus: AlfaScoringStatus.none, + ), + ); } } + Future retryScoring() async { + if (!state.alfaScoringStatus.canRetry || state.scoringRetryInProgress) { + return; + } + emit( + state.copyWith( + scoringRetryInProgress: true, + error: null, + fieldErrors: const {}, + alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating, + isScoringPolling: true, + awaitingScoringOutcome: true, + ), + ); + _update = true; + try { + if (!state.consultant.hasValidPassportData) { + emit( + state.copyWith( + scoringRetryInProgress: false, + isScoringPolling: false, + awaitingScoringOutcome: false, + alfaScoringStatus: AlfaScoringStatus.scoringFailed, + error: 'Заполните паспортные данные', + ), + ); + return; + } + await consultantRepository.registerConsultant( + consultant: buildSubmitPayload(), + update: true, + ); + startScoringPoll(); + emit( + state.copyWith( + scoringRetryInProgress: false, + isUpdating: true, + awaitingScoringOutcome: true, + ), + ); + } on DioException catch (e, st) { + await reportFrom(e, st, this); + log(e.toString()); + final parsed = parseLaravelValidationErrors(e); + emit( + state.copyWith( + scoringRetryInProgress: false, + isScoringPolling: false, + awaitingScoringOutcome: false, + alfaScoringStatus: AlfaScoringStatus.scoringFailed, + error: parsed.summary, + fieldErrors: parsed.fieldErrors, + ), + ); + } catch (e, st) { + await reportFrom(e, st, this); + log(e.toString()); + emit( + state.copyWith( + scoringRetryInProgress: false, + isScoringPolling: false, + awaitingScoringOutcome: false, + alfaScoringStatus: AlfaScoringStatus.scoringFailed, + error: e.toString(), + ), + ); + } + } + + void startScoringPoll() { + final slug = _userSlug; + if (slug == null || slug.isEmpty) { + emit( + state.copyWith(isScoringPolling: false, awaitingScoringOutcome: false), + ); + return; + } + _stopScoringPoll(); + emit(state.copyWith(isScoringPolling: true)); + _scoringPollTimer = Timer.periodic( + scoringPollInterval, + (_) => _pollScoringStatus(slug), + ); + unawaited(_pollScoringStatus(slug)); + } + + Future _pollScoringStatus(String slug) async { + if (isClosed) { + return; + } + try { + final status = await consultantRepository.getMyRequestStatus(slug); + if (isClosed) { + return; + } + final scoringStatus = + status.legalEntity?.alfaScoringStatus ?? AlfaScoringStatus.none; + // API stays on `none` until ProcessConsultantScoringJob starts + // (ScoringStarted → beneficiary_creating). Keep optimistic processing + // UI and continue polling until a real terminal/processing status arrives. + if (scoringStatus == AlfaScoringStatus.none) { + emit( + state.copyWith( + requestModel: status, + alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating, + isScoringPolling: true, + ), + ); + return; + } + final isTerminal = scoringStatus.isTerminal; + emit( + state.copyWith( + requestModel: status, + alfaScoringStatus: scoringStatus, + isScoringPolling: scoringStatus.isProcessing, + // Clear session flag on terminal so success sheet can fire once + // (listenWhen: previous.awaiting && !current.awaiting && complete). + awaitingScoringOutcome: isTerminal + ? false + : state.awaitingScoringOutcome, + ), + ); + if (!scoringStatus.isProcessing) { + _stopScoringPoll(); + } + } catch (e, st) { + await reportFrom(e, st, this); + log(e.toString()); + // Keep polling on transient errors while scoring is in progress. + } + } + + void _stopScoringPoll() { + _scoringPollTimer?.cancel(); + _scoringPollTimer = null; + } + Future addCertPhotos(List files) async { if (files.isEmpty) { return; } try { - final compressedFiles = await ImageCompressUtility.compressFilesList(files); + final compressedFiles = await ImageCompressUtility.compressFilesList( + files, + ); - emit(state.copyWith(newAddedPhotos: [...state.newAddedPhotos, ...compressedFiles], error: null)); + emit( + state.copyWith( + newAddedPhotos: [...state.newAddedPhotos, ...compressedFiles], + error: null, + ), + ); } catch (e, st) { await reportFrom(e, st, this); log(e.toString()); @@ -282,7 +563,19 @@ class RegisterConsultantCubit extends Cubit { return; } if (path != null) { - emit(state.copyWith(newAddedPhotos: state.newAddedPhotos.where((file) => file.uri.path != path).toList())); + emit( + state.copyWith( + newAddedPhotos: state.newAddedPhotos + .where((file) => file.uri.path != path) + .toList(), + ), + ); } } + + @override + Future close() { + _stopScoringPoll(); + return super.close(); + } } diff --git a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart index a39a1313..42daeb1e 100644 --- a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart +++ b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; @@ -17,10 +18,18 @@ abstract class RegisterConsultantState with _$RegisterConsultantState { required bool loading, required bool isUpdating, ConsultationRequestModel? requestModel, - @Default([]) List certPhotos, + @Default([]) + List certPhotos, @Default([]) List sbpBanks, @Default([]) List newAddedPhotos, @Default([]) List deletedPhotoIds, + @Default(AlfaScoringStatus.none) AlfaScoringStatus alfaScoringStatus, + @Default(false) bool isScoringPolling, + @Default(false) bool scoringRetryInProgress, + + /// True only after successful register/retry in this session until scoring is terminal. + @Default(false) bool awaitingScoringOutcome, + @Default({}) Map fieldErrors, String? error, }) = _RegisterConsultantState; } diff --git a/lib/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart index ce13f905..90b673a5 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart @@ -8,7 +8,8 @@ class AdditionalSettingsScreen extends StatefulWidget { const AdditionalSettingsScreen({super.key}); @override - State createState() => _AdditionalSettingsScreenState(); + State createState() => + _AdditionalSettingsScreenState(); } class _AdditionalSettingsScreenState extends State { @@ -17,7 +18,12 @@ class _AdditionalSettingsScreenState extends State { @override void initState() { - if (context.read().state.consultant.hasMedicalEducation != null) { + if (context + .read() + .state + .consultant + .hasMedicalEducation != + null) { haveMedicalDegree = context .read() .state @@ -52,7 +58,8 @@ class _AdditionalSettingsScreenState extends State { value: publishDiplomasAndCertificatesInProfile, onTap: () { setState(() { - publishDiplomasAndCertificatesInProfile = !publishDiplomasAndCertificatesInProfile; + publishDiplomasAndCertificatesInProfile = + !publishDiplomasAndCertificatesInProfile; }); }, ), @@ -64,11 +71,15 @@ class _AdditionalSettingsScreenState extends State { setState(() { haveMedicalDegree = !haveMedicalDegree; }); - context.read().updateConsultantProfileModel( - context.read().state.consultant.copyWith( - hasMedicalEducation: haveMedicalDegree, - ), - ); + context + .read() + .updateConsultantProfileModel( + context + .read() + .state + .consultant + .copyWith(hasMedicalEducation: haveMedicalDegree), + ); }, ), ], 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 40d4a352..2955ee09 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 @@ -3,7 +3,9 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; class BecomeConsultantScreen extends StatefulWidget { - const BecomeConsultantScreen({super.key}); + final String? slug; + + const BecomeConsultantScreen({super.key, this.slug}); @override State createState() => _BecomeConsultantScreenState(); @@ -17,7 +19,10 @@ class _BecomeConsultantScreenState extends State { title: const Text('Стать консультантом'), automaticallyImplyLeading: false, centerTitle: false, - leading: IconButton(onPressed: () => GoRouter.of(context).pop(), icon: const Icon(Icons.close)), + leading: IconButton( + onPressed: () => GoRouter.of(context).pop(), + icon: const Icon(Icons.close), + ), ), body: const Padding( padding: EdgeInsets.all(16), @@ -32,16 +37,27 @@ class _BecomeConsultantScreenState extends State { InkWell _startRegistrationButton() { return InkWell( - onTap: () => GoRouter.of(context).pushReplacementNamed('stepperBecomeConsultantScreen', extra: {'slug': null}), + onTap: () => GoRouter.of(context).pushReplacementNamed( + 'stepperBecomeConsultantScreen', + extra: {'slug': widget.slug, 'isNewRegistration': true}, + ), borderRadius: BorderRadius.circular(20), child: Ink( height: 52, width: MediaQuery.of(context).size.width - 32, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: appTheme.blueA400, + ), child: const Center( child: Text( 'Начать регистрацию', - style: TextStyle(color: Colors.white, height: 1, fontWeight: FontWeight.w600, fontSize: 18), + style: TextStyle( + color: Colors.white, + height: 1, + fontWeight: FontWeight.w600, + fontSize: 18, + ), ), ), ), diff --git a/lib/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart index afff462e..33772eb5 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart @@ -13,10 +13,12 @@ class CertificatesAndDiplomasScreen extends StatefulWidget { const CertificatesAndDiplomasScreen({super.key}); @override - State createState() => _CertificatesAndDiplomasScreenState(); + State createState() => + _CertificatesAndDiplomasScreenState(); } -class _CertificatesAndDiplomasScreenState extends State { +class _CertificatesAndDiplomasScreenState + extends State { final scrollController = ScrollController(); List diplomasList = [ @@ -30,9 +32,15 @@ class _CertificatesAndDiplomasScreenState extends State().state.consultant.certificates != null) { + if (context.read().state.consultant.certificates != + null) { diplomasList = [ - ...context.read().state.consultant.certificates!.map( + ...context + .read() + .state + .consultant + .certificates! + .map( (e) => DiplomaEntity( date: DateTime.tryParse(e.date), textEditingController: TextEditingController(text: e.title), @@ -71,7 +79,8 @@ class _CertificatesAndDiplomasScreenState extends State _diplomaInfo(diploma: diplomasList[index]), + itemBuilder: (context, index) => + _diplomaInfo(diploma: diplomasList[index]), separatorBuilder: (context, index) => Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: _divider(context), @@ -94,9 +103,7 @@ class _CertificatesAndDiplomasScreenState extends State().updateConsultantProfileModel( - context.read().state.consultant.copyWith( - certificates: diplomasList - .map( - (e) => CertificatesModel( - title: e.textEditingController.text, - date: e.date == null ? '' : DateFormat('yyyy-MM-dd').format(e.date!), - ), - ) - .toList() - .sublist(0, diplomasList.length - 1), + context.read().state.consultant.copyWith( + certificates: diplomasList + .map( + (e) => CertificatesModel( + title: e.textEditingController.text, + date: e.date == null + ? '' + : DateFormat('yyyy-MM-dd').format(e.date!), ), - ); + ) + .toList() + .sublist(0, diplomasList.length - 1), + ), + ); } } diff --git a/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart index afdcbf83..5e87ba50 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart @@ -1,8 +1,10 @@ import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/const.dart'; import 'package:eatvio_app/core/locator_service.dart'; -import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/scoring_status_widget.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -24,7 +26,8 @@ class _LastStepScreenState extends State { @override void initState() { super.initState(); - _frdoTapRecognizer = TapGestureRecognizer()..onTap = () async => await launchUrl(Uri.parse(_frdoUrl)); + _frdoTapRecognizer = TapGestureRecognizer() + ..onTap = () async => await launchUrl(Uri.parse(_frdoUrl)); } @override @@ -46,8 +49,22 @@ class _LastStepScreenState extends State { children: [ const Padding( padding: EdgeInsets.symmetric(horizontal: 16), - child: Text('Последний шаг', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)), + child: Text( + 'Последний шаг', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + height: 1.1, + ), + ), ), + if (state.awaitingScoringOutcome || + state.isScoringPolling || + state.alfaScoringStatus != + AlfaScoringStatus.none) ...[ + const SizedBox(height: 16), + const ScoringStatusWidget(), + ], if (state.requestModel?.canceledModeration != null) ...[ const SizedBox(height: 16), Padding( @@ -64,12 +81,24 @@ class _LastStepScreenState extends State { children: [ Text( state.requestModel!.canceledModeration!.title, - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, height: 1.2), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + height: 1.2, + ), ), - if (state.requestModel!.canceledModeration!.additional != null) ...[ + if (state.requestModel!.canceledModeration!.additional != + null) ...[ const SizedBox(height: 8), - for (final item in state.requestModel!.canceledModeration!.additional!) ...[ - Text('• $item', style: const TextStyle(fontSize: 14, height: 1.3)), + for (final item + in state + .requestModel! + .canceledModeration! + .additional!) ...[ + Text( + '• $item', + style: const TextStyle(fontSize: 14, height: 1.3), + ), ], ], ], @@ -98,9 +127,16 @@ class _LastStepScreenState extends State { value: state.acceptFrdoAgreement, title: Text.rich( TextSpan( - style: const TextStyle(fontSize: 18, height: 1.2, color: Color(0xFF323F4B)), + style: const TextStyle( + fontSize: 18, + height: 1.2, + color: Color(0xFF323F4B), + ), children: [ - const TextSpan(text: 'Обязательное требование: Один из моих документов об образовании есть в реестре '), + const TextSpan( + text: + 'Обязательное требование: Один из моих документов об образовании есть в реестре ', + ), TextSpan( text: 'ФРДО', style: TextStyle( @@ -120,16 +156,31 @@ class _LastStepScreenState extends State { controlAffinity: ListTileControlAffinity.leading, ), ], - Padding(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: _divider(context)), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + child: _divider(context), + ), Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: TextButton( onPressed: () async => await launchUrl( - Uri.parse(hidePlans ? '${sl().baseUrl}/docs/privacy.pdf' : '${sl().baseUrl}/oferta_bs.pdf'), + Uri.parse( + hidePlans + ? '${sl().baseUrl}/docs/privacy.pdf' + : '${sl().baseUrl}/oferta_bs.pdf', + ), ), child: Text( - hidePlans ? 'Политика конфиденциальности и пользовательское соглашение' : 'Договор оферты', - style: const TextStyle(height: 1, fontSize: 16, color: Color(0xFF323F4B), decoration: TextDecoration.underline), + hidePlans + ? 'Политика конфиденциальности и пользовательское соглашение' + : 'Договор оферты', + style: const TextStyle( + height: 1, + fontSize: 16, + color: Color(0xFF323F4B), + decoration: TextDecoration.underline, + ), ), ), ), @@ -142,7 +193,10 @@ class _LastStepScreenState extends State { return Container( height: 0.4, width: MediaQuery.of(context).size.width - 32, - decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(16)), + decoration: BoxDecoration( + color: Colors.black26, + borderRadius: BorderRadius.circular(16), + ), ); } } diff --git a/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart index 814f2139..466f4418 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart @@ -1,8 +1,11 @@ +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_text_form_field.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/passport_field_with_error.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/passport_section_title.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart'; @@ -100,9 +103,7 @@ class _PassportDataScreenState extends State { birthPlaceController = TextEditingController( text: consultant.birthPlace ?? '', ); - citizenshipController = TextEditingController( - text: consultant.citizenship, - ); + citizenshipController = TextEditingController(text: consultant.citizenship); registrationAddressController = TextEditingController( text: consultant.registrationAddress ?? '', ); @@ -269,258 +270,321 @@ class _PassportDataScreenState extends State { } } - Widget _sectionTitle(String title) { - return Padding( - padding: const EdgeInsets.only(top: 8, bottom: 12), - child: Text( - title, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - height: 1.2, - ), - ), - ); - } - @override Widget build(BuildContext context) { return BlocListener( listenWhen: (previous, current) => previous.sbpBanks != current.sbpBanks, listener: (context, state) => _hydrateSbpBankNameIfNeeded(), - child: SingleChildScrollView( - padding: const EdgeInsets.only( - bottom: 80, - right: 16, - left: 16, - top: 16, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Паспортные данные', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.w700, - height: 1.1, - ), + child: BlocBuilder( + buildWhen: (previous, current) => + previous.fieldErrors != current.fieldErrors, + builder: (context, state) { + final errors = state.fieldErrors; + return SingleChildScrollView( + padding: const EdgeInsets.only( + bottom: 80, + right: 16, + left: 16, + top: 16, ), - _sectionTitle('Персональные данные (как в паспорте)'), - CustomTextFormField( - hintText: 'Фамилия', - controller: lastNameController, - onChanged: (_) => _syncToCubit(), - ), - const SizedBox(height: 12), - CustomTextFormField( - hintText: 'Имя', - controller: firstNameController, - onChanged: (_) => _syncToCubit(), - ), - const SizedBox(height: 12), - CustomTextFormField( - hintText: 'Отчество', - controller: middleNameController, - onChanged: (_) => _syncToCubit(), - ), - _sectionTitle('Идентификация'), - CustomTextFormField( - hintText: 'ИНН (12 цифр)', - controller: innController, - keyboardType: TextInputType.number, - maxLength: 12, - onChanged: (_) => _syncToCubit(), - ), - const SizedBox(height: 12), - CustomTextFormField( - hintText: 'СНИЛС', - controller: snilsController, - keyboardType: TextInputType.number, - inputFormatters: [snilsMask], - onChanged: (_) => _syncToCubit(), - ), - const SizedBox(height: 12), - CustomTextFormField( - hintText: 'Дата рождения (ДД.ММ.ГГГГ)', - controller: birthDateController, - keyboardType: TextInputType.number, - inputFormatters: [dateMaskBirth], - onChanged: (_) => _syncToCubit(), - ), - const SizedBox(height: 12), - CustomTextFormField( - hintText: 'Место рождения', - controller: birthPlaceController, - onChanged: (_) => _syncToCubit(), - ), - const SizedBox(height: 12), - CustomTextFormField( - hintText: 'Гражданство (ISO, например RU)', - controller: citizenshipController, - maxLength: 2, - onChanged: (_) => _syncToCubit(), - ), - const SizedBox(height: 12), - CustomTextFormField( - hintText: 'Адрес регистрации', - controller: registrationAddressController, - onChanged: (_) => _syncToCubit(), - ), - const SizedBox(height: 12), - CustomTextFormField( - hintText: 'Адрес проживания', - controller: residenceAddressController, - onChanged: (_) => _syncToCubit(), - ), - const SizedBox(height: 8), - CheckboxListTile( - value: taxResidentUs, - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - activeColor: appTheme.blueA400, - title: const Text( - 'Я являюсь налоговым резидентом США', - style: TextStyle(fontSize: 16, height: 1.2), - ), - onChanged: (value) { - setState(() => taxResidentUs = value ?? false); - _syncToCubit(); - }, - ), - CheckboxListTile( - value: foreignTaxResident, - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - activeColor: appTheme.blueA400, - title: const Text( - 'Я являюсь иностранным налоговым резидентом', - style: TextStyle(fontSize: 16, height: 1.2), - ), - onChanged: (value) { - setState(() => foreignTaxResident = value ?? false); - _syncToCubit(); - }, - ), - _sectionTitle('Паспорт'), - Row( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( + const PassportScreenTitle(title: 'Паспортные данные'), + const PassportSectionTitle( + title: 'Персональные данные (как в паспорте)', + ), + PassportFieldWithError( + error: errors['last_name'], child: CustomTextFormField( - hintText: 'Серия', - controller: seriesController, - keyboardType: TextInputType.number, - maxLength: 4, + hintText: 'Фамилия', + controller: lastNameController, + hasError: errors.containsKey('last_name'), onChanged: (_) => _syncToCubit(), ), ), - const SizedBox(width: 8), - Expanded( + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['first_name'], child: CustomTextFormField( - hintText: 'Номер', - controller: numberController, - keyboardType: TextInputType.number, - maxLength: 6, + hintText: 'Имя', + controller: firstNameController, + hasError: errors.containsKey('first_name'), onChanged: (_) => _syncToCubit(), ), ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['middle_name'], + child: CustomTextFormField( + hintText: 'Отчество', + controller: middleNameController, + hasError: errors.containsKey('middle_name'), + onChanged: (_) => _syncToCubit(), + ), + ), + const PassportSectionTitle(title: 'Идентификация'), + PassportFieldWithError( + error: errors['inn'], + child: CustomTextFormField( + hintText: 'ИНН (12 цифр)', + controller: innController, + keyboardType: TextInputType.number, + maxLength: 12, + hasError: errors.containsKey('inn'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['snils'], + child: CustomTextFormField( + hintText: 'СНИЛС', + controller: snilsController, + keyboardType: TextInputType.number, + inputFormatters: [snilsMask], + hasError: errors.containsKey('snils'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['birth_date'], + child: CustomTextFormField( + hintText: 'Дата рождения (ДД.ММ.ГГГГ)', + controller: birthDateController, + keyboardType: TextInputType.number, + inputFormatters: [dateMaskBirth], + hasError: errors.containsKey('birth_date'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['birth_place'], + child: CustomTextFormField( + hintText: 'Место рождения', + controller: birthPlaceController, + hasError: errors.containsKey('birth_place'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['citizenship'], + child: CustomTextFormField( + hintText: 'Гражданство (ISO, например RU)', + controller: citizenshipController, + maxLength: 2, + hasError: errors.containsKey('citizenship'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['registration_address'], + child: CustomTextFormField( + hintText: 'Адрес регистрации', + controller: registrationAddressController, + hasError: errors.containsKey('registration_address'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['residence_address'], + child: CustomTextFormField( + hintText: 'Адрес проживания', + controller: residenceAddressController, + hasError: errors.containsKey('residence_address'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 8), + CheckboxListTile( + value: taxResidentUs, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + activeColor: appTheme.blueA400, + title: Text( + 'Я являюсь налоговым резидентом США', + style: TextStyles.textMMediumAuto.copyWith( + color: appTheme.black, + ), + ), + onChanged: (value) { + setState(() => taxResidentUs = value ?? false); + _syncToCubit(); + }, + ), + CheckboxListTile( + value: foreignTaxResident, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + activeColor: appTheme.blueA400, + title: Text( + 'Я являюсь иностранным налоговым резидентом', + style: TextStyles.textMMediumAuto.copyWith( + color: appTheme.black, + ), + ), + onChanged: (value) { + setState(() => foreignTaxResident = value ?? false); + _syncToCubit(); + }, + ), + const PassportSectionTitle(title: 'Паспорт'), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: PassportFieldWithError( + error: errors['passport_series'], + child: CustomTextFormField( + hintText: 'Серия', + controller: seriesController, + keyboardType: TextInputType.number, + maxLength: 4, + hasError: errors.containsKey('passport_series'), + onChanged: (_) => _syncToCubit(), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: PassportFieldWithError( + error: errors['passport_number'], + child: CustomTextFormField( + hintText: 'Номер', + controller: numberController, + keyboardType: TextInputType.number, + maxLength: 6, + hasError: errors.containsKey('passport_number'), + onChanged: (_) => _syncToCubit(), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['passport_issue_date'], + child: CustomTextFormField( + hintText: 'Дата выдачи (ДД.ММ.ГГГГ)', + controller: issueDateController, + keyboardType: TextInputType.number, + inputFormatters: [dateMaskIssue], + hasError: errors.containsKey('passport_issue_date'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['passport_issuer_code'], + child: CustomTextFormField( + hintText: 'Код подразделения', + controller: issuerCodeController, + keyboardType: TextInputType.number, + inputFormatters: [issuerCodeMask], + hasError: errors.containsKey('passport_issuer_code'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['passport_issuer'], + child: CustomTextFormField( + hintText: 'Кем выдан', + controller: issuerController, + hasError: errors.containsKey('passport_issuer'), + onChanged: (_) => _syncToCubit(), + ), + ), + const PassportSectionTitle(title: 'Реквизиты СБП'), + PassportFieldWithError( + error: errors['sbp_phone'], + child: CustomTextFormField( + hintText: 'Телефон для выплат через СБП', + controller: sbpPhoneController, + keyboardType: TextInputType.phone, + inputFormatters: [phoneMask], + hasError: errors.containsKey('sbp_phone'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['sbp_bank_member_id'], + child: TypeAheadField( + debounceDuration: const Duration(milliseconds: 250), + controller: sbpBankController, + suggestionsController: sbpBankSuggestionsController, + builder: (context, controller, focusNode) => + CustomTextFormField( + controller: controller, + focusNode: focusNode, + hintText: 'Банк получатель (поиск)', + hasError: errors.containsKey('sbp_bank_member_id'), + onChanged: _clearSbpBankIfNeeded, + ), + suggestionsCallback: (query) async { + return context + .read() + .searchSbpBanks(query); + }, + itemBuilder: (context, bank) { + return ListTile( + dense: true, + title: Text( + bank.bankName, + style: TextStyles.textMMedium.copyWith( + color: appTheme.black900, + ), + ), + subtitle: + bank.shortName == null || bank.shortName!.isEmpty + ? null + : Text( + bank.shortName!, + style: TextStyles.textSMedium.copyWith( + color: appTheme.grey7B, + ), + ), + ); + }, + decorationBuilder: (context, child) => Material( + type: MaterialType.card, + elevation: 4, + borderRadius: BorderRadius.circular(8), + color: appTheme.whiteA700, + child: child, + ), + emptyBuilder: (context) => Padding( + padding: const EdgeInsets.all(12), + child: Text( + 'Ничего не найдено', + style: TextStyles.textSMedium.copyWith( + color: appTheme.grey7B, + ), + ), + ), + onSelected: _selectSbpBank, + ), + ), + const SizedBox(height: 16), + Divider(height: 0.4, thickness: 0.4, color: appTheme.greyD9), + const SizedBox(height: 16), + Text( + 'Данные используются для проверки и скоринга. Заполните поля в соответствии с паспортом РФ.', + style: TextStyles.textSMedium.copyWith( + color: appTheme.blueA400, + ), + ), ], ), - const SizedBox(height: 12), - CustomTextFormField( - hintText: 'Дата выдачи (ДД.ММ.ГГГГ)', - controller: issueDateController, - keyboardType: TextInputType.number, - inputFormatters: [dateMaskIssue], - onChanged: (_) => _syncToCubit(), - ), - const SizedBox(height: 12), - CustomTextFormField( - hintText: 'Код подразделения', - controller: issuerCodeController, - keyboardType: TextInputType.number, - inputFormatters: [issuerCodeMask], - onChanged: (_) => _syncToCubit(), - ), - const SizedBox(height: 12), - CustomTextFormField( - hintText: 'Кем выдан', - controller: issuerController, - onChanged: (_) => _syncToCubit(), - ), - _sectionTitle('Реквизиты СБП'), - CustomTextFormField( - hintText: 'Телефон для выплат через СБП', - controller: sbpPhoneController, - keyboardType: TextInputType.phone, - inputFormatters: [phoneMask], - onChanged: (_) => _syncToCubit(), - ), - const SizedBox(height: 12), - TypeAheadField( - debounceDuration: const Duration(milliseconds: 250), - controller: sbpBankController, - suggestionsController: sbpBankSuggestionsController, - builder: (context, controller, focusNode) => CustomTextFormField( - controller: controller, - focusNode: focusNode, - hintText: 'Банк получатель (поиск)', - onChanged: (value) { - _clearSbpBankIfNeeded(value); - }, - ), - suggestionsCallback: (query) async { - return context.read().searchSbpBanks( - query, - ); - }, - itemBuilder: (context, bank) { - return ListTile( - dense: true, - title: Text( - bank.bankName, - style: const TextStyle(fontSize: 16, color: Colors.black), - ), - subtitle: bank.shortName == null || bank.shortName!.isEmpty - ? null - : Text( - bank.shortName!, - style: const TextStyle( - fontSize: 13, - color: Colors.black54, - ), - ), - ); - }, - decorationBuilder: (context, child) => Material( - type: MaterialType.card, - elevation: 4, - borderRadius: BorderRadius.circular(8), - child: child, - ), - emptyBuilder: (context) => const Padding( - padding: EdgeInsets.all(12), - child: Text( - 'Ничего не найдено', - style: TextStyle(color: Colors.black54), - ), - ), - onSelected: _selectSbpBank, - ), - const SizedBox(height: 16), - const Divider(height: 0.4, thickness: 0.4, color: Colors.black26), - const SizedBox(height: 16), - Text( - 'Данные используются для проверки и скоринга. Заполните поля в соответствии с паспортом РФ.', - style: TextStyle( - fontSize: 14, - height: 1.2, - color: appTheme.blueA400, - ), - ), - ], - ), + ); + }, ), ); } diff --git a/lib/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart index cf21b646..87269eb5 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart @@ -29,32 +29,48 @@ class _PhotoOrScansScreenState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Загрузите фото или сканы дипломов и сертификатов', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)), + const Text( + 'Загрузите фото или сканы дипломов и сертификатов', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + height: 1.1, + ), + ), const SizedBox(height: 16), BlocBuilder( builder: (context, state) { - final photos = state.certPhotos.where((photo) => !state.deletedPhotoIds.contains(photo.id)).toList(); + final photos = state.certPhotos + .where((photo) => !state.deletedPhotoIds.contains(photo.id)) + .toList(); final addedPhotos = state.newAddedPhotos; final totalPhotosCount = photos.length + addedPhotos.length; return Column( children: [ - if (isUploading) ...[const LinearProgressIndicator(), const SizedBox(height: 16)], + if (isUploading) ...[ + const LinearProgressIndicator(), + const SizedBox(height: 16), + ], GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - childAspectRatio: 3 / 4, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - ), + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + childAspectRatio: 3 / 4, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), itemBuilder: (BuildContext context, int index) { if (index == totalPhotosCount) { return InkWell( borderRadius: BorderRadius.circular(20), onTap: () => _pickAndUploadPhotos(), child: Ink( - decoration: BoxDecoration(color: const Color(0xFFF6F6F6), borderRadius: BorderRadius.circular(20)), + decoration: BoxDecoration( + color: const Color(0xFFF6F6F6), + borderRadius: BorderRadius.circular(20), + ), child: Icon(Icons.add, color: appTheme.blueA400), ), ); @@ -72,7 +88,8 @@ class _PhotoOrScansScreenState extends State { return _LocalCertPhotoCard( photo: localPhoto, allPhotos: addedPhotos, - onRemove: () => _removePhoto(path: localPhoto.uri.path), + onRemove: () => + _removePhoto(path: localPhoto.uri.path), ); } } @@ -99,7 +116,9 @@ class _PhotoOrScansScreenState extends State { }); try { // ignore: use_build_context_synchronously - await context.read().addCertPhotos(newImages.map((file) => File(file.path)).toList()); + await context.read().addCertPhotos( + newImages.map((file) => File(file.path)).toList(), + ); } finally { if (mounted) { setState(() { @@ -119,7 +138,11 @@ class _CertPhotoCard extends StatelessWidget { final List allPhotos; final VoidCallback onRemove; - const _CertPhotoCard({required this.photo, required this.allPhotos, required this.onRemove}); + const _CertPhotoCard({ + required this.photo, + required this.allPhotos, + required this.onRemove, + }); @override Widget build(BuildContext context) { @@ -131,7 +154,11 @@ class _CertPhotoCard extends StatelessWidget { onTap: () { context.pushTransparentRoute( rootNavigator: true, - PhotoViewScreen(imageUrls: allPhotos.map((e) => e.url).toList(), isLocal: false, index: allPhotos.indexOf(photo)), + PhotoViewScreen( + imageUrls: allPhotos.map((e) => e.url).toList(), + isLocal: false, + index: allPhotos.indexOf(photo), + ), ); }, borderRadius: BorderRadius.circular(20), @@ -141,7 +168,10 @@ class _CertPhotoCard extends StatelessWidget { Ink( decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(20)), - image: DecorationImage(fit: BoxFit.cover, image: NetworkImage(photo.url)), + image: DecorationImage( + fit: BoxFit.cover, + image: NetworkImage(photo.url), + ), ), ), Positioned( @@ -152,8 +182,15 @@ class _CertPhotoCard extends StatelessWidget { customBorder: const CircleBorder(), child: Ink( padding: const EdgeInsets.all(4), - decoration: const BoxDecoration(color: Color(0xFFF1F2F6), shape: BoxShape.circle), - child: const Icon(size: 20, Icons.close, color: Colors.black), + decoration: const BoxDecoration( + color: Color(0xFFF1F2F6), + shape: BoxShape.circle, + ), + child: const Icon( + size: 20, + Icons.close, + color: Colors.black, + ), ), ), ), @@ -170,7 +207,11 @@ class _LocalCertPhotoCard extends StatelessWidget { final List allPhotos; final VoidCallback onRemove; - const _LocalCertPhotoCard({required this.photo, required this.allPhotos, required this.onRemove}); + const _LocalCertPhotoCard({ + required this.photo, + required this.allPhotos, + required this.onRemove, + }); @override Widget build(BuildContext context) { @@ -210,8 +251,15 @@ class _LocalCertPhotoCard extends StatelessWidget { customBorder: const CircleBorder(), child: Ink( padding: const EdgeInsets.all(4), - decoration: const BoxDecoration(color: Color(0xFFF1F2F6), shape: BoxShape.circle), - child: const Icon(size: 20, Icons.close, color: Colors.black), + decoration: const BoxDecoration( + color: Color(0xFFF1F2F6), + shape: BoxShape.circle, + ), + child: const Icon( + size: 20, + Icons.close, + color: Colors.black, + ), ), ), ), diff --git a/lib/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart index a754de27..e10fc74a 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart @@ -13,7 +13,8 @@ class RegistrationAsScreen extends StatefulWidget { enum RegistrationAsType { physical, selfBusy, iP, ooo } class _RegistrationAsScreenState extends State { - static const RegistrationAsType registrationAsType = RegistrationAsType.physical; + static const RegistrationAsType registrationAsType = + RegistrationAsType.physical; @override void initState() { @@ -24,7 +25,9 @@ class _RegistrationAsScreenState extends State { } final cubit = context.read(); cubit.updateConsultantProfileModel( - cubit.state.consultant.copyWith(legalType: RegisterConsultantCubit.individualPersonLegalType), + cubit.state.consultant.copyWith( + legalType: RegisterConsultantCubit.individualPersonLegalType, + ), cubit.state.acceptOffer, cubit.state.acceptFrdoAgreement, ); @@ -41,7 +44,14 @@ class _RegistrationAsScreenState extends State { children: [ const Padding( padding: EdgeInsets.symmetric(horizontal: 16), - child: Text('Регистрация в качестве', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)), + child: Text( + 'Регистрация в качестве', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + height: 1.1, + ), + ), ), const SizedBox(height: 16), Column( @@ -59,7 +69,10 @@ class _RegistrationAsScreenState extends State { const Expanded( child: Padding( padding: EdgeInsets.symmetric(horizontal: 8), - child: Text('Физического лица', style: TextStyle(fontSize: 18, height: 1)), + child: Text( + 'Физического лица', + style: TextStyle(fontSize: 18, height: 1), + ), ), ), ], @@ -77,7 +90,14 @@ class _RegistrationAsScreenState extends State { const Expanded( child: Padding( padding: EdgeInsets.symmetric(horizontal: 8), - child: Text('Самозанятого', style: TextStyle(fontSize: 18, height: 1, color: Colors.black38)), + child: Text( + 'Самозанятого', + style: TextStyle( + fontSize: 18, + height: 1, + color: Colors.black38, + ), + ), ), ), ], @@ -95,7 +115,14 @@ class _RegistrationAsScreenState extends State { const Expanded( child: Padding( padding: EdgeInsets.symmetric(horizontal: 8), - child: Text('Индивидуального предпринимателя', style: TextStyle(fontSize: 18, height: 1, color: Colors.black38)), + child: Text( + 'Индивидуального предпринимателя', + style: TextStyle( + fontSize: 18, + height: 1, + color: Colors.black38, + ), + ), ), ), ], @@ -113,17 +140,29 @@ class _RegistrationAsScreenState extends State { const Expanded( child: Padding( padding: EdgeInsets.symmetric(horizontal: 8), - child: Text('Общества с ограниченной ответственностью', style: TextStyle(fontSize: 18, height: 1, color: Colors.black38)), + child: Text( + 'Общества с ограниченной ответственностью', + style: TextStyle( + fontSize: 18, + height: 1, + color: Colors.black38, + ), + ), ), ), ], ), ], ), - Padding(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: _divider(context)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + child: _divider(context), + ), const Padding( padding: EdgeInsets.symmetric(horizontal: 16), - child: Text('* На данный момент регистрация доступна только в качестве физического лица. Остальные варианты скоро появятся'), + child: Text( + '* На данный момент регистрация доступна только в качестве физического лица. Остальные варианты скоро появятся', + ), ), ], ), @@ -134,7 +173,10 @@ class _RegistrationAsScreenState extends State { return Container( height: 0.4, width: MediaQuery.of(context).size.width - 32, - decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(16)), + decoration: BoxDecoration( + color: Colors.black26, + borderRadius: BorderRadius.circular(16), + ), ); } } diff --git a/lib/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart index 609e94a5..1847ec60 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart @@ -19,32 +19,47 @@ class _SocialNetworksScreenState extends State { var maskFormatter = MaskTextInputFormatter( mask: 'https://########################################', - filter: { - '#': RegExp(r"[a-zA-Z. ]+"), - }, + filter: {'#': RegExp(r"[a-zA-Z. ]+")}, type: MaskAutoCompletionType.lazy, ); var maskTgFormatter = MaskTextInputFormatter( mask: 'https://tg.me/########################################', - filter: { - '#': RegExp(r"[a-zA-Z. ]+"), - }, + filter: {'#': RegExp(r"[a-zA-Z. ]+")}, type: MaskAutoCompletionType.lazy, ); var maskVkFormatter = MaskTextInputFormatter( mask: 'https://vk.com/########################################', - filter: { - '#': RegExp(r"[a-zA-Z. ]+"), - }, + filter: {'#': RegExp(r"[a-zA-Z. ]+")}, type: MaskAutoCompletionType.lazy, ); @override void initState() { super.initState(); - vkController.text = context.read().state.consultant.socialLinks!.vkLink ?? ''; - telegramController.text = context.read().state.consultant.socialLinks!.tgLink ?? ''; - webSiteController.text = context.read().state.consultant.socialLinks!.personalSiteLink ?? ''; + vkController.text = + context + .read() + .state + .consultant + .socialLinks! + .vkLink ?? + ''; + telegramController.text = + context + .read() + .state + .consultant + .socialLinks! + .tgLink ?? + ''; + webSiteController.text = + context + .read() + .state + .consultant + .socialLinks! + .personalSiteLink ?? + ''; } @override @@ -75,9 +90,7 @@ class _SocialNetworksScreenState extends State { color: const Color(0xFFF6F6F6), borderRadius: BorderRadius.circular(20), ), - child: SvgPicture.asset( - 'assets/icons/vk.svg', - ), + child: SvgPicture.asset('assets/icons/vk.svg'), ), const SizedBox(width: 8), SizedBox( @@ -105,9 +118,7 @@ class _SocialNetworksScreenState extends State { color: const Color(0xFFF6F6F6), borderRadius: BorderRadius.circular(20), ), - child: SvgPicture.asset( - 'assets/icons/telegram.svg', - ), + child: SvgPicture.asset('assets/icons/telegram.svg'), ), const SizedBox(width: 8), SizedBox( @@ -135,9 +146,7 @@ class _SocialNetworksScreenState extends State { color: const Color(0xFFF6F6F6), borderRadius: BorderRadius.circular(20), ), - child: SvgPicture.asset( - 'assets/icons/web.svg', - ), + child: SvgPicture.asset('assets/icons/web.svg'), ), const SizedBox(width: 8), SizedBox( @@ -164,25 +173,45 @@ class _SocialNetworksScreenState extends State { String? personalSiteLink, }) { context.read().updateConsultantProfileModel( - context.read().state.consultant.copyWith( - socialLinks: context.read().state.consultant.socialLinks?.copyWith( - tgLink: tgLink == null - ? context.read().state.consultant.socialLinks?.tgLink - : tgLink.isEmpty - ? null - : tgLink, - vkLink: vkLink == null - ? context.read().state.consultant.socialLinks?.vkLink - : vkLink.isEmpty - ? null - : vkLink, - personalSiteLink: personalSiteLink == null - ? context.read().state.consultant.socialLinks?.personalSiteLink - : personalSiteLink.isEmpty - ? null - : personalSiteLink, - ), - ), - ); + context.read().state.consultant.copyWith( + socialLinks: context + .read() + .state + .consultant + .socialLinks + ?.copyWith( + tgLink: tgLink == null + ? context + .read() + .state + .consultant + .socialLinks + ?.tgLink + : tgLink.isEmpty + ? null + : tgLink, + vkLink: vkLink == null + ? context + .read() + .state + .consultant + .socialLinks + ?.vkLink + : vkLink.isEmpty + ? null + : vkLink, + personalSiteLink: personalSiteLink == null + ? context + .read() + .state + .consultant + .socialLinks + ?.personalSiteLink + : personalSiteLink.isEmpty + ? null + : personalSiteLink, + ), + ), + ); } } diff --git a/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart index 73359e25..12b016af 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart @@ -1,6 +1,7 @@ import 'package:eatvio_app/core/utils/image_constant.dart'; import 'package:eatvio_app/core/widgets/data_error_widget.dart'; import 'package:eatvio_app/core/widgets/loader.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart'; @@ -22,26 +23,37 @@ import 'package:lottie/lottie.dart'; class StepperBecomeConsultantScreen extends StatefulWidget { final String? slug; final bool? isCanceled; - const StepperBecomeConsultantScreen({super.key, this.slug, this.isCanceled}); + final bool isNewRegistration; + const StepperBecomeConsultantScreen({ + super.key, + this.slug, + this.isCanceled, + this.isNewRegistration = false, + }); @override - State createState() => _StepperBecomeConsultantScreenState(); + State createState() => + _StepperBecomeConsultantScreenState(); } -class _StepperBecomeConsultantScreenState extends State { +class _StepperBecomeConsultantScreenState + extends State { double progressValue = 0; int step = 1; final pageController = PageController(); late final RegisterConsultantCubit registrationConsultantCubit; bool isLoading = false; + bool _successSheetShown = false; @override void initState() { super.initState(); - if (widget.slug == null) { - registrationConsultantCubit = sl()..disableLoading(); + if (widget.isNewRegistration || widget.slug == null) { + registrationConsultantCubit = sl() + ..disableLoading(slug: widget.slug); } else { - registrationConsultantCubit = sl()..getMyRequestStatus(widget.slug!); + registrationConsultantCubit = sl() + ..getMyRequestStatus(widget.slug!); } } @@ -59,16 +71,97 @@ class _StepperBecomeConsultantScreenState extends State _showSuccessSheet(BuildContext context) async { + if (_successSheetShown) { + return; + } + _successSheetShown = true; + await showModalBottomSheet( + useRootNavigator: true, + showDragHandle: true, + context: context, + builder: (context) => Container( + padding: const EdgeInsets.all(16), + width: MediaQuery.of(context).size.width, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Спасибо!\nВаши данные отправлены на модерацию', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 20, + height: 1, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + Lottie.asset( + 'assets/lottie_animations/success.json', + width: 160, + height: 160, + ), + const SizedBox(height: 16), + const Text( + 'В течении трех рабочих дней Вам придет уведомление о смене статуса', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + height: 1, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 16), + InkWell( + onTap: () { + Navigator.of(context).pop(); + GoRouter.of(context).pop(); + }, + child: Ink( + width: MediaQuery.of(context).size.width - 32, + height: 52, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: appTheme.blueA400, + ), + child: const Center( + child: Text( + 'Закрыть', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { return BlocProvider( create: (context) => registrationConsultantCubit, - child: BlocBuilder( + child: BlocConsumer( + listenWhen: (previous, current) => + previous.awaitingScoringOutcome && + !current.awaitingScoringOutcome && + current.alfaScoringStatus.isComplete, + listener: (context, state) { + _showSuccessSheet(context); + }, builder: (context, state) { if (state.loading) { return const Scaffold(body: Loader()); } - if (state.error != null && state.requestModel == null && widget.slug != null) { + if (state.error != null && + state.requestModel == null && + widget.slug != null && + state.alfaScoringStatus == AlfaScoringStatus.none) { return Scaffold( appBar: AppBar( centerTitle: true, @@ -87,11 +180,15 @@ class _StepperBecomeConsultantScreenState extends State context.read().reloadData(widget.slug), + onReload: () => context + .read() + .reloadData(widget.slug), ), ); } @@ -100,8 +197,14 @@ class _StepperBecomeConsultantScreenState extends State Navigator.pop(context), child: const Text('OK'))], + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('OK'), + ), + ], ), ); }, @@ -150,11 +268,19 @@ class _StepperBecomeConsultantScreenState extends State AlertDialog( - actionsPadding: const EdgeInsets.symmetric(horizontal: 4), - content: const Text('Заполните паспортные данные'), + actionsPadding: const EdgeInsets.symmetric( + horizontal: 4, + ), + content: const Text( + 'Заполните паспортные данные', + ), actions: [ TextButton( child: const Text('Ок'), - onPressed: () => Navigator.of(context).pop(), + onPressed: () => + Navigator.of(context).pop(), ), ], ), ); return; } - if (registrationConsultantCubit.hasAcceptedRequiredAgreements) { + if (registrationConsultantCubit + .hasAcceptedRequiredAgreements) { setState(() { isLoading = true; }); try { - await registrationConsultantCubit.registerConsultant(); + await registrationConsultantCubit + .registerConsultant(); if (!context.mounted) return; - if (registrationConsultantCubit.state.error != null) { - return; - } - await showModalBottomSheet( - useRootNavigator: true, - showDragHandle: true, - context: context, - builder: (context) => Container( - padding: const EdgeInsets.all(16), - width: MediaQuery.of(context).size.width, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - 'Спасибо!\nВаши данные отправлены на модерацию', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 20, height: 1, fontWeight: FontWeight.w600), - ), - const SizedBox(height: 16), - Lottie.asset('assets/lottie_animations/success.json', width: 160, height: 160), - const SizedBox(height: 16), - const Text( - 'В течении трех рабочих дней Вам придет уведомление о смене статуса', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 16, height: 1, fontWeight: FontWeight.w500), - ), - const SizedBox(height: 16), - InkWell( - onTap: () { - Navigator.of(context).pop(); - GoRouter.of(context).pop(); - }, - child: Ink( - width: MediaQuery.of(context).size.width - 32, - height: 52, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400), - child: const Center( - child: Text( - 'Закрыть', - style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600), - ), - ), + if (registrationConsultantCubit.state.error != + null) { + showDialog( + context: context, + builder: (context) => AlertDialog( + actionsPadding: + const EdgeInsets.symmetric( + horizontal: 4, ), + content: Text( + registrationConsultantCubit + .state + .error!, + ), + actions: [ + TextButton( + child: const Text('Ок'), + onPressed: () => + Navigator.of(context).pop(), ), ], ), - ), - ); + ); + return; + } + // Success sheet is shown by BlocListener when scoring completes. } catch (e) { if (!context.mounted) return; showDialog( context: context, builder: (context) => AlertDialog( - actionsPadding: const EdgeInsets.symmetric(horizontal: 4), + actionsPadding: + const EdgeInsets.symmetric( + horizontal: 4, + ), content: Text(e.toString()), actions: [ TextButton( child: const Text('Ок'), - onPressed: () => Navigator.of(context).pop(), + onPressed: () => + Navigator.of(context).pop(), ), ], ), @@ -275,7 +392,9 @@ class _StepperBecomeConsultantScreenState extends State AlertDialog( - actionsPadding: const EdgeInsets.symmetric(horizontal: 4), + actionsPadding: const EdgeInsets.symmetric( + horizontal: 4, + ), content: Text( state.isUpdating ? 'Примите договор публичной оферты' @@ -295,17 +414,25 @@ class _StepperBecomeConsultantScreenState extends State IconButton(icon: const Icon(Icons.close_rounded), onPressed: () => GoRouter.of(context).pop()), + builder: (context) => IconButton( + icon: const Icon(Icons.close_rounded), + onPressed: () => GoRouter.of(context).pop(), + ), ), centerTitle: true, - title: SvgPicture.asset(ImageConstant.imgLogoEatvio, height: 48, fit: BoxFit.contain), - actions: [Padding(padding: const EdgeInsets.only(right: 16), child: Text('шаг $step из $totalSteps'))], + title: SvgPicture.asset( + ImageConstant.imgLogoEatvio, + height: 48, + fit: BoxFit.contain, + ), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 16), + child: Text('шаг $step из $totalSteps'), + ), + ], bottom: PreferredSize( preferredSize: Size(MediaQuery.of(context).size.width, 32), child: Padding( @@ -326,13 +465,18 @@ class _StepperBecomeConsultantScreenState extends State( duration: const Duration(milliseconds: 350), curve: Curves.easeInOut, - tween: Tween(begin: 1 / totalSteps, end: progressValue), + tween: Tween( + begin: 1 / totalSteps, + end: progressValue, + ), builder: (context, value, _) => LinearProgressIndicator( value: value, borderRadius: BorderRadius.circular(16), minHeight: 8, backgroundColor: Colors.grey.shade100, - valueColor: AlwaysStoppedAnimation(appTheme.blueA400), + valueColor: AlwaysStoppedAnimation( + appTheme.blueA400, + ), ), ), ), diff --git a/lib/features/consultant/become_consultant/presentation/widgets/passport_field_with_error.dart b/lib/features/consultant/become_consultant/presentation/widgets/passport_field_with_error.dart new file mode 100644 index 00000000..5e34fa79 --- /dev/null +++ b/lib/features/consultant/become_consultant/presentation/widgets/passport_field_with_error.dart @@ -0,0 +1,28 @@ +import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart'; +import 'package:flutter/material.dart'; + +/// Wraps a form field with [ValidationErrorWidget] when [error] is present. +/// Pass [hasError] into [CustomTextFormField] from the parent. +class PassportFieldWithError extends StatelessWidget { + const PassportFieldWithError({required this.child, this.error, super.key}); + + final Widget child; + final String? error; + + @override + Widget build(BuildContext context) { + final message = error?.trim(); + final showError = message != null && message.isNotEmpty; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + child, + if (showError) ...[ + const SizedBox(height: 4), + ValidationErrorWidget(error: message), + ], + ], + ); + } +} diff --git a/lib/features/consultant/become_consultant/presentation/widgets/passport_section_title.dart b/lib/features/consultant/become_consultant/presentation/widgets/passport_section_title.dart new file mode 100644 index 00000000..29abf8af --- /dev/null +++ b/lib/features/consultant/become_consultant/presentation/widgets/passport_section_title.dart @@ -0,0 +1,34 @@ +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:flutter/material.dart'; + +class PassportSectionTitle extends StatelessWidget { + const PassportSectionTitle({required this.title, super.key}); + + final String title; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 8, bottom: 12), + child: Text( + title, + style: TextStyles.textLSemibold.copyWith(color: appTheme.black), + ), + ); + } +} + +class PassportScreenTitle extends StatelessWidget { + const PassportScreenTitle({required this.title, super.key}); + + final String title; + + @override + Widget build(BuildContext context) { + return Text( + title, + style: TextStyles.textFS24FW700.copyWith(color: appTheme.black), + ); + } +} diff --git a/lib/features/consultant/become_consultant/presentation/widgets/scoring_status_widget.dart b/lib/features/consultant/become_consultant/presentation/widgets/scoring_status_widget.dart new file mode 100644 index 00000000..764c803d --- /dev/null +++ b/lib/features/consultant/become_consultant/presentation/widgets/scoring_status_widget.dart @@ -0,0 +1,218 @@ +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; +import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class ScoringStatusWidget extends StatelessWidget { + const ScoringStatusWidget({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (previous, current) => + previous.alfaScoringStatus != current.alfaScoringStatus || + previous.isScoringPolling != current.isScoringPolling || + previous.scoringRetryInProgress != current.scoringRetryInProgress || + previous.awaitingScoringOutcome != current.awaitingScoringOutcome, + builder: (context, state) { + final isWaiting = + state.awaitingScoringOutcome || state.isScoringPolling; + final status = state.alfaScoringStatus; + final showProcessing = status.isProcessing || + (isWaiting && !status.isTerminal); + + if (status == AlfaScoringStatus.none && !isWaiting) { + return const SizedBox.shrink(); + } + + final displayStatus = showProcessing && !status.isProcessing + ? AlfaScoringStatus.beneficiaryCreating + : status; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all(color: appTheme.greyD9), + color: appTheme.whiteA700, + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ScoringStatusHeader(status: displayStatus), + const SizedBox(height: 12), + if (showProcessing) const ScoringProcessingBody(), + if (status.isComplete) const ScoringCompleteBody(), + if (status.isFailed) + ScoringFailedBody( + retryInProgress: state.scoringRetryInProgress, + onRetry: () => context + .read() + .retryScoring(), + ), + ], + ), + ), + ), + ); + }, + ); + } +} + +class ScoringStatusHeader extends StatelessWidget { + const ScoringStatusHeader({required this.status, super.key}); + + final AlfaScoringStatus status; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Text( + 'Проверка данных', + style: TextStyles.textMSemibold.copyWith(color: appTheme.black), + ), + ), + Text( + ScoringStatusLabels.hintFor(status), + style: TextStyles.textSMedium.copyWith( + color: ScoringStatusLabels.colorFor(status), + ), + ), + ], + ); + } +} + +class ScoringProcessingBody extends StatelessWidget { + const ScoringProcessingBody({super.key}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: appTheme.blueA400, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Идёт проверка…', + style: TextStyles.textMMediumAuto.copyWith(color: appTheme.black), + ), + ), + ], + ); + } +} + +class ScoringCompleteBody extends StatelessWidget { + const ScoringCompleteBody({super.key}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon(Icons.check_circle, color: appTheme.green, size: 22), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Проверка успешно завершена', + style: TextStyles.textMMediumAuto.copyWith(color: appTheme.black), + ), + ), + ], + ); + } +} + +class ScoringFailedBody extends StatelessWidget { + const ScoringFailedBody({ + required this.retryInProgress, + required this.onRetry, + super.key, + }); + + final bool retryInProgress; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ValidationErrorWidget(error: 'Не удалось пройти проверку'), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + height: 44, + child: ElevatedButton( + onPressed: retryInProgress ? null : onRetry, + style: ElevatedButton.styleFrom( + backgroundColor: appTheme.blueA400, + disabledBackgroundColor: appTheme.greyD9, + foregroundColor: appTheme.whiteA700, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: 0, + ), + child: retryInProgress + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: appTheme.whiteA700, + ), + ) + : Text( + 'Повторить', + style: TextStyles.textMSemibold.copyWith( + color: appTheme.whiteA700, + ), + ), + ), + ), + ], + ); + } +} + +abstract final class ScoringStatusLabels { + static String hintFor(AlfaScoringStatus status) { + return switch (status) { + AlfaScoringStatus.beneficiaryCreating => 'Создание профиля', + AlfaScoringStatus.scoringPending => 'Ожидание результата', + AlfaScoringStatus.scoringComplete => 'Успешно', + AlfaScoringStatus.beneficiaryFailed => 'Ошибка', + AlfaScoringStatus.scoringFailed => 'Ошибка', + AlfaScoringStatus.none => '', + }; + } + + static Color colorFor(AlfaScoringStatus status) { + return switch (status) { + AlfaScoringStatus.scoringComplete => appTheme.green, + AlfaScoringStatus.beneficiaryFailed || + AlfaScoringStatus.scoringFailed => appTheme.red, + AlfaScoringStatus.beneficiaryCreating || + AlfaScoringStatus.scoringPending => appTheme.blueA400, + AlfaScoringStatus.none => appTheme.grey7B, + }; + } +} diff --git a/lib/features/consultant/become_consultant/repositories/consultant_repository.dart b/lib/features/consultant/become_consultant/repositories/consultant_repository.dart index b8d55b5a..4bc97dce 100644 --- a/lib/features/consultant/become_consultant/repositories/consultant_repository.dart +++ b/lib/features/consultant/become_consultant/repositories/consultant_repository.dart @@ -12,16 +12,25 @@ class ConsultantRepository { const ConsultantRepository({required this.consultantService}); - Future registerConsultant({required ConsultantProfileModel consultant, bool update = false}) async { + Future registerConsultant({ + required ConsultantProfileModel consultant, + bool update = false, + }) async { if (update) { - return await consultantService.consultantUpdate(consultantModel: consultant); + return await consultantService.consultantUpdate( + consultantModel: consultant, + ); } else { - return await consultantService.consultantRegistration(consultantModel: consultant); + return await consultantService.consultantRegistration( + consultantModel: consultant, + ); } } /// Профиль консультанта (свой или чужой) — публичные данные без паспорта. - Future getConsultantRequestStatus(String slug) async { + Future getConsultantRequestStatus( + String slug, + ) async { return await consultantService.getConsultantRequestStatus(slug: slug); } @@ -38,12 +47,18 @@ class ConsultantRepository { return await consultantService.getConsultantCertPhotos(); } - Future> storeConsultantCertPhotos({required List files}) async { + Future> storeConsultantCertPhotos({ + required List files, + }) async { final multipartFiles = []; for (final file in files) { final fileBytes = await file.readAsBytes(); - final multipartFile = MultipartFile.fromBytes(fileBytes, filename: file.path.split('/').last, contentType: MediaType('application', 'octet-stream')); + final multipartFile = MultipartFile.fromBytes( + fileBytes, + filename: file.path.split('/').last, + contentType: MediaType('application', 'octet-stream'), + ); multipartFiles.add(multipartFile); } @@ -51,15 +66,21 @@ class ConsultantRepository { return []; } - return await consultantService.storeConsultantCertPhotos(photos: multipartFiles); + return await consultantService.storeConsultantCertPhotos( + photos: multipartFiles, + ); } Future hideConsultantCertPhoto(int id) async { - await consultantService.hideConsultantCertPhoto(payload: {'cert_photo_id': id}); + await consultantService.hideConsultantCertPhoto( + payload: {'cert_photo_id': id}, + ); } Future unHideConsultantCertPhoto(int id) async { - await consultantService.unHideConsultantCertPhoto(payload: {'cert_photo_id': id}); + await consultantService.unHideConsultantCertPhoto( + payload: {'cert_photo_id': id}, + ); } Future deletePhoto(int id) async { diff --git a/lib/features/consultant/become_consultant/services/consultant_service.dart b/lib/features/consultant/become_consultant/services/consultant_service.dart index 6482c667..8458824d 100644 --- a/lib/features/consultant/become_consultant/services/consultant_service.dart +++ b/lib/features/consultant/become_consultant/services/consultant_service.dart @@ -12,24 +12,35 @@ abstract class ConsultantService { factory ConsultantService(Dio dio, {String baseUrl}) = _ConsultantService; @POST('/api/v2/consultant_request/registration') - Future consultantRegistration({@Body() required ConsultantProfileModel consultantModel}); + Future consultantRegistration({ + @Body() required ConsultantProfileModel consultantModel, + }); @POST('/api/consultant_request/photos/upload') @MultiPart() - Future> uploadPhotos({@Part(name: 'photos[]', contentType: 'multipart/form-data') List? photos}); + Future> uploadPhotos({ + @Part(name: 'photos[]', contentType: 'multipart/form-data') + List? photos, + }); /// Публичный статус консультанта для просмотра профиля. /// Без паспортных данных: `/api/profile/consultant/{slug}/status`. @GET('/api/profile/consultant/{slug}/status') - Future getConsultantRequestStatus({@Path() required String slug}); + Future getConsultantRequestStatus({ + @Path() required String slug, + }); /// Свой статус заявки для редактирования/модерации. /// С паспортными полями в `legal_entity_resource`: `/api/v2/consultant_request/{slug}/status`. @GET('/api/v2/consultant_request/{slug}/status') - Future getMyRequestStatus({@Path() required String slug}); + Future getMyRequestStatus({ + @Path() required String slug, + }); @PUT('/api/v2/consultant_request/update') - Future consultantUpdate({@Body() required ConsultantProfileModel consultantModel}); + Future consultantUpdate({ + @Body() required ConsultantProfileModel consultantModel, + }); @GET('/api/finances/sbp_banks') Future> getSbpBanks(); @@ -40,14 +51,19 @@ abstract class ConsultantService { @POST('/api/v2/consultant_request/cert_photos/store') @MultiPart() Future> storeConsultantCertPhotos({ - @Part(name: 'photos[]', contentType: 'multipart/form-data') required List photos, + @Part(name: 'photos[]', contentType: 'multipart/form-data') + required List photos, }); @POST('/api/v2/consultant_request/cert_photos/hide') - Future hideConsultantCertPhoto({@Body() required Map payload}); + Future hideConsultantCertPhoto({ + @Body() required Map payload, + }); @POST('/api/v2/consultant_request/cert_photos/un_hide') - Future unHideConsultantCertPhoto({@Body() required Map payload}); + Future unHideConsultantCertPhoto({ + @Body() required Map payload, + }); @POST('/api/v2/consultant_request/cert_photos/{id}/queue-for-deletion') Future deletePhoto({@Path() required int id}); diff --git a/lib/features/consultant/become_consultant/utils/laravel_validation_errors.dart b/lib/features/consultant/become_consultant/utils/laravel_validation_errors.dart new file mode 100644 index 00000000..d4b0d947 --- /dev/null +++ b/lib/features/consultant/become_consultant/utils/laravel_validation_errors.dart @@ -0,0 +1,167 @@ +import 'package:dio/dio.dart'; + +/// Parsed Laravel 422 validation payload for consultant registration UI. +class LaravelValidationErrors { + const LaravelValidationErrors({ + required this.summary, + this.fieldErrors = const {}, + }); + + final String summary; + final Map fieldErrors; + + static const LaravelValidationErrors empty = LaravelValidationErrors( + summary: '', + ); + + bool get hasFieldErrors => fieldErrors.isNotEmpty; +} + +const _genericValidationSummary = 'Проверьте введённые данные'; +const _genericNetworkSummary = 'Не удалось отправить данные. Попробуйте ещё раз'; + +/// Parses Laravel `errors` map and maps them to user-facing Russian texts. +/// Raw backend messages are never shown in the UI. +LaravelValidationErrors parseLaravelValidationErrors(Object error) { + if (error is! DioException) { + return const LaravelValidationErrors(summary: _genericNetworkSummary); + } + + final data = error.response?.data; + if (error.response?.statusCode == 422 && data is Map) { + final fieldErrors = {}; + final errors = data['errors']; + + if (errors is Map) { + errors.forEach((key, value) { + final fieldKey = key.toString(); + final raw = _rawErrorText(value); + fieldErrors[fieldKey] = mapConsultantValidationMessage( + fieldKey: fieldKey, + rawMessage: raw, + ); + }); + } + + if (fieldErrors.isNotEmpty) { + final uniqueMessages = fieldErrors.values.toSet().toList(); + return LaravelValidationErrors( + summary: uniqueMessages.join('\n'), + fieldErrors: fieldErrors, + ); + } + + return const LaravelValidationErrors(summary: _genericValidationSummary); + } + + return const LaravelValidationErrors(summary: _genericNetworkSummary); +} + +String _rawErrorText(Object? value) { + if (value is List && value.isNotEmpty) { + return value.map((e) => e.toString()).join(' '); + } + if (value is String) { + return value; + } + return ''; +} + +/// Maps a backend validation message for [fieldKey] to a short Russian UI string. +String mapConsultantValidationMessage({ + required String fieldKey, + required String rawMessage, +}) { + final field = fieldKey.split('.').last.toLowerCase(); + final raw = rawMessage.toLowerCase(); + + final isUnique = + raw.contains('taken') || + raw.contains('unique') || + raw.contains('already') || + raw.contains('уже существ') || + raw.contains('уже занят'); + final isRequired = + raw.contains('required') || + raw.contains('обязательн') || + raw.contains('required_if'); + final isAccepted = + raw.contains('accepted') || field.contains('agreement'); + final isUrl = raw.contains('url') || field.contains('link'); + final isFormat = + raw.contains('format') || + raw.contains('invalid') || + raw.contains('regex') || + raw.contains('digits') || + raw.contains('size') || + raw.contains('date') || + raw.contains('before'); + + if (isAccepted) { + if (field.contains('frdo')) { + return 'Подтвердите согласие ФРДО'; + } + return 'Примите условия оферты'; + } + + if (isUnique) { + return switch (field) { + 'inn' => 'Такой ИНН существует', + 'snils' => 'Такой СНИЛС существует', + 'passport_number' || 'passport_series' => 'Такой паспорт существует', + 'sbp_phone' => 'Такой телефон СБП существует', + _ => 'Такие данные уже существуют', + }; + } + + if (isRequired) { + return switch (field) { + 'inn' => 'Укажите ИНН', + 'snils' => 'Укажите СНИЛС', + 'last_name' => 'Укажите фамилию', + 'first_name' => 'Укажите имя', + 'middle_name' => 'Укажите отчество', + 'passport_series' => 'Укажите серию паспорта', + 'passport_number' => 'Укажите номер паспорта', + 'passport_issue_date' => 'Укажите дату выдачи паспорта', + 'passport_issuer' => 'Укажите, кем выдан паспорт', + 'passport_issuer_code' => 'Укажите код подразделения', + 'birth_date' => 'Укажите дату рождения', + 'birth_place' => 'Укажите место рождения', + 'registration_address' => 'Укажите адрес регистрации', + 'residence_address' => 'Укажите адрес проживания', + 'citizenship' => 'Укажите гражданство', + 'sbp_phone' => 'Укажите телефон СБП', + 'sbp_bank_member_id' => 'Выберите банк СБП', + 'completed_courses' => 'Добавьте хотя бы один курс', + 'title' => 'Укажите название курса', + 'date' => 'Укажите дату курса', + 'agreement_accepted' => 'Примите условия оферты', + 'frdo_agreement_accepted' => 'Подтвердите согласие ФРДО', + _ => 'Заполните поле', + }; + } + + if (isUrl) { + return 'Некорректная ссылка'; + } + + if (isFormat || raw.isNotEmpty) { + return switch (field) { + 'inn' => 'Некорректный ИНН', + 'snils' => 'Некорректный СНИЛС', + 'passport_series' => 'Некорректная серия паспорта', + 'passport_number' => 'Некорректный номер паспорта', + 'passport_issue_date' => 'Некорректная дата выдачи', + 'passport_issuer_code' => 'Некорректный код подразделения', + 'birth_date' => 'Некорректная дата рождения', + 'citizenship' => 'Некорректный код гражданства', + 'sbp_phone' => 'Некорректный телефон СБП', + 'title' => 'Слишком короткое название курса', + 'date' => 'Некорректная дата курса', + _ => 'Некорректное значение', + }; + } + + return 'Проверьте поле'; +} diff --git a/lib/features/settings/presentation/pages/settings_screen.dart b/lib/features/settings/presentation/pages/settings_screen.dart index db230482..a1b0acab 100644 --- a/lib/features/settings/presentation/pages/settings_screen.dart +++ b/lib/features/settings/presentation/pages/settings_screen.dart @@ -238,7 +238,10 @@ class _SettingsScreenState extends State { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16) + const EdgeInsets.only(bottom: 8), child: InkWell( - onTap: () => GoRouter.of(context).pushNamed('becomeConsultantScreen'), + onTap: () => GoRouter.of(context).pushNamed( + 'becomeConsultantScreen', + extra: {'slug': state.profile.slug}, + ), borderRadius: BorderRadius.circular(20), child: Ink( height: 52, diff --git a/test/features/consultant/become_consultant/entities/diploma_entity_test.dart b/test/features/consultant/become_consultant/entities/diploma_entity_test.dart index e0f0e80b..3604a737 100644 --- a/test/features/consultant/become_consultant/entities/diploma_entity_test.dart +++ b/test/features/consultant/become_consultant/entities/diploma_entity_test.dart @@ -33,4 +33,3 @@ void main() { }); }); } - diff --git a/test/features/consultant/become_consultant/models/consultant_cert_photo_model_test.dart b/test/features/consultant/become_consultant/models/consultant_cert_photo_model_test.dart index 19292196..b1a832c6 100644 --- a/test/features/consultant/become_consultant/models/consultant_cert_photo_model_test.dart +++ b/test/features/consultant/become_consultant/models/consultant_cert_photo_model_test.dart @@ -100,4 +100,3 @@ void main() { }); }); } - diff --git a/test/features/consultant/become_consultant/models/consultant_profile_model_test.dart b/test/features/consultant/become_consultant/models/consultant_profile_model_test.dart index fb88c9c5..12676d48 100644 --- a/test/features/consultant/become_consultant/models/consultant_profile_model_test.dart +++ b/test/features/consultant/become_consultant/models/consultant_profile_model_test.dart @@ -63,7 +63,10 @@ void main() { }); test('toJson serializes all fields correctly', () { - const model = CertificatesModel(title: 'Certificate Title', date: '2024-01-15'); + const model = CertificatesModel( + title: 'Certificate Title', + date: '2024-01-15', + ); final json = model.toJson(); @@ -105,7 +108,10 @@ void main() { test('toJson serializes all fields correctly', () { const socialLinks = SocialLinksModel(vkLink: 'https://vk.com/user'); - const certificates = CertificatesModel(title: 'Course 1', date: '2024-01-01'); + const certificates = CertificatesModel( + title: 'Course 1', + date: '2024-01-01', + ); const model = ConsultantProfileModel( hasMedicalEducation: true, socialLinks: socialLinks, diff --git a/test/features/consultant/become_consultant/models/consultation_request_model_test.dart b/test/features/consultant/become_consultant/models/consultation_request_model_test.dart index f11423e4..11c95acb 100644 --- a/test/features/consultant/become_consultant/models/consultation_request_model_test.dart +++ b/test/features/consultant/become_consultant/models/consultation_request_model_test.dart @@ -1,3 +1,4 @@ +import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; import 'package:eatvio_app/features/profile/models/profile_model.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -46,7 +47,11 @@ void main() { }); test('fromJson handles null additional field', () { - final json = {'title': 'Test title', 'status': 'canceled', 'additional': null}; + final json = { + 'title': 'Test title', + 'status': 'canceled', + 'additional': null, + }; final model = CanceledModerationModel.fromJson(json); @@ -56,7 +61,11 @@ void main() { }); test('toJson serializes all fields correctly', () { - const model = CanceledModerationModel(title: 'Test title', status: ModerationStatus.canceled, additional: ['reason1', 'reason2']); + const model = CanceledModerationModel( + title: 'Test title', + status: ModerationStatus.canceled, + additional: ['reason1', 'reason2'], + ); final json = model.toJson(); @@ -133,7 +142,7 @@ void main() { 'inn': '123456789012', 'snils': '123-456-789 00', 'state': 'created', - 'alfa_scoring_status': 'pending', + 'alfa_scoring_status': 'scoring_failed', 'alfa_scoring_request_id': 'abc', 'passport_series': '1234', 'passport_number': '567890', @@ -154,19 +163,35 @@ void main() { expect(model.legalEntity!.inn, '123456789012'); expect(model.legalEntity!.passportSeries, '1234'); expect(model.legalEntity!.normalizedPassportIssueDate, '2020-01-15'); - expect(model.legalEntity!.alfaScoringStatus, 'pending'); + expect( + model.legalEntity!.alfaScoringStatus, + AlfaScoringStatus.scoringFailed, + ); }); test('normalizeApiDate strips time from DateTime.toString format', () { - expect(LegalEntityModel.normalizeApiDate('2023-09-22 00:00:00.000'), '2023-09-22'); + expect( + LegalEntityModel.normalizeApiDate('2023-09-22 00:00:00.000'), + '2023-09-22', + ); expect(LegalEntityModel.normalizeApiDate('2023-09-22'), '2023-09-22'); expect(LegalEntityModel.normalizeApiDate(null), isNull); }); test('toJson serializes ConsultationRequestModel correctly', () { - const educationModel = EducationModel(requestState: ModerationStatus.approved); - const photos = [CertPhotosModel(url: 'https://example.com/photo1.jpg'), CertPhotosModel(url: 'https://example.com/photo2.jpg')]; - const model = ConsultationRequestModel(ratingPlace: 5, educationModel: educationModel, photos: photos, canceledModeration: null); + const educationModel = EducationModel( + requestState: ModerationStatus.approved, + ); + const photos = [ + CertPhotosModel(url: 'https://example.com/photo1.jpg'), + CertPhotosModel(url: 'https://example.com/photo2.jpg'), + ]; + const model = ConsultationRequestModel( + ratingPlace: 5, + educationModel: educationModel, + photos: photos, + canceledModeration: null, + ); final json = model.toJson(); diff --git a/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state_test.dart b/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state_test.dart index 48a90b13..beb67773 100644 --- a/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state_test.dart +++ b/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state_test.dart @@ -12,7 +12,7 @@ void main() { acceptOffer: false, loading: false, requestModel: null, - isUpdating: false + isUpdating: false, ); expect(state.consultant, consultant); @@ -20,6 +20,8 @@ void main() { expect(state.loading, false); expect(state.requestModel, isNull); expect(state.certPhotos, isEmpty); + expect(state.awaitingScoringOutcome, isFalse); + expect(state.fieldErrors, isEmpty); }); test('creates instance with custom values', () { @@ -38,7 +40,7 @@ void main() { loading: true, requestModel: null, certPhotos: [certPhoto], - isUpdating: false + isUpdating: false, ); expect(state.acceptOffer, true); @@ -54,7 +56,7 @@ void main() { acceptOffer: false, loading: false, requestModel: null, - isUpdating: false + isUpdating: false, ); final updatedState = originalState.copyWith( @@ -92,7 +94,7 @@ void main() { loading: false, requestModel: null, certPhotos: [certPhoto1], - isUpdating: false + isUpdating: false, ); final updatedState = originalState.copyWith( @@ -100,9 +102,14 @@ void main() { ); expect(updatedState.certPhotos.length, 2); - expect(updatedState.certPhotos.first.url, 'https://example.com/photo1.jpg'); - expect(updatedState.certPhotos.last.url, 'https://example.com/photo2.jpg'); + expect( + updatedState.certPhotos.first.url, + 'https://example.com/photo1.jpg', + ); + expect( + updatedState.certPhotos.last.url, + 'https://example.com/photo2.jpg', + ); }); }); } - diff --git a/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart b/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart index 9eeeb304..6f6a0090 100644 --- a/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart +++ b/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart @@ -1,3 +1,5 @@ +import 'package:dio/dio.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; @@ -27,18 +29,47 @@ void main() { late List sbpBanksFromFixture; late ConsultantProfileModel consultantProfile; + ConsultationRequestModel requestWithScoring(String scoringStatus) { + final json = FixtureReader.readAsMap( + 'consultant/consultation_request.json', + ); + final legal = Map.from( + json['legal_entity_resource'] as Map, + ); + legal['alfa_scoring_status'] = scoringStatus; + json['legal_entity_resource'] = legal; + return ConsultationRequestModel.fromJson(json); + } + setUp(() { repository = MockConsultantRepository(); - cubit = RegisterConsultantCubit(consultantRepository: repository); - requestModel = ConsultationRequestModel.fromJson(FixtureReader.readAsMap('consultant/consultation_request.json')); - certPhotosFromFixture = FixtureReader.readAsList( - 'consultant/consultant_cert_photos.json', - ).map((item) => ConsultantCertPhotoModel.fromJson(item as Map)).toList(); - sbpBanksFromFixture = FixtureReader.readAsList( - 'consultant/sbp_banks.json', - ).map((item) => SbpBankModel.fromJson(item as Map)).toList(); - consultantProfile = ConsultantProfileModel.fromJson(FixtureReader.readAsMap('consultant/consultant_profile_request.json')); - when(() => repository.getSbpBanks()).thenAnswer((_) async => sbpBanksFromFixture); + cubit = RegisterConsultantCubit( + consultantRepository: repository, + scoringPollInterval: const Duration(milliseconds: 30), + ); + requestModel = ConsultationRequestModel.fromJson( + FixtureReader.readAsMap('consultant/consultation_request.json'), + ); + certPhotosFromFixture = + FixtureReader.readAsList('consultant/consultant_cert_photos.json') + .map( + (item) => ConsultantCertPhotoModel.fromJson( + item as Map, + ), + ) + .toList(); + sbpBanksFromFixture = + FixtureReader.readAsList('consultant/sbp_banks.json') + .map( + (item) => SbpBankModel.fromJson(item as Map), + ) + .toList(); + consultantProfile = ConsultantProfileModel.fromJson( + FixtureReader.readAsMap('consultant/consultant_profile_request.json'), + ); + when( + () => repository.getSbpBanks(), + ).thenAnswer((_) async => sbpBanksFromFixture); }); tearDown(() async { @@ -50,11 +81,16 @@ void main() { expect(cubit.state.consultant.hasMedicalEducation, isTrue); expect(cubit.state.acceptOffer, isFalse); expect(cubit.state.requestModel, isNull); + expect(cubit.state.alfaScoringStatus, AlfaScoringStatus.none); }); test('getMyRequestStatus hydrates state with backend data', () async { - when(() => repository.getMyRequestStatus(any())).thenAnswer((_) async => requestModel); - when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); + when( + () => repository.getMyRequestStatus(any()), + ).thenAnswer((_) async => requestModel); + when( + () => repository.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotosFromFixture); await cubit.getMyRequestStatus('my-consultant-slug'); @@ -62,41 +98,90 @@ void main() { expect(state.loading, isFalse); expect(state.requestModel, equals(requestModel)); expect(state.consultant.certificates, isNotNull); - // Certificates list is built from backend courses; we just ensure it is not empty. expect(state.consultant.certificates!.length, greaterThan(0)); expect(state.consultant.certificates!.first.title, 'Нутрициология PRO'); - expect(state.consultant.socialLinks?.instaLink, isNull, reason: 'empty links are sanitized'); + expect( + state.consultant.socialLinks?.instaLink, + isNull, + reason: 'empty links are sanitized', + ); expect(state.consultant.socialLinks?.vkLink, 'https://vk.com/consultant'); expect(state.consultant.legalType, 'individualPerson'); expect(state.consultant.inn, '123456789012'); expect(state.consultant.passportSeries, '1234'); expect(state.consultant.passportIssueDate, '2020-01-15'); expect(state.consultant.birthPlace, 'г. Москва'); - // All cert photos from backend are kept in state (visible + hidden). expect(state.certPhotos, hasLength(3)); - expect(state.certPhotos.map((e) => e.id), containsAll([101, 102, 103])); + expect( + state.certPhotos.map((e) => e.id), + containsAll([101, 102, 103]), + ); expect(state.sbpBanks, hasLength(2)); expect(state.sbpBanks.first.bankName, 'Сбербанк'); - verify(() => repository.getMyRequestStatus('my-consultant-slug')).called(1); + expect(state.alfaScoringStatus, AlfaScoringStatus.scoringFailed); + expect(state.alfaScoringStatus.canRetry, isTrue); + expect(state.isScoringPolling, isFalse); + expect( + state.awaitingScoringOutcome, + isFalse, + reason: 'hydrate must not arm success sheet', + ); + verify( + () => repository.getMyRequestStatus('my-consultant-slug'), + ).called(1); verify(() => repository.getConsultantCertPhotos()).called(1); verify(() => repository.getSbpBanks()).called(1); }); - test('disableLoading stops spinner and loads cert photos', () async { - when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); + test( + 'getMyRequestStatus with processing status starts scoring poll', + () async { + final pending = requestWithScoring('scoring_pending'); + final complete = requestWithScoring('scoring_complete'); + var calls = 0; + when(() => repository.getMyRequestStatus(any())).thenAnswer(( + _, + ) async { + calls++; + return calls == 1 ? pending : complete; + }); + when( + () => repository.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotosFromFixture); - await cubit.disableLoading(); + await cubit.getMyRequestStatus('my-consultant-slug'); + + expect(cubit.state.alfaScoringStatus, AlfaScoringStatus.scoringPending); + expect(cubit.state.isScoringPolling, isTrue); + + await Future.delayed(const Duration(milliseconds: 50)); + expect( + cubit.state.alfaScoringStatus, + AlfaScoringStatus.scoringComplete, + ); + expect(cubit.state.isScoringPolling, isFalse); + }, + ); + + test('disableLoading stops spinner and loads cert photos', () async { + when( + () => repository.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotosFromFixture); + + await cubit.disableLoading(slug: 'my-consultant-slug'); expect(cubit.state.loading, isFalse); - // disableLoading now keeps all photos from repository. expect(cubit.state.certPhotos, hasLength(3)); expect(cubit.state.sbpBanks, hasLength(2)); + expect(cubit.userSlug, 'my-consultant-slug'); verify(() => repository.getConsultantCertPhotos()).called(1); verify(() => repository.getSbpBanks()).called(1); }); test('searchSbpBanks filters banks by name', () async { - when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); + when( + () => repository.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotosFromFixture); await cubit.disableLoading(); final results = cubit.searchSbpBanks('сбер'); @@ -106,7 +191,9 @@ void main() { }); test('findSbpBankById returns matching bank', () async { - when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); + when( + () => repository.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotosFromFixture); await cubit.disableLoading(); final bank = cubit.findSbpBankById('100000000004'); @@ -114,42 +201,205 @@ void main() { expect(bank?.bankName, 'Т-Банк'); }); - test('updateConsultantProfileModel replaces consultant data and offer flag', () { - cubit.updateConsultantProfileModel(consultantProfile, true); + test( + 'updateConsultantProfileModel replaces consultant data and offer flag', + () { + cubit.updateConsultantProfileModel(consultantProfile, true); - expect(cubit.state.consultant, consultantProfile); - expect(cubit.state.acceptOffer, isTrue); - expect(cubit.state.loading, isFalse); - }); + expect(cubit.state.consultant, consultantProfile); + expect(cubit.state.acceptOffer, isTrue); + expect(cubit.state.loading, isFalse); + }, + ); - test('registerConsultant forwards payload with update=false by default', () async { - when( - () => repository.registerConsultant( - consultant: any(named: 'consultant'), - update: any(named: 'update'), - ), - ).thenAnswer((_) async {}); + test( + 'registerConsultant forwards payload with update=false by default', + () async { + when( + () => repository.registerConsultant( + consultant: any(named: 'consultant'), + update: any(named: 'update'), + ), + ).thenAnswer((_) async {}); - cubit.updateConsultantProfileModel(consultantProfile); - cubit.updateAgreements(acceptOffer: true, acceptFrdoAgreement: true); - await cubit.registerConsultant(); + cubit.updateConsultantProfileModel(consultantProfile); + cubit.updateAgreements(acceptOffer: true, acceptFrdoAgreement: true); + await cubit.registerConsultant(); - final captured = verify( - () => repository.registerConsultant( - consultant: captureAny(named: 'consultant'), - update: false, - ), - ).captured.single as ConsultantProfileModel; + final captured = + verify( + () => repository.registerConsultant( + consultant: captureAny( + named: 'consultant', + ), + update: false, + ), + ).captured.single + as ConsultantProfileModel; - expect(captured.legalType, 'individualPerson'); - expect(captured.agreementAccepted, isTrue); - expect(captured.frdoAgreementAccepted, isTrue); - expect(captured.inn, isNotNull); - }); + expect(captured.legalType, 'individualPerson'); + expect(captured.agreementAccepted, isTrue); + expect(captured.frdoAgreementAccepted, isTrue); + expect(captured.inn, isNotNull); + expect( + cubit.state.alfaScoringStatus, + AlfaScoringStatus.beneficiaryCreating, + ); + expect( + cubit.state.isScoringPolling, + isFalse, + reason: 'no slug for poll', + ); + expect( + cubit.state.awaitingScoringOutcome, + isFalse, + reason: 'cannot await scoring without slug', + ); + }, + ); + + test( + 'getMyRequestStatus with scoring_complete does not arm awaiting flag', + () async { + when( + () => repository.getMyRequestStatus(any()), + ).thenAnswer((_) async => requestWithScoring('scoring_complete')); + when( + () => repository.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotosFromFixture); + + await cubit.getMyRequestStatus('my-consultant-slug'); + + expect( + cubit.state.alfaScoringStatus, + AlfaScoringStatus.scoringComplete, + ); + expect(cubit.state.awaitingScoringOutcome, isFalse); + }, + ); + + test( + 'registerConsultant with slug starts scoring poll after submit', + () async { + when( + () => repository.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotosFromFixture); + when( + () => repository.getMyRequestStatus(any()), + ).thenAnswer((_) async => requestWithScoring('scoring_complete')); + when( + () => repository.registerConsultant( + consultant: any(named: 'consultant'), + update: any(named: 'update'), + ), + ).thenAnswer((_) async {}); + + await cubit.disableLoading(slug: 'my-consultant-slug'); + cubit.updateConsultantProfileModel(consultantProfile); + cubit.updateAgreements(acceptOffer: true, acceptFrdoAgreement: true); + await cubit.registerConsultant(); + + expect( + cubit.state.alfaScoringStatus, + AlfaScoringStatus.beneficiaryCreating, + ); + expect(cubit.state.isScoringPolling, isTrue); + expect(cubit.state.awaitingScoringOutcome, isTrue); + + await Future.delayed(const Duration(milliseconds: 50)); + expect( + cubit.state.alfaScoringStatus, + AlfaScoringStatus.scoringComplete, + ); + expect(cubit.state.isScoringPolling, isFalse); + expect( + cubit.state.awaitingScoringOutcome, + isFalse, + reason: 'flag clears on terminal scoring for one-shot success sheet', + ); + }, + ); + + test( + 'scoring poll failed clears awaiting flag without leaving processing', + () async { + when( + () => repository.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotosFromFixture); + when( + () => repository.getMyRequestStatus(any()), + ).thenAnswer((_) async => requestWithScoring('scoring_failed')); + when( + () => repository.registerConsultant( + consultant: any(named: 'consultant'), + update: any(named: 'update'), + ), + ).thenAnswer((_) async {}); + + await cubit.disableLoading(slug: 'my-consultant-slug'); + cubit.updateConsultantProfileModel(consultantProfile); + cubit.updateAgreements(acceptOffer: true, acceptFrdoAgreement: true); + await cubit.registerConsultant(); + + await Future.delayed(const Duration(milliseconds: 50)); + expect(cubit.state.alfaScoringStatus, AlfaScoringStatus.scoringFailed); + expect(cubit.state.isScoringPolling, isFalse); + expect(cubit.state.awaitingScoringOutcome, isFalse); + expect(cubit.state.alfaScoringStatus.canRetry, isTrue); + }, + ); + + test( + 'scoring poll keeps processing UI while API still returns none', + () async { + when( + () => repository.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotosFromFixture); + var calls = 0; + when(() => repository.getMyRequestStatus(any())).thenAnswer(( + _, + ) async { + calls++; + if (calls <= 2) { + return requestWithScoring('none'); + } + return requestWithScoring('scoring_complete'); + }); + when( + () => repository.registerConsultant( + consultant: any(named: 'consultant'), + update: any(named: 'update'), + ), + ).thenAnswer((_) async {}); + + await cubit.disableLoading(slug: 'my-consultant-slug'); + cubit.updateConsultantProfileModel(consultantProfile); + cubit.updateAgreements(acceptOffer: true, acceptFrdoAgreement: true); + await cubit.registerConsultant(); + + await Future.delayed(const Duration(milliseconds: 50)); + expect( + cubit.state.alfaScoringStatus, + AlfaScoringStatus.beneficiaryCreating, + ); + expect(cubit.state.isScoringPolling, isTrue); + + await Future.delayed(const Duration(milliseconds: 80)); + expect( + cubit.state.alfaScoringStatus, + AlfaScoringStatus.scoringComplete, + ); + expect(cubit.state.isScoringPolling, isFalse); + }, + ); test('registerConsultant sends legal payload with update=true', () async { - when(() => repository.getMyRequestStatus(any())).thenAnswer((_) async => requestModel); - when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); + when( + () => repository.getMyRequestStatus(any()), + ).thenAnswer((_) async => requestModel); + when( + () => repository.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotosFromFixture); when( () => repository.registerConsultant( consultant: any(named: 'consultant'), @@ -160,12 +410,16 @@ void main() { await cubit.getMyRequestStatus('my-consultant-slug'); await cubit.registerConsultant(); - final captured = verify( - () => repository.registerConsultant( - consultant: captureAny(named: 'consultant'), - update: true, - ), - ).captured.single as ConsultantProfileModel; + final captured = + verify( + () => repository.registerConsultant( + consultant: captureAny( + named: 'consultant', + ), + update: true, + ), + ).captured.single + as ConsultantProfileModel; final json = captured.toJson(); expect(json['legal_type'], 'individualPerson'); @@ -175,6 +429,40 @@ void main() { expect(json.containsKey('frdo_agreement_accepted'), isFalse); }); + test('retryScoring calls update and restarts poll', () async { + when( + () => repository.getMyRequestStatus(any()), + ).thenAnswer((_) async => requestModel); + when( + () => repository.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotosFromFixture); + when( + () => repository.registerConsultant( + consultant: any(named: 'consultant'), + update: any(named: 'update'), + ), + ).thenAnswer((_) async {}); + + await cubit.getMyRequestStatus('my-consultant-slug'); + expect(cubit.state.alfaScoringStatus, AlfaScoringStatus.scoringFailed); + + when( + () => repository.getMyRequestStatus(any()), + ).thenAnswer((_) async => requestWithScoring('scoring_complete')); + await cubit.retryScoring(); + + verify( + () => repository.registerConsultant( + consultant: any(named: 'consultant'), + update: true, + ), + ).called(1); + + await Future.delayed(const Duration(milliseconds: 50)); + expect(cubit.state.alfaScoringStatus, AlfaScoringStatus.scoringComplete); + expect(cubit.state.isScoringPolling, isFalse); + }); + test('registerConsultant blocks submit without passport data', () async { when( () => repository.registerConsultant( @@ -194,18 +482,59 @@ void main() { ); }); + test('registerConsultant maps Laravel 422 into fieldErrors', () async { + when( + () => repository.registerConsultant( + consultant: any(named: 'consultant'), + update: any(named: 'update'), + ), + ).thenThrow( + DioException( + requestOptions: RequestOptions( + path: '/api/v2/consultant_request/registration', + ), + response: Response( + requestOptions: RequestOptions( + path: '/api/v2/consultant_request/registration', + ), + statusCode: 422, + data: { + 'message': 'The given data was invalid.', + 'errors': { + 'inn': ['The inn format is invalid.'], + }, + }, + ), + type: DioExceptionType.badResponse, + ), + ); + + cubit.updateConsultantProfileModel(consultantProfile); + cubit.updateAgreements(acceptOffer: true, acceptFrdoAgreement: true); + await cubit.registerConsultant(); + + expect(cubit.state.fieldErrors['inn'], 'Некорректный ИНН'); + expect(cubit.state.error, contains('Некорректный ИНН')); + expect(cubit.state.awaitingScoringOutcome, isFalse); + expect(cubit.state.isScoringPolling, isFalse); + }); + test('removeCertPhoto marks cert as deleted locally', () async { - when(() => repository.getMyRequestStatus(any())).thenAnswer((_) async => requestModel); - when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); - when(() => repository.hideConsultantCertPhoto(any())).thenAnswer((_) async {}); + when( + () => repository.getMyRequestStatus(any()), + ).thenAnswer((_) async => requestModel); + when( + () => repository.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotosFromFixture); + when( + () => repository.hideConsultantCertPhoto(any()), + ).thenAnswer((_) async {}); await cubit.getMyRequestStatus('my-consultant-slug'); - // All photos from backend are present in state. expect(cubit.state.certPhotos, hasLength(3)); await cubit.removeCertPhoto(101, null); - // Current implementation only tracks deleted IDs; photos list remains unchanged here. verifyNever(() => repository.hideConsultantCertPhoto(any())); expect(cubit.state.deletedPhotoIds, contains(101)); expect(cubit.state.certPhotos, hasLength(3)); @@ -214,7 +543,9 @@ void main() { test('uploadCertPhotos - не загружает если список пуст', () async { await cubit.addCertPhotos([]); - verifyNever(() => repository.storeConsultantCertPhotos(files: any(named: 'files'))); + verifyNever( + () => repository.storeConsultantCertPhotos(files: any(named: 'files')), + ); }); test('updateConsultantProfileModel - обновляет без acceptOffer', () { @@ -225,4 +556,38 @@ void main() { expect(cubit.state.loading, isFalse); }); }); + + group('AlfaScoringStatus', () { + test('fromString maps known values', () { + expect( + AlfaScoringStatus.fromString('beneficiary_creating'), + AlfaScoringStatus.beneficiaryCreating, + ); + expect( + AlfaScoringStatus.fromString('scoring_pending'), + AlfaScoringStatus.scoringPending, + ); + expect( + AlfaScoringStatus.fromString('scoring_complete'), + AlfaScoringStatus.scoringComplete, + ); + expect( + AlfaScoringStatus.fromString('scoring_failed'), + AlfaScoringStatus.scoringFailed, + ); + expect( + AlfaScoringStatus.fromString('beneficiary_failed'), + AlfaScoringStatus.beneficiaryFailed, + ); + expect(AlfaScoringStatus.fromString(null), AlfaScoringStatus.none); + expect(AlfaScoringStatus.fromString('unknown'), AlfaScoringStatus.none); + }); + + test('helpers', () { + expect(AlfaScoringStatus.scoringPending.isProcessing, isTrue); + expect(AlfaScoringStatus.scoringFailed.canRetry, isTrue); + expect(AlfaScoringStatus.scoringComplete.isComplete, isTrue); + expect(AlfaScoringStatus.scoringComplete.isTerminal, isTrue); + }); + }); } diff --git a/test/features/consultant/become_consultant/presentation/pages/additional_settings_screen_test.dart b/test/features/consultant/become_consultant/presentation/pages/additional_settings_screen_test.dart index 79425f7f..c4fc2eb9 100644 --- a/test/features/consultant/become_consultant/presentation/pages/additional_settings_screen_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/additional_settings_screen_test.dart @@ -18,15 +18,28 @@ void main() { return MaterialApp( theme: ThemeHelper().themeData(), home: Scaffold( - body: BlocProvider.value(value: cubit, child: child), + body: BlocProvider.value( + value: cubit, + child: child, + ), ), ); } testWidgets('renders additional settings screen', (tester) async { final cubit = MockRegisterConsultantCubit(); - const consultant = ConsultantProfileModel(hasMedicalEducation: null, socialLinks: null, photos: null, certificates: null); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + const consultant = ConsultantProfileModel( + hasMedicalEducation: null, + socialLinks: null, + photos: null, + certificates: null, + ); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); when(cubit.state).thenReturn(state); when(cubit.stream).thenAnswer((_) => Stream.value(state)); diff --git a/test/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen_smoke_test.dart b/test/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen_smoke_test.dart index 8101e5eb..5288e170 100644 --- a/test/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen_smoke_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen_smoke_test.dart @@ -21,12 +21,18 @@ import 'certificates_and_diplomas_screen_smoke_test.mocks.dart'; void main() { group('CertificatesAndDiplomasScreen smoke', () { setUpAll(() async { - dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); + dotenv.loadFromString( + envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost', + ); }); setUp(() async { await sl.reset(); - sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); + sl.registerLazySingleton( + () => + AppConfig(buildType: AppBuildType.tests) + ..setBaseUrl('http://localhost'), + ); }); Widget wrap(Widget child) { @@ -45,15 +51,30 @@ void main() { testWidgets('renders certificates and diplomas screen', (tester) async { final cubit = MockRegisterConsultantCubit(); - const consultant = ConsultantProfileModel(hasMedicalEducation: null, socialLinks: null, photos: null, certificates: null); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + const consultant = ConsultantProfileModel( + hasMedicalEducation: null, + socialLinks: null, + photos: null, + certificates: null, + ); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); when(cubit.state).thenReturn(state); when(cubit.stream).thenAnswer((_) => Stream.value(state)); when(cubit.close()).thenAnswer((_) async => Future.value()); when(cubit.updateConsultantProfileModel(any)).thenReturn(null); - await tester.pumpWidget(BlocProvider.value(value: cubit, child: wrap(const CertificatesAndDiplomasScreen()))); + await tester.pumpWidget( + BlocProvider.value( + value: cubit, + child: wrap(const CertificatesAndDiplomasScreen()), + ), + ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); @@ -67,16 +88,28 @@ void main() { hasMedicalEducation: null, socialLinks: null, photos: null, - certificates: [CertificatesModel(title: 'Test Certificate', date: '2025-01-01')], + certificates: [ + CertificatesModel(title: 'Test Certificate', date: '2025-01-01'), + ], + ); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); when(cubit.state).thenReturn(state); when(cubit.stream).thenAnswer((_) => Stream.value(state)); when(cubit.close()).thenAnswer((_) async => Future.value()); when(cubit.updateConsultantProfileModel(any)).thenReturn(null); - await tester.pumpWidget(BlocProvider.value(value: cubit, child: wrap(const CertificatesAndDiplomasScreen()))); + await tester.pumpWidget( + BlocProvider.value( + value: cubit, + child: wrap(const CertificatesAndDiplomasScreen()), + ), + ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); diff --git a/test/features/consultant/become_consultant/presentation/pages/last_step_screen_test.dart b/test/features/consultant/become_consultant/presentation/pages/last_step_screen_test.dart index c95b21c1..800f7430 100644 --- a/test/features/consultant/become_consultant/presentation/pages/last_step_screen_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/last_step_screen_test.dart @@ -19,50 +19,78 @@ import 'last_step_screen_test.mocks.dart'; void main() { group('LastStepScreen smoke', () { setUpAll(() async { - dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); + dotenv.loadFromString( + envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost', + ); }); setUp(() async { await sl.reset(); - sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); + sl.registerLazySingleton( + () => + AppConfig(buildType: AppBuildType.tests) + ..setBaseUrl('http://localhost'), + ); }); Widget wrap(Widget child, RegisterConsultantCubit cubit) { return MaterialApp( theme: ThemeHelper().themeData(), home: Scaffold( - body: BlocProvider.value(value: cubit, child: child), + body: BlocProvider.value( + value: cubit, + child: child, + ), ), ); } - testWidgets('renders last step screen with both agreements for new registration', (tester) async { - final cubit = MockRegisterConsultantCubit(); - const consultant = ConsultantProfileModel( - hasMedicalEducation: null, - socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null), - photos: null, - certificates: null, - ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + testWidgets( + 'renders last step screen with both agreements for new registration', + (tester) async { + final cubit = MockRegisterConsultantCubit(); + const consultant = ConsultantProfileModel( + hasMedicalEducation: null, + socialLinks: SocialLinksModel( + vkLink: null, + tgLink: null, + fbLink: null, + instaLink: null, + personalSiteLink: null, + ), + photos: null, + certificates: null, + ); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); - when(cubit.state).thenReturn(state); - when(cubit.stream).thenAnswer((_) => Stream.value(state)); - when(cubit.close()).thenAnswer((_) async => Future.value()); + when(cubit.state).thenReturn(state); + when(cubit.stream).thenAnswer((_) => Stream.value(state)); + when(cubit.close()).thenAnswer((_) async => Future.value()); - await tester.pumpWidget(wrap(const LastStepScreen(), cubit)); - await tester.pump(); + await tester.pumpWidget(wrap(const LastStepScreen(), cubit)); + await tester.pump(); - expect(find.byType(LastStepScreen), findsOneWidget); - expect(find.text('Последний шаг'), findsOneWidget); - expect(find.byType(CheckboxListTile), findsNWidgets(2)); - expect(find.textContaining('ФРДО'), findsOneWidget); - }); + expect(find.byType(LastStepScreen), findsOneWidget); + expect(find.text('Последний шаг'), findsOneWidget); + expect(find.byType(CheckboxListTile), findsNWidgets(2)); + expect(find.textContaining('ФРДО'), findsOneWidget); + }, + ); testWidgets('hides FRDO checkbox in update mode', (tester) async { final cubit = MockRegisterConsultantCubit(); const consultant = ConsultantProfileModel(); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: true); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: true, + ); when(cubit.state).thenReturn(state); when(cubit.stream).thenAnswer((_) => Stream.value(state)); diff --git a/test/features/consultant/become_consultant/presentation/pages/passport_data_screen_test.dart b/test/features/consultant/become_consultant/presentation/pages/passport_data_screen_test.dart index 3344e719..1b508e45 100644 --- a/test/features/consultant/become_consultant/presentation/pages/passport_data_screen_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/passport_data_screen_test.dart @@ -16,7 +16,9 @@ void main() { late RegisterConsultantCubit cubit; setUp(() { - cubit = RegisterConsultantCubit(consultantRepository: MockConsultantRepository()); + cubit = RegisterConsultantCubit( + consultantRepository: MockConsultantRepository(), + ); cubit.emit( const RegisterConsultantState( consultant: ConsultantProfileModel(socialLinks: SocialLinksModel()), @@ -35,7 +37,10 @@ void main() { return MaterialApp( theme: ThemeHelper().themeData(), home: Scaffold( - body: BlocProvider.value(value: cubit, child: child), + body: BlocProvider.value( + value: cubit, + child: child, + ), ), ); } diff --git a/test/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen_smoke_test.dart b/test/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen_smoke_test.dart index 94bdfe3b..5086cf86 100644 --- a/test/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen_smoke_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen_smoke_test.dart @@ -19,12 +19,18 @@ import 'stepper_become_consultant_screen_smoke_test.mocks.dart'; void main() { group('PhotoOrScansScreen smoke', () { setUpAll(() async { - dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); + dotenv.loadFromString( + envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost', + ); }); setUp(() async { await sl.reset(); - sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); + sl.registerLazySingleton( + () => + AppConfig(buildType: AppBuildType.tests) + ..setBaseUrl('http://localhost'), + ); }); Widget wrap(Widget child, RegisterConsultantCubit cubit) { @@ -38,7 +44,10 @@ void main() { ], supportedLocales: const [Locale('ru')], home: Scaffold( - body: BlocProvider.value(value: cubit, child: child), + body: BlocProvider.value( + value: cubit, + child: child, + ), ), ); } @@ -49,11 +58,22 @@ void main() { final cubit = RegisterConsultantCubit(consultantRepository: repository); const consultant = ConsultantProfileModel( hasMedicalEducation: null, - socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null), + socialLinks: SocialLinksModel( + vkLink: null, + tgLink: null, + fbLink: null, + instaLink: null, + personalSiteLink: null, + ), photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); cubit.emit(state); @@ -61,7 +81,10 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(PhotoOrScansScreen), findsOneWidget); - expect(find.text('Загрузите фото или сканы дипломов и сертификатов'), findsOneWidget); + expect( + find.text('Загрузите фото или сканы дипломов и сертификатов'), + findsOneWidget, + ); }); }); @@ -71,11 +94,22 @@ void main() { final cubit = RegisterConsultantCubit(consultantRepository: repository); const consultant = ConsultantProfileModel( hasMedicalEducation: null, - socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null), + socialLinks: SocialLinksModel( + vkLink: null, + tgLink: null, + fbLink: null, + instaLink: null, + personalSiteLink: null, + ), photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); cubit.emit(state); diff --git a/test/features/consultant/become_consultant/presentation/pages/registration_as_screen_test.dart b/test/features/consultant/become_consultant/presentation/pages/registration_as_screen_test.dart index d4818dd8..28d432f6 100644 --- a/test/features/consultant/become_consultant/presentation/pages/registration_as_screen_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/registration_as_screen_test.dart @@ -16,7 +16,9 @@ void main() { late RegisterConsultantCubit cubit; setUp(() { - cubit = RegisterConsultantCubit(consultantRepository: MockConsultantRepository()); + cubit = RegisterConsultantCubit( + consultantRepository: MockConsultantRepository(), + ); cubit.emit( const RegisterConsultantState( consultant: ConsultantProfileModel(socialLinks: SocialLinksModel()), @@ -35,7 +37,10 @@ void main() { return MaterialApp( theme: ThemeHelper().themeData(), home: Scaffold( - body: BlocProvider.value(value: cubit, child: child), + body: BlocProvider.value( + value: cubit, + child: child, + ), ), ); } diff --git a/test/features/consultant/become_consultant/presentation/pages/social_networks_screen_smoke_test.dart b/test/features/consultant/become_consultant/presentation/pages/social_networks_screen_smoke_test.dart index adddd454..7db759e2 100644 --- a/test/features/consultant/become_consultant/presentation/pages/social_networks_screen_smoke_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/social_networks_screen_smoke_test.dart @@ -21,12 +21,18 @@ import 'social_networks_screen_smoke_test.mocks.dart'; void main() { group('SocialNetworksScreen smoke', () { setUpAll(() async { - dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); + dotenv.loadFromString( + envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost', + ); }); setUp(() async { await sl.reset(); - sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); + sl.registerLazySingleton( + () => + AppConfig(buildType: AppBuildType.tests) + ..setBaseUrl('http://localhost'), + ); }); Widget wrap(Widget child) { @@ -47,18 +53,34 @@ void main() { final cubit = MockRegisterConsultantCubit(); const consultant = ConsultantProfileModel( hasMedicalEducation: null, - socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null), + socialLinks: SocialLinksModel( + vkLink: null, + tgLink: null, + fbLink: null, + instaLink: null, + personalSiteLink: null, + ), photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); when(cubit.state).thenReturn(state); when(cubit.stream).thenAnswer((_) => Stream.value(state)); when(cubit.close()).thenAnswer((_) async => Future.value()); when(cubit.updateConsultantProfileModel(any)).thenReturn(null); - await tester.pumpWidget(BlocProvider.value(value: cubit, child: wrap(const SocialNetworksScreen()))); + await tester.pumpWidget( + BlocProvider.value( + value: cubit, + child: wrap(const SocialNetworksScreen()), + ), + ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); @@ -69,18 +91,34 @@ void main() { final cubit = MockRegisterConsultantCubit(); const consultant = ConsultantProfileModel( hasMedicalEducation: null, - socialLinks: SocialLinksModel(vkLink: 'https://vk.com/test', tgLink: 'https://t.me/test', fbLink: null, instaLink: null, personalSiteLink: null), + socialLinks: SocialLinksModel( + vkLink: 'https://vk.com/test', + tgLink: 'https://t.me/test', + fbLink: null, + instaLink: null, + personalSiteLink: null, + ), photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); when(cubit.state).thenReturn(state); when(cubit.stream).thenAnswer((_) => Stream.value(state)); when(cubit.close()).thenAnswer((_) async => Future.value()); when(cubit.updateConsultantProfileModel(any)).thenReturn(null); - await tester.pumpWidget(BlocProvider.value(value: cubit, child: wrap(const SocialNetworksScreen()))); + await tester.pumpWidget( + BlocProvider.value( + value: cubit, + child: wrap(const SocialNetworksScreen()), + ), + ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); diff --git a/test/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen_smoke_test.dart b/test/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen_smoke_test.dart index 0f742c33..d663ea7e 100644 --- a/test/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen_smoke_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen_smoke_test.dart @@ -21,12 +21,18 @@ import 'stepper_become_consultant_screen_smoke_test.mocks.dart'; void main() { group('StepperBecomeConsultantScreen smoke', () { setUpAll(() async { - dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); + dotenv.loadFromString( + envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost', + ); }); setUp(() async { await sl.reset(); - sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); + sl.registerLazySingleton( + () => + AppConfig(buildType: AppBuildType.tests) + ..setBaseUrl('http://localhost'), + ); }); Widget wrap(Widget child) { @@ -48,11 +54,22 @@ void main() { final cubit = RegisterConsultantCubit(consultantRepository: repository); const consultant = ConsultantProfileModel( hasMedicalEducation: null, - socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null), + socialLinks: SocialLinksModel( + vkLink: null, + tgLink: null, + fbLink: null, + instaLink: null, + personalSiteLink: null, + ), photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); cubit.emit(state); @@ -71,11 +88,22 @@ void main() { final cubit = RegisterConsultantCubit(consultantRepository: repository); const consultant = ConsultantProfileModel( hasMedicalEducation: null, - socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null), + socialLinks: SocialLinksModel( + vkLink: null, + tgLink: null, + fbLink: null, + instaLink: null, + personalSiteLink: null, + ), photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); cubit.emit(state); @@ -94,11 +122,22 @@ void main() { final cubit = RegisterConsultantCubit(consultantRepository: repository); const consultant = ConsultantProfileModel( hasMedicalEducation: null, - socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null), + socialLinks: SocialLinksModel( + vkLink: null, + tgLink: null, + fbLink: null, + instaLink: null, + personalSiteLink: null, + ), photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); cubit.emit(state); @@ -117,11 +156,22 @@ void main() { final cubit = RegisterConsultantCubit(consultantRepository: repository); const consultant = ConsultantProfileModel( hasMedicalEducation: null, - socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null), + socialLinks: SocialLinksModel( + vkLink: null, + tgLink: null, + fbLink: null, + instaLink: null, + personalSiteLink: null, + ), photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); cubit.emit(state); @@ -140,11 +190,22 @@ void main() { final cubit = RegisterConsultantCubit(consultantRepository: repository); const consultant = ConsultantProfileModel( hasMedicalEducation: null, - socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null), + socialLinks: SocialLinksModel( + vkLink: null, + tgLink: null, + fbLink: null, + instaLink: null, + personalSiteLink: null, + ), photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); cubit.emit(state); @@ -163,11 +224,22 @@ void main() { final cubit = RegisterConsultantCubit(consultantRepository: repository); const consultant = ConsultantProfileModel( hasMedicalEducation: null, - socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null), + socialLinks: SocialLinksModel( + vkLink: null, + tgLink: null, + fbLink: null, + instaLink: null, + personalSiteLink: null, + ), photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); cubit.emit(state); diff --git a/test/features/consultant/become_consultant/repositories/consultant_repository_test.dart b/test/features/consultant/become_consultant/repositories/consultant_repository_test.dart index 468e638c..3ea68162 100644 --- a/test/features/consultant/become_consultant/repositories/consultant_repository_test.dart +++ b/test/features/consultant/become_consultant/repositories/consultant_repository_test.dart @@ -37,42 +37,99 @@ void main() { setUp(() { mockService = MockConsultantService(); repository = ConsultantRepository(consultantService: mockService); - consultantProfile = ConsultantProfileModel.fromJson(FixtureReader.readAsMap('consultant/consultant_profile_request.json')); - consultationRequest = ConsultationRequestModel.fromJson(FixtureReader.readAsMap('consultant/consultation_request.json')); - certPhotos = FixtureReader.readAsList('consultant/consultant_cert_photos.json') - .map((item) => ConsultantCertPhotoModel.fromJson(item as Map)) - .toList(); + consultantProfile = ConsultantProfileModel.fromJson( + FixtureReader.readAsMap('consultant/consultant_profile_request.json'), + ); + consultationRequest = ConsultationRequestModel.fromJson( + FixtureReader.readAsMap('consultant/consultation_request.json'), + ); + certPhotos = + FixtureReader.readAsList('consultant/consultant_cert_photos.json') + .map( + (item) => ConsultantCertPhotoModel.fromJson( + item as Map, + ), + ) + .toList(); }); - test('registerConsultant delegates to consultantRegistration when update=false', () async { - when(() => mockService.consultantRegistration(consultantModel: any(named: 'consultantModel'))).thenAnswer((_) async {}); + test( + 'registerConsultant delegates to consultantRegistration when update=false', + () async { + when( + () => mockService.consultantRegistration( + consultantModel: any( + named: 'consultantModel', + ), + ), + ).thenAnswer((_) async {}); - await repository.registerConsultant(consultant: consultantProfile); + await repository.registerConsultant(consultant: consultantProfile); - verify(() => mockService.consultantRegistration(consultantModel: consultantProfile)).called(1); - verifyNever(() => mockService.consultantUpdate(consultantModel: any(named: 'consultantModel'))); - }); + verify( + () => mockService.consultantRegistration( + consultantModel: consultantProfile, + ), + ).called(1); + verifyNever( + () => mockService.consultantUpdate( + consultantModel: any( + named: 'consultantModel', + ), + ), + ); + }, + ); - test('registerConsultant delegates to consultantUpdate when update=true', () async { - when(() => mockService.consultantUpdate(consultantModel: any(named: 'consultantModel'))).thenAnswer((_) async {}); + test( + 'registerConsultant delegates to consultantUpdate when update=true', + () async { + when( + () => mockService.consultantUpdate( + consultantModel: any( + named: 'consultantModel', + ), + ), + ).thenAnswer((_) async {}); - await repository.registerConsultant(consultant: consultantProfile, update: true); + await repository.registerConsultant( + consultant: consultantProfile, + update: true, + ); - verify(() => mockService.consultantUpdate(consultantModel: consultantProfile)).called(1); - verifyNever(() => mockService.consultantRegistration(consultantModel: any(named: 'consultantModel'))); - }); + verify( + () => + mockService.consultantUpdate(consultantModel: consultantProfile), + ).called(1); + verifyNever( + () => mockService.consultantRegistration( + consultantModel: any( + named: 'consultantModel', + ), + ), + ); + }, + ); test('getConsultantRequestStatus returns request from service', () async { - when(() => mockService.getConsultantRequestStatus(slug: any(named: 'slug'))).thenAnswer((_) async => consultationRequest); + when( + () => mockService.getConsultantRequestStatus( + slug: any(named: 'slug'), + ), + ).thenAnswer((_) async => consultationRequest); final result = await repository.getConsultantRequestStatus('user-slug'); expect(result, equals(consultationRequest)); - verify(() => mockService.getConsultantRequestStatus(slug: 'user-slug')).called(1); + verify( + () => mockService.getConsultantRequestStatus(slug: 'user-slug'), + ).called(1); }); test('getMyRequestStatus returns request from service', () async { - when(() => mockService.getMyRequestStatus(slug: any(named: 'slug'))).thenAnswer((_) async => consultationRequest); + when( + () => mockService.getMyRequestStatus(slug: any(named: 'slug')), + ).thenAnswer((_) async => consultationRequest); final result = await repository.getMyRequestStatus('my-slug'); @@ -81,7 +138,9 @@ void main() { }); test('getConsultantCertPhotos returns photos from service', () async { - when(() => mockService.getConsultantCertPhotos()).thenAnswer((_) async => certPhotos); + when( + () => mockService.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotos); final result = await repository.getConsultantCertPhotos(); @@ -101,42 +160,80 @@ void main() { verify(() => mockService.getSbpBanks()).called(1); }); - test('storeConsultantCertPhotos returns empty list when no files provided', () async { - final result = await repository.storeConsultantCertPhotos(files: []); + test( + 'storeConsultantCertPhotos returns empty list when no files provided', + () async { + final result = await repository.storeConsultantCertPhotos(files: []); - expect(result, isEmpty); - verifyNever(() => mockService.storeConsultantCertPhotos(photos: any>(named: 'photos'))); - }); + expect(result, isEmpty); + verifyNever( + () => mockService.storeConsultantCertPhotos( + photos: any>(named: 'photos'), + ), + ); + }, + ); - test('storeConsultantCertPhotos uploads prepared files and returns response', () async { - final mockFile = MockFile(); - when(() => mockFile.readAsBytes()).thenAnswer((_) async => Uint8List.fromList([1, 2, 3])); - when(() => mockFile.path).thenReturn('/tmp/certificate.png'); - when(() => mockService.storeConsultantCertPhotos(photos: any>(named: 'photos'))).thenAnswer((_) async => certPhotos); + test( + 'storeConsultantCertPhotos uploads prepared files and returns response', + () async { + final mockFile = MockFile(); + when( + () => mockFile.readAsBytes(), + ).thenAnswer((_) async => Uint8List.fromList([1, 2, 3])); + when(() => mockFile.path).thenReturn('/tmp/certificate.png'); + when( + () => mockService.storeConsultantCertPhotos( + photos: any>(named: 'photos'), + ), + ).thenAnswer((_) async => certPhotos); - final result = await repository.storeConsultantCertPhotos(files: [mockFile]); + final result = await repository.storeConsultantCertPhotos( + files: [mockFile], + ); - expect(result, certPhotos); - final capturedArgument = verify(() => mockService.storeConsultantCertPhotos(photos: captureAny>(named: 'photos'))).captured.single as List; - expect(capturedArgument, hasLength(1)); - expect(capturedArgument.first.filename, 'certificate.png'); - }); + expect(result, certPhotos); + final capturedArgument = + verify( + () => mockService.storeConsultantCertPhotos( + photos: captureAny>(named: 'photos'), + ), + ).captured.single + as List; + expect(capturedArgument, hasLength(1)); + expect(capturedArgument.first.filename, 'certificate.png'); + }, + ); test('hideConsultantCertPhoto sends payload to service', () async { - when(() => mockService.hideConsultantCertPhoto(payload: any>(named: 'payload'))).thenAnswer((_) async {}); + when( + () => mockService.hideConsultantCertPhoto( + payload: any>(named: 'payload'), + ), + ).thenAnswer((_) async {}); await repository.hideConsultantCertPhoto(42); - verify(() => mockService.hideConsultantCertPhoto(payload: {'cert_photo_id': 42})).called(1); + verify( + () => + mockService.hideConsultantCertPhoto(payload: {'cert_photo_id': 42}), + ).called(1); }); test('unHideConsultantCertPhoto sends payload to service', () async { - when(() => mockService.unHideConsultantCertPhoto(payload: any>(named: 'payload'))).thenAnswer((_) async {}); + when( + () => mockService.unHideConsultantCertPhoto( + payload: any>(named: 'payload'), + ), + ).thenAnswer((_) async {}); await repository.unHideConsultantCertPhoto(7); - verify(() => mockService.unHideConsultantCertPhoto(payload: {'cert_photo_id': 7})).called(1); + verify( + () => mockService.unHideConsultantCertPhoto( + payload: {'cert_photo_id': 7}, + ), + ).called(1); }); }); } - diff --git a/test/features/consultant/become_consultant/services/consultant_service_test.dart b/test/features/consultant/become_consultant/services/consultant_service_test.dart index dd429904..0ae3b63b 100644 --- a/test/features/consultant/become_consultant/services/consultant_service_test.dart +++ b/test/features/consultant/become_consultant/services/consultant_service_test.dart @@ -20,11 +20,14 @@ void main() { test('getMyRequestStatus - возвращает статус запроса', () async { const slug = 'test-consultant'; - final json = FixtureReader.readAsMap('consultant/consultation_request.json'); + final json = FixtureReader.readAsMap( + 'consultant/consultation_request.json', + ); final expectedRequest = ConsultationRequestModel.fromJson(json); - when(mockConsultantService.getMyRequestStatus(slug: slug)) - .thenAnswer((_) async => expectedRequest); + when( + mockConsultantService.getMyRequestStatus(slug: slug), + ).thenAnswer((_) async => expectedRequest); final result = await mockConsultantService.getMyRequestStatus(slug: slug); @@ -33,25 +36,36 @@ void main() { verify(mockConsultantService.getMyRequestStatus(slug: slug)).called(1); }); - test('getConsultantRequestStatus - возвращает статус запроса консультанта', () async { - const slug = 'test-consultant'; - final json = FixtureReader.readAsMap('consultant/consultation_request.json'); - final expectedRequest = ConsultationRequestModel.fromJson(json); + test( + 'getConsultantRequestStatus - возвращает статус запроса консультанта', + () async { + const slug = 'test-consultant'; + final json = FixtureReader.readAsMap( + 'consultant/consultation_request.json', + ); + final expectedRequest = ConsultationRequestModel.fromJson(json); - when(mockConsultantService.getConsultantRequestStatus(slug: slug)) - .thenAnswer((_) async => expectedRequest); + when( + mockConsultantService.getConsultantRequestStatus(slug: slug), + ).thenAnswer((_) async => expectedRequest); - final result = await mockConsultantService.getConsultantRequestStatus(slug: slug); + final result = await mockConsultantService.getConsultantRequestStatus( + slug: slug, + ); - expect(result, isA()); - verify(mockConsultantService.getConsultantRequestStatus(slug: slug)).called(1); - }); + expect(result, isA()); + verify( + mockConsultantService.getConsultantRequestStatus(slug: slug), + ).called(1); + }, + ); test('getConsultantCertPhotos - возвращает фото сертификатов', () async { final expectedPhotos = []; - when(mockConsultantService.getConsultantCertPhotos()) - .thenAnswer((_) async => expectedPhotos); + when( + mockConsultantService.getConsultantCertPhotos(), + ).thenAnswer((_) async => expectedPhotos); final result = await mockConsultantService.getConsultantCertPhotos(); @@ -60,16 +74,22 @@ void main() { }); test('consultantRegistration - регистрирует консультанта', () async { - final profileJson = FixtureReader.readAsMap('consultant/consultant_profile_request.json'); + final profileJson = FixtureReader.readAsMap( + 'consultant/consultant_profile_request.json', + ); final profile = ConsultantProfileModel.fromJson(profileJson); - when(mockConsultantService.consultantRegistration(consultantModel: profile)) - .thenAnswer((_) async => Future.value()); + when( + mockConsultantService.consultantRegistration(consultantModel: profile), + ).thenAnswer((_) async => Future.value()); - await mockConsultantService.consultantRegistration(consultantModel: profile); + await mockConsultantService.consultantRegistration( + consultantModel: profile, + ); - verify(mockConsultantService.consultantRegistration(consultantModel: profile)).called(1); + verify( + mockConsultantService.consultantRegistration(consultantModel: profile), + ).called(1); }); }); } - diff --git a/test/features/consultant/become_consultant/utils/laravel_validation_errors_test.dart b/test/features/consultant/become_consultant/utils/laravel_validation_errors_test.dart new file mode 100644 index 00000000..4470e4e3 --- /dev/null +++ b/test/features/consultant/become_consultant/utils/laravel_validation_errors_test.dart @@ -0,0 +1,94 @@ +import 'package:dio/dio.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/utils/laravel_validation_errors.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('parseLaravelValidationErrors', () { + DioException dio422(Map data) { + return DioException( + requestOptions: RequestOptions(path: '/test'), + response: Response( + requestOptions: RequestOptions(path: '/test'), + statusCode: 422, + data: data, + ), + type: DioExceptionType.badResponse, + ); + } + + test('maps unique and format errors to Russian UI texts', () { + final parsed = parseLaravelValidationErrors( + dio422({ + 'message': 'The given data was invalid.', + 'errors': { + 'inn': ['The inn format is invalid.'], + 'passport_number': ['The passport number has already been taken.'], + 'snils': ['The snils has already been taken.'], + }, + }), + ); + + expect(parsed.fieldErrors['inn'], 'Некорректный ИНН'); + expect(parsed.fieldErrors['passport_number'], 'Такой паспорт существует'); + expect(parsed.fieldErrors['snils'], 'Такой СНИЛС существует'); + expect(parsed.summary, isNot(contains('The given data was invalid.'))); + expect(parsed.summary, isNot(contains('already been taken'))); + expect(parsed.summary, contains('Некорректный ИНН')); + expect(parsed.summary, contains('Такой паспорт существует')); + expect(parsed.hasFieldErrors, isTrue); + }); + + test('maps required and agreement errors', () { + final parsed = parseLaravelValidationErrors( + dio422({ + 'errors': { + 'last_name': ['The last name field is required.'], + 'agreement_accepted': ['The agreement accepted must be accepted.'], + }, + }), + ); + + expect(parsed.fieldErrors['last_name'], 'Укажите фамилию'); + expect(parsed.fieldErrors['agreement_accepted'], 'Примите условия оферты'); + }); + + test('falls back to generic message for non-422 DioException', () { + final error = DioException( + requestOptions: RequestOptions(path: '/test'), + message: 'Network failed', + type: DioExceptionType.connectionError, + ); + + final parsed = parseLaravelValidationErrors(error); + + expect(parsed.summary, 'Не удалось отправить данные. Попробуйте ещё раз'); + expect(parsed.fieldErrors, isEmpty); + }); + + test('uses generic text for non-Dio errors', () { + final parsed = parseLaravelValidationErrors(Exception('boom')); + + expect(parsed.summary, 'Не удалось отправить данные. Попробуйте ещё раз'); + expect(parsed.fieldErrors, isEmpty); + }); + }); + + group('mapConsultantValidationMessage', () { + test('unique inn and snils', () { + expect( + mapConsultantValidationMessage( + fieldKey: 'inn', + rawMessage: 'The inn has already been taken.', + ), + 'Такой ИНН существует', + ); + expect( + mapConsultantValidationMessage( + fieldKey: 'snils', + rawMessage: 'The snils has already been taken.', + ), + 'Такой СНИЛС существует', + ); + }); + }); +} diff --git a/test/fixtures/consultant/consultation_request.json b/test/fixtures/consultant/consultation_request.json index bf7ae187..7d1fcc78 100644 --- a/test/fixtures/consultant/consultation_request.json +++ b/test/fixtures/consultant/consultation_request.json @@ -32,7 +32,7 @@ "inn": "123456789012", "snils": "123-456-789 00", "state": "created", - "alfa_scoring_status": "pending", + "alfa_scoring_status": "scoring_failed", "alfa_scoring_request_id": "abc-123", "passport_series": "1234", "passport_number": "567890", -- 2.49.1 From 9de0efa28ec8ad24c60ebd6fad26a3febdbaf69e Mon Sep 17 00:00:00 2001 From: misha Date: Mon, 3 Aug 2026 15:30:54 +0300 Subject: [PATCH 71/88] fix: test fixed --- .../presentation/pages/blog_post_screen_smoke_test.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/features/blog_post/presentation/pages/blog_post_screen_smoke_test.dart b/test/features/blog_post/presentation/pages/blog_post_screen_smoke_test.dart index a021157a..f4253129 100644 --- a/test/features/blog_post/presentation/pages/blog_post_screen_smoke_test.dart +++ b/test/features/blog_post/presentation/pages/blog_post_screen_smoke_test.dart @@ -221,7 +221,10 @@ void main() { await tester.pump(); await tester.pumpAndSettle(); - await tester.tap(find.byIcon(Icons.favorite_border_rounded)); + final likeIcon = find.byIcon(Icons.favorite_border_rounded); + await tester.ensureVisible(likeIcon); + await tester.pumpAndSettle(); + await tester.tap(likeIcon); await tester.pump(); verify(blogPostCubit.toggleLike()).called(1); -- 2.49.1 From 9e8b8b89fc38b876dc930de32721f6d997313127 Mon Sep 17 00:00:00 2001 From: misha Date: Mon, 3 Aug 2026 18:12:05 +0300 Subject: [PATCH 72/88] feat(promo_codes): added promo codes handling to app --- .gitignore | 3 +- lib/features/chat/services/chat_service.dart | 24 ++ .../presentation/widgets/plan_widget.dart | 4 +- .../models/promo_applied_list_response.dart | 15 ++ .../payment/models/promo_apply_response.dart | 21 ++ .../payment/models/promo_code_model.dart | 22 ++ .../payment/models/promo_remove_response.dart | 15 ++ .../models/promo_validate_response.dart | 20 ++ .../payment/presentation/payment_flow.dart | 160 +++++++++++- .../widgets/promo_code_modal.dart | 242 +++++++++++++++--- .../repositories/payment_repository.dart | 69 +++++ lib/generated/intl/messages_en.dart | 42 ++- lib/generated/intl/messages_ru.dart | 14 + lib/generated/l10n.dart | 50 ++++ lib/l10n/intl_en.arb | 11 + lib/l10n/intl_ru.arb | 11 + .../consultation_payment_flow_test.dart | 233 +++++++++++++++-- .../models/promo_response_models_test.dart | 108 ++++++++ .../widgets/promo_code_modal_test.dart | 83 +++++- 19 files changed, 1062 insertions(+), 85 deletions(-) create mode 100644 lib/features/payment/models/promo_applied_list_response.dart create mode 100644 lib/features/payment/models/promo_apply_response.dart create mode 100644 lib/features/payment/models/promo_code_model.dart create mode 100644 lib/features/payment/models/promo_remove_response.dart create mode 100644 lib/features/payment/models/promo_validate_response.dart create mode 100644 test/features/payment/models/promo_response_models_test.dart diff --git a/.gitignore b/.gitignore index 6b66540b..af0c7b6d 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,5 @@ coverage/ backend/ .fvm/ .fvmrc -/eatvio \ No newline at end of file +/eatvio +*.code-workspace \ No newline at end of file diff --git a/lib/features/chat/services/chat_service.dart b/lib/features/chat/services/chat_service.dart index 2d5ceaac..3bd6a505 100644 --- a/lib/features/chat/services/chat_service.dart +++ b/lib/features/chat/services/chat_service.dart @@ -6,6 +6,10 @@ import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart' import 'package:eatvio_app/features/chat/models/order_id_to_conversation_model.dart'; import 'package:eatvio_app/features/chat/models/payment_model.dart'; import 'package:eatvio_app/features/chat/models/plan_complain_model.dart'; +import 'package:eatvio_app/features/payment/models/promo_applied_list_response.dart'; +import 'package:eatvio_app/features/payment/models/promo_apply_response.dart'; +import 'package:eatvio_app/features/payment/models/promo_remove_response.dart'; +import 'package:eatvio_app/features/payment/models/promo_validate_response.dart'; import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart'; import 'package:retrofit/retrofit.dart'; @@ -69,6 +73,26 @@ abstract class ChatService { @Body() required Map body, }); + @GET('/api/chosen_plans/applied_promos') + Future listAppliedPromoCodes(); + + @POST('/api/chosen_plans/{id}/validate_promo') + Future validatePromoCode({ + @Path() required int id, + @Body() required Map body, + }); + + @POST('/api/chosen_plans/{id}/apply_promo') + Future applyPromoCode({ + @Path() required int id, + @Body() required Map body, + }); + + @DELETE('/api/chosen_plans/{id}/remove_promo_code') + Future removePromoCode({ + @Path() required int id, + }); + @GET('/api/consultation/resolve/order_id_to_conversation/{orderId}') Future resolveOrderIdToConversation({ @Path() required String orderId, diff --git a/lib/features/feed/presentation/widgets/plan_widget.dart b/lib/features/feed/presentation/widgets/plan_widget.dart index 9b0a3f3d..96e2acff 100644 --- a/lib/features/feed/presentation/widgets/plan_widget.dart +++ b/lib/features/feed/presentation/widgets/plan_widget.dart @@ -116,9 +116,7 @@ class _PlanWidgetState extends State { Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: MarkdownHtmlWidget( - markdownText: widget.plan.details.length > 200 - ? '${widget.plan.details.substring(0, 200)}...' - : widget.plan.details, + markdownText: widget.plan.details.length > 200 ? '${widget.plan.details.substring(0, 200)}...' : widget.plan.details, textColor: appTheme.black, ), ), diff --git a/lib/features/payment/models/promo_applied_list_response.dart b/lib/features/payment/models/promo_applied_list_response.dart new file mode 100644 index 00000000..8d80c3e4 --- /dev/null +++ b/lib/features/payment/models/promo_applied_list_response.dart @@ -0,0 +1,15 @@ +import 'package:eatvio_app/features/payment/models/promo_code_model.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'promo_applied_list_response.freezed.dart'; +part 'promo_applied_list_response.g.dart'; + +@freezed +abstract class PromoAppliedListResponse with _$PromoAppliedListResponse { + const factory PromoAppliedListResponse({ + @JsonKey(name: 'promo_codes') required List promoCodes, + }) = _PromoAppliedListResponse; + + factory PromoAppliedListResponse.fromJson(Map json) => + _$PromoAppliedListResponseFromJson(json); +} diff --git a/lib/features/payment/models/promo_apply_response.dart b/lib/features/payment/models/promo_apply_response.dart new file mode 100644 index 00000000..0d3a5da5 --- /dev/null +++ b/lib/features/payment/models/promo_apply_response.dart @@ -0,0 +1,21 @@ +import 'package:eatvio_app/features/payment/models/promo_code_model.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'promo_apply_response.freezed.dart'; +part 'promo_apply_response.g.dart'; + +@freezed +abstract class PromoApplyResponse with _$PromoApplyResponse { + const factory PromoApplyResponse({ + required bool success, + String? error, + @JsonKey(name: 'promo_code') PromoCodeModel? promoCode, + @JsonKey(name: 'discount_percent') int? discountPercent, + @JsonKey(name: 'discount_amount_rub') double? discountAmountRub, + @JsonKey(name: 'original_amount_rub') double? originalAmountRub, + @JsonKey(name: 'final_amount_rub') double? finalAmountRub, + }) = _PromoApplyResponse; + + factory PromoApplyResponse.fromJson(Map json) => + _$PromoApplyResponseFromJson(json); +} diff --git a/lib/features/payment/models/promo_code_model.dart b/lib/features/payment/models/promo_code_model.dart new file mode 100644 index 00000000..4f3858ea --- /dev/null +++ b/lib/features/payment/models/promo_code_model.dart @@ -0,0 +1,22 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'promo_code_model.freezed.dart'; +part 'promo_code_model.g.dart'; + +@freezed +abstract class PromoCodeModel with _$PromoCodeModel { + const factory PromoCodeModel({ + required int id, + @JsonKey(name: 'chosen_plan_id') required int chosenPlanId, + @JsonKey(name: 'promo_code') required String promoCode, + @JsonKey(name: 'discount_percent') required int discountPercent, + @JsonKey(name: 'discount_amount_rub') required double discountAmountRub, + required String state, + @JsonKey(name: 'applied_at') String? appliedAt, + @JsonKey(name: 'confirmed_at') String? confirmedAt, + @JsonKey(name: 'cancelled_at') String? cancelledAt, + }) = _PromoCodeModel; + + factory PromoCodeModel.fromJson(Map json) => + _$PromoCodeModelFromJson(json); +} diff --git a/lib/features/payment/models/promo_remove_response.dart b/lib/features/payment/models/promo_remove_response.dart new file mode 100644 index 00000000..2d675e0c --- /dev/null +++ b/lib/features/payment/models/promo_remove_response.dart @@ -0,0 +1,15 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'promo_remove_response.freezed.dart'; +part 'promo_remove_response.g.dart'; + +@freezed +abstract class PromoRemoveResponse with _$PromoRemoveResponse { + const factory PromoRemoveResponse({ + required bool success, + String? error, + }) = _PromoRemoveResponse; + + factory PromoRemoveResponse.fromJson(Map json) => + _$PromoRemoveResponseFromJson(json); +} diff --git a/lib/features/payment/models/promo_validate_response.dart b/lib/features/payment/models/promo_validate_response.dart new file mode 100644 index 00000000..a07b3408 --- /dev/null +++ b/lib/features/payment/models/promo_validate_response.dart @@ -0,0 +1,20 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'promo_validate_response.freezed.dart'; +part 'promo_validate_response.g.dart'; + +@freezed +abstract class PromoValidateResponse with _$PromoValidateResponse { + const factory PromoValidateResponse({ + required bool valid, + String? error, + @JsonKey(name: 'promo_code') String? promoCode, + @JsonKey(name: 'discount_percent') int? discountPercent, + @JsonKey(name: 'discount_amount_rub') double? discountAmountRub, + @JsonKey(name: 'original_amount_rub') double? originalAmountRub, + @JsonKey(name: 'final_amount_rub') double? finalAmountRub, + }) = _PromoValidateResponse; + + factory PromoValidateResponse.fromJson(Map json) => + _$PromoValidateResponseFromJson(json); +} diff --git a/lib/features/payment/presentation/payment_flow.dart b/lib/features/payment/presentation/payment_flow.dart index 03cfe5b5..2764dc9e 100644 --- a/lib/features/payment/presentation/payment_flow.dart +++ b/lib/features/payment/presentation/payment_flow.dart @@ -1,22 +1,73 @@ +import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/utils/toasts.dart'; +import 'package:eatvio_app/features/payment/models/promo_apply_response.dart'; +import 'package:eatvio_app/features/payment/models/promo_code_model.dart'; +import 'package:eatvio_app/features/payment/models/promo_validate_response.dart'; import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_gate_bottom_sheet.dart'; import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_modal.dart'; import 'package:eatvio_app/features/payment/presentation/widgets/sbp_banks_bottom_sheet.dart'; -import 'package:eatvio_app/generated/l10n.dart'; +import 'package:eatvio_app/features/payment/repositories/payment_repository.dart'; import 'package:flutter/material.dart'; /// Оркестрирует флоу оплаты консультации: -/// гейт «У вас есть промокод?» → (Да → форма промокода) → SbpBanks. +/// applied rehydrate → (или гейт → форма) → apply при необходимости → SbpBanks. Future showConsultationPaymentFlow( BuildContext context, { required int planId, Future Function(BuildContext)? showGate, - Future Function(BuildContext, {Future Function(String)? onValidate})? showForm, + Future Function( + BuildContext, { + Future Function(String)? onValidate, + Future Function()? onRemove, + PromoCodeModel? initialPromo, + })? showForm, Future Function(BuildContext, {required int planId})? showSbp, + Future Function({ + required int planId, + required String promoCode, + })? validatePromo, + Future Function({ + required int planId, + required String promoCode, + })? applyPromo, + Future Function(int planId)? listApplied, + Future Function(int planId)? removePromo, }) async { final gate = showGate ?? _showGate; final form = showForm ?? _showForm; final sbp = showSbp ?? _showSbp; - final s = S.of(context); + final validate = validatePromo ?? _defaultValidatePromo; + final apply = applyPromo ?? _defaultApplyPromo; + final findApplied = listApplied ?? _defaultFindApplied; + final remove = removePromo ?? _defaultRemovePromo; + + final existing = await findApplied(planId); + if (!context.mounted) return; + + if (existing != null) { + final formResult = await form( + context, + initialPromo: existing, + onRemove: () => remove(planId), + onValidate: (code) => _mapValidateResponse( + validate(planId: planId, promoCode: code), + ), + ); + if (formResult == null || !context.mounted) return; + + if (formResult.hasPromoCode && !formResult.alreadyApplied) { + final applied = await _tryApplyPromo( + apply: apply, + planId: planId, + promoCode: formResult.code!, + ); + if (!applied || !context.mounted) return; + } + + if (!context.mounted) return; + await sbp(context, planId: planId); + return; + } final gateChoice = await gate(context); if (gateChoice == null || !context.mounted) return; @@ -24,9 +75,20 @@ Future showConsultationPaymentFlow( if (gateChoice) { final formResult = await form( context, - onValidate: (_) async => PromoValidation.failure(s.promo_invalid), + onValidate: (code) => _mapValidateResponse( + validate(planId: planId, promoCode: code), + ), ); if (formResult == null || !context.mounted) return; + + if (formResult.hasPromoCode && !formResult.alreadyApplied) { + final applied = await _tryApplyPromo( + apply: apply, + planId: planId, + promoCode: formResult.code!, + ); + if (!applied || !context.mounted) return; + } } if (!context.mounted) return; @@ -40,10 +102,96 @@ Future _showGate(BuildContext context) { Future _showForm( BuildContext context, { Future Function(String)? onValidate, + Future Function()? onRemove, + PromoCodeModel? initialPromo, }) { - return ItvioPromoCodeModal.show(context, onValidate: onValidate); + return ItvioPromoCodeModal.show( + context, + onValidate: onValidate, + onRemove: onRemove, + initialPromo: initialPromo, + ); } Future _showSbp(BuildContext context, {required int planId}) { return showSbpBanksBottomSheet(context, planId: planId); } + +Future _defaultValidatePromo({ + required int planId, + required String promoCode, +}) { + return sl().validatePromoCode( + planId: planId, + promoCode: promoCode, + ); +} + +Future _defaultApplyPromo({ + required int planId, + required String promoCode, +}) { + return sl().applyPromoCode( + planId: planId, + promoCode: promoCode, + ); +} + +Future _defaultFindApplied(int planId) { + return sl().findAppliedPromoForPlan(planId); +} + +Future _defaultRemovePromo(int planId) async { + try { + final response = await sl().removePromoCode(planId: planId); + if (response.success) return true; + showErrorToast(response.error ?? 'Не удалось убрать промокод.'); + return false; + } catch (_) { + showErrorToast('Не удалось убрать промокод. Попробуйте позже'); + return false; + } +} + +Future _mapValidateResponse( + Future request, +) async { + try { + final response = await request; + if (!response.valid) { + return PromoValidation.failure( + response.error ?? 'Промокод невалиден.', + ); + } + return PromoValidation.success( + discountPercent: response.discountPercent, + discountAmountRub: response.discountAmountRub, + originalAmountRub: response.originalAmountRub, + finalAmountRub: response.finalAmountRub, + ); + } catch (_) { + return const PromoValidation.failure( + 'Не удалось проверить промокод. Попробуйте позже', + ); + } +} + +Future _tryApplyPromo({ + required Future Function({ + required int planId, + required String promoCode, + }) apply, + required int planId, + required String promoCode, +}) async { + try { + final response = await apply(planId: planId, promoCode: promoCode); + if (response.success) return true; + + showErrorToast(response.error ?? 'Не удалось применить промокод.'); + return false; + } catch (_) { + showErrorToast('Не удалось применить промокод. Попробуйте позже'); + return false; + } +} diff --git a/lib/features/payment/presentation/widgets/promo_code_modal.dart b/lib/features/payment/presentation/widgets/promo_code_modal.dart index 0d4a9942..7de3eecf 100644 --- a/lib/features/payment/presentation/widgets/promo_code_modal.dart +++ b/lib/features/payment/presentation/widgets/promo_code_modal.dart @@ -1,45 +1,87 @@ 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/bottom_sheet_handler_bar.dart'; +import 'package:eatvio_app/features/payment/models/promo_code_model.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; /// Результат работы модалки промокода. class PromoCodeResult { - const PromoCodeResult({this.code}); + const PromoCodeResult({ + this.code, + this.discountPercent, + this.discountAmountRub, + this.originalAmountRub, + this.finalAmountRub, + this.alreadyApplied = false, + }); /// Применённый промокод или null, если пользователь продолжил без него. final String? code; + final int? discountPercent; + final double? discountAmountRub; + final double? originalAmountRub; + final double? finalAmountRub; + + /// true — промокод уже на сервере (rehydrate Continue), apply не нужен. + final bool alreadyApplied; bool get hasPromoCode => code != null; } /// Ответ проверки промокода. class PromoValidation { - const PromoValidation.success() : error = null; - const PromoValidation.failure(this.error); + const PromoValidation.success({ + this.discountPercent, + this.discountAmountRub, + this.originalAmountRub, + this.finalAmountRub, + }) : error = null; + + const PromoValidation.failure(this.error) + : discountPercent = null, + discountAmountRub = null, + originalAmountRub = null, + finalAmountRub = null; final String? error; + final int? discountPercent; + final double? discountAmountRub; + final double? originalAmountRub; + final double? finalAmountRub; bool get isValid => error == null; } -/// Модальное окно ввода промокода. +/// Модальное окно ввода / просмотра применённого промокода. class ItvioPromoCodeModal extends StatefulWidget { - const ItvioPromoCodeModal({super.key, this.onValidate}); + const ItvioPromoCodeModal({ + super.key, + this.onValidate, + this.onRemove, + this.initialPromo, + }); final Future Function(String code)? onValidate; + final Future Function()? onRemove; + final PromoCodeModel? initialPromo; static Future show( BuildContext context, { Future Function(String code)? onValidate, + Future Function()? onRemove, + PromoCodeModel? initialPromo, }) { return showModalBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, backgroundColor: Colors.transparent, - builder: (_) => ItvioPromoCodeModal(onValidate: onValidate), + builder: (_) => ItvioPromoCodeModal( + onValidate: onValidate, + onRemove: onRemove, + initialPromo: initialPromo, + ), ); } @@ -47,14 +89,32 @@ class ItvioPromoCodeModal extends StatefulWidget { State createState() => _ItvioPromoCodeModalState(); } -enum _ModalState { input, loading } +enum _ModalState { input, loading, applied } class _ItvioPromoCodeModalState extends State { final _controller = TextEditingController(); final _focusNode = FocusNode(); - _ModalState _state = _ModalState.input; + late _ModalState _state; String? _errorText; + int? _discountPercent; + double? _discountAmountRub; + String? _appliedCode; + + @override + void initState() { + super.initState(); + final initial = widget.initialPromo; + if (initial != null) { + _state = _ModalState.applied; + _appliedCode = initial.promoCode; + _discountPercent = initial.discountPercent; + _discountAmountRub = initial.discountAmountRub; + _controller.text = initial.promoCode; + } else { + _state = _ModalState.input; + } + } @override void dispose() { @@ -98,7 +158,62 @@ class _ItvioPromoCodeModalState extends State { return; } - Navigator.of(context).pop(PromoCodeResult(code: code)); + Navigator.of(context).pop( + PromoCodeResult( + code: code, + discountPercent: validation.discountPercent, + discountAmountRub: validation.discountAmountRub, + originalAmountRub: validation.originalAmountRub, + finalAmountRub: validation.finalAmountRub, + ), + ); + } + + void _onContinueApplied() { + Navigator.of(context).pop( + PromoCodeResult( + code: _appliedCode, + discountPercent: _discountPercent, + discountAmountRub: _discountAmountRub, + alreadyApplied: true, + ), + ); + } + + Future _onRemove() async { + setState(() { + _state = _ModalState.loading; + _errorText = null; + }); + + var success = true; + try { + if (widget.onRemove != null) { + success = await widget.onRemove!(); + } + } catch (_) { + success = false; + } + + if (!mounted) return; + + if (!success) { + setState(() { + _state = _ModalState.applied; + _errorText = S.of(context).promo_remove_failed; + }); + return; + } + + setState(() { + _state = _ModalState.input; + _appliedCode = null; + _discountPercent = null; + _discountAmountRub = null; + _errorText = null; + _controller.clear(); + }); + _focusNode.requestFocus(); } void _onSkip() { @@ -107,6 +222,10 @@ class _ItvioPromoCodeModalState extends State { bool get _isLoading => _state == _ModalState.loading; + /// Applied UI while loading remove (state=loading, but promo still shown). + bool get _isApplied => + _appliedCode != null && _state != _ModalState.input; + @override Widget build(BuildContext context) { final bottomInset = MediaQuery.of(context).viewInsets.bottom; @@ -127,40 +246,51 @@ class _ItvioPromoCodeModalState extends State { children: [ const Center(child: BottomSheetHandlerBar()), const SizedBox(height: 16), - _buildHeader(), + _buildHeader(s), + if (_isApplied) ...[ + const SizedBox(height: 6), + Text( + s.promo_applied_subtitle, + style: const TextStyle(fontSize: 14, color: Color(0xFF6B7684)), + ), + ], const SizedBox(height: 16), - const Text( - 'Промокод', - style: TextStyle( + Text( + s.promo_code, + style: const TextStyle( fontSize: 13, color: Color(0xFF6B7684), ), ), const SizedBox(height: 8), - _buildField(), + _buildField(s), const SizedBox(height: 8), - _buildHint(), + _buildHint(s), const SizedBox(height: 12), - _buildApplyButton(s), - const SizedBox(height: 6), - _buildSkipButton(s), + if (_isApplied) ...[ + _buildContinueButton(s), + const SizedBox(height: 6), + _buildRemoveButton(s), + ] else ...[ + _buildApplyButton(s), + const SizedBox(height: 6), + _buildSkipButton(s), + ], ], ), ), ); } - Widget _buildHeader() { - final s = S.of(context); + Widget _buildHeader(S s) { return Text( - s.have_promo_code_form, + _isApplied ? s.promo_applied_title : s.have_promo_code_form, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), ); } - Widget _buildField() { - final hasError = _errorText != null; - final s = S.of(context); + Widget _buildField(S s) { + final hasError = _errorText != null && !_isApplied; return AnimatedContainer( duration: const Duration(milliseconds: 150), @@ -171,7 +301,7 @@ class _ItvioPromoCodeModalState extends State { width: 2, color: hasError ? appTheme.red - : (_focusNode.hasFocus + : (_focusNode.hasFocus && !_isApplied ? appTheme.blueA400 : Colors.transparent), ), @@ -182,14 +312,16 @@ class _ItvioPromoCodeModalState extends State { child: TextField( controller: _controller, focusNode: _focusNode, - enabled: !_isLoading, + enabled: !_isLoading && !_isApplied, textCapitalization: TextCapitalization.characters, onChanged: (_) { if (_errorText != null) { setState(() => _errorText = null); } }, - onSubmitted: (_) => _apply(), + onSubmitted: (_) { + if (!_isApplied) _apply(); + }, decoration: InputDecoration( hintText: s.enter_promo_code, hintStyle: const TextStyle( @@ -202,9 +334,11 @@ class _ItvioPromoCodeModalState extends State { vertical: 14, ), ), - style: const TextStyle( + style: TextStyle( fontSize: 15, - color: Color(0xFF24303F), + color: _isApplied + ? const Color(0xFF6B7684) + : const Color(0xFF24303F), ), ), ), @@ -213,13 +347,17 @@ class _ItvioPromoCodeModalState extends State { ); } - Widget _buildHint() { + Widget _buildHint(S s) { final String text; final Color color; if (_errorText != null) { text = _errorText!; color = appTheme.red; + } else if (_isApplied && _discountPercent != null) { + final amount = (_discountAmountRub ?? 0).toStringAsFixed(0); + text = s.promo_discount_hint(_discountPercent!, amount); + color = const Color(0xFF27A06B); } else { text = ''; color = Colors.transparent; @@ -264,6 +402,50 @@ class _ItvioPromoCodeModalState extends State { ); } + Widget _buildContinueButton(S s) { + return SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _onContinueApplied, + style: FilledButton.styleFrom( + backgroundColor: appTheme.blueA400, + foregroundColor: appTheme.whiteA700, + disabledBackgroundColor: appTheme.blueA400.withValues(alpha: 0.55), + shape: const StadiumBorder(), + textStyle: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + child: Text(s.continue_), + ), + ); + } + + Widget _buildRemoveButton(S s) { + return SizedBox( + height: 46, + child: TextButton( + onPressed: _isLoading ? null : _onRemove, + style: TextButton.styleFrom( + foregroundColor: appTheme.blueA400, + shape: const StadiumBorder(), + textStyle: const TextStyle(fontSize: 14), + ), + child: _isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: appTheme.blueA400, + ), + ) + : Text(s.promo_remove), + ), + ); + } + Widget _buildSkipButton(S s) { return SizedBox( height: 46, diff --git a/lib/features/payment/repositories/payment_repository.dart b/lib/features/payment/repositories/payment_repository.dart index 1d590e48..00d2ca75 100644 --- a/lib/features/payment/repositories/payment_repository.dart +++ b/lib/features/payment/repositories/payment_repository.dart @@ -1,5 +1,11 @@ +import 'package:dio/dio.dart'; import 'package:eatvio_app/features/chat/models/payment_model.dart'; import 'package:eatvio_app/features/chat/services/chat_service.dart'; +import 'package:eatvio_app/features/payment/models/promo_applied_list_response.dart'; +import 'package:eatvio_app/features/payment/models/promo_apply_response.dart'; +import 'package:eatvio_app/features/payment/models/promo_code_model.dart'; +import 'package:eatvio_app/features/payment/models/promo_remove_response.dart'; +import 'package:eatvio_app/features/payment/models/promo_validate_response.dart'; /// Репозиторий оплаты консультаций (chosen_plan) через Alfa Bank / СБП. class PaymentRepository { @@ -15,4 +21,67 @@ class PaymentRepository { body: const {'payment_provider': 'alfa-bank'}, ); } + + /// Список applied-промокодов текущего пользователя. + Future listAppliedPromoCodes() { + return chatService.listAppliedPromoCodes(); + } + + /// Applied-промокод для конкретного chosen_plan, либо null (нет / ошибка сети). + Future findAppliedPromoForPlan(int planId) async { + try { + final response = await listAppliedPromoCodes(); + for (final promo in response.promoCodes) { + if (promo.chosenPlanId == planId && promo.state == 'applied') { + return promo; + } + } + return null; + } catch (_) { + return null; + } + } + + /// Preview-валидация промокода без применения. + Future validatePromoCode({ + required int planId, + required String promoCode, + }) { + return chatService.validatePromoCode( + id: planId, + body: {'promo_code': promoCode}, + ); + } + + /// Применяет промокод к chosen_plan. При HTTP 422 возвращает тело ответа. + Future applyPromoCode({ + required int planId, + required String promoCode, + }) async { + try { + return await chatService.applyPromoCode( + id: planId, + body: {'promo_code': promoCode}, + ); + } on DioException catch (e) { + final data = e.response?.data; + if (data is Map) { + return PromoApplyResponse.fromJson(data); + } + rethrow; + } + } + + /// Удаляет применённый промокод (idempotent). + Future removePromoCode({required int planId}) async { + try { + return await chatService.removePromoCode(id: planId); + } on DioException catch (e) { + final data = e.response?.data; + if (data is Map) { + return PromoRemoveResponse.fromJson(data); + } + rethrow; + } + } } diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index e563a193..81d5acf0 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -27,24 +27,27 @@ class MessageLookup extends MessageLookupByLibrary { static String m2(planName, name) => "План «${planName}» с пользователем ${name} начался"; - static String m3(step) => "шаг ${step} из 2"; + static String m3(percent, amount) => + "Discount ${percent}% — you save ${amount} ₽"; - static String m4(planName, name) => + static String m4(step) => "шаг ${step} из 2"; + + static String m5(planName, name) => "Завтра начинается план «${planName}» с пользователем ${name}"; - static String m5(planName) => + static String m6(planName) => "Завтра начнется прохождение плана «${planName}»"; - static String m6(name, planName) => + static String m7(name, planName) => "Пользователь ${name} отменил подписку на план «${planName}»"; - static String m7(name, planName) => + static String m8(name, planName) => "Пользователь ${name} оплатил план «${planName}»"; - static String m8(name, planName) => + static String m9(name, planName) => "Пользователь ${name} подписался на план «${planName}»"; - static String m9(planName) => "Вы отменили подписку на план «${planName}»"; + static String m10(planName) => "Вы отменили подписку на план «${planName}»"; final messages = _notInlinedMessages(_notInlinedMessages); static Map _notInlinedMessages(_) => { @@ -715,10 +718,21 @@ class MessageLookup extends MessageLookupByLibrary { "Разница в ценах, это комиссия Итвио", ), "profile": MessageLookupByLibrary.simpleMessage("Профиль"), + "promo_applied_subtitle": MessageLookupByLibrary.simpleMessage( + "The discount will be applied at checkout", + ), + "promo_applied_title": MessageLookupByLibrary.simpleMessage( + "Promo code applied!", + ), "promo_code": MessageLookupByLibrary.simpleMessage("Promo code"), + "promo_discount_hint": m3, "promo_invalid": MessageLookupByLibrary.simpleMessage( "Promo code not found or expired", ), + "promo_remove": MessageLookupByLibrary.simpleMessage("Remove promo code"), + "promo_remove_failed": MessageLookupByLibrary.simpleMessage( + "Could not remove the promo code. Try again later", + ), "prostitution": MessageLookupByLibrary.simpleMessage("Проституция"), "proteins": MessageLookupByLibrary.simpleMessage("Белки"), "public_diet_state": MessageLookupByLibrary.simpleMessage("Публичная"), @@ -809,7 +823,7 @@ class MessageLookup extends MessageLookupByLibrary { "step_by_step_photo_recipe": MessageLookupByLibrary.simpleMessage( "Пошаговый фоторецепт", ), - "step_widget": m3, + "step_widget": m4, "successfully_published": MessageLookupByLibrary.simpleMessage( "успешно опубликован", ), @@ -834,8 +848,8 @@ class MessageLookup extends MessageLookupByLibrary { "toOffer": MessageLookupByLibrary.simpleMessage("Предложить"), "to_draft": MessageLookupByLibrary.simpleMessage("В черновик"), "to_order": MessageLookupByLibrary.simpleMessage("Заказать"), - "tommorowPlanWithUserWillStart": m4, - "tomorrowYourPlanWillStart": m5, + "tommorowPlanWithUserWillStart": m5, + "tomorrowYourPlanWillStart": m6, "try_again": MessageLookupByLibrary.simpleMessage("Попробовать снова"), "try_again_2": MessageLookupByLibrary.simpleMessage("Попробуйте ещё раз"), "typeAmoutOfReturn": MessageLookupByLibrary.simpleMessage( @@ -869,7 +883,7 @@ class MessageLookup extends MessageLookupByLibrary { "Верхние показатели давления:", ), "upper": MessageLookupByLibrary.simpleMessage("Верхнее"), - "userCanceldThePlanSubscription": m6, + "userCanceldThePlanSubscription": m7, "userConfirmedPlanCompletion": MessageLookupByLibrary.simpleMessage( "Пользователь подтвердил прохождения плана. В ближайшее время Вы получите", ), @@ -877,8 +891,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage( "Пользователь подтвердил прохождения плана.", ), - "userPaidThePlan": m7, - "userSubscribedToPlan": m8, + "userPaidThePlan": m8, + "userSubscribedToPlan": m9, "user_authorization": MessageLookupByLibrary.simpleMessage( "Аутентификация", ), @@ -1008,7 +1022,7 @@ class MessageLookup extends MessageLookupByLibrary { "youCanComplain": MessageLookupByLibrary.simpleMessage( "Если вас что-то не устроило, вы можете подать жалобу", ), - "youCanceledSubscriptionToPlan": m9, + "youCanceledSubscriptionToPlan": m10, "youShureYouWantToCancelOrder": MessageLookupByLibrary.simpleMessage( "Вы уверены, что хотите отменить заказ?", ), diff --git a/lib/generated/intl/messages_ru.dart b/lib/generated/intl/messages_ru.dart index 5783d1f0..a1c9f2c3 100644 --- a/lib/generated/intl/messages_ru.dart +++ b/lib/generated/intl/messages_ru.dart @@ -20,6 +20,9 @@ typedef String MessageIfAbsent(String messageStr, List args); class MessageLookup extends MessageLookupByLibrary { String get localeName => 'ru'; + static String m3(percent, amount) => + "Скидка ${percent}% — вы экономите ${amount} ₽"; + final messages = _notInlinedMessages(_notInlinedMessages); static Map _notInlinedMessages(_) => { "Moscow": MessageLookupByLibrary.simpleMessage("Москва"), @@ -322,10 +325,21 @@ class MessageLookup extends MessageLookupByLibrary { "Разница в ценах, это комиссия Итвио", ), "profile": MessageLookupByLibrary.simpleMessage("Профиль"), + "promo_applied_subtitle": MessageLookupByLibrary.simpleMessage( + "Скидка учтётся при оплате", + ), + "promo_applied_title": MessageLookupByLibrary.simpleMessage( + "Промокод применён!", + ), "promo_code": MessageLookupByLibrary.simpleMessage("Промокод"), + "promo_discount_hint": m3, "promo_invalid": MessageLookupByLibrary.simpleMessage( "Промокод не найден или истёк", ), + "promo_remove": MessageLookupByLibrary.simpleMessage("Убрать промокод"), + "promo_remove_failed": MessageLookupByLibrary.simpleMessage( + "Не удалось убрать промокод. Попробуйте позже", + ), "proteins": MessageLookupByLibrary.simpleMessage("Белки"), "public_diet_state": MessageLookupByLibrary.simpleMessage("Публичная"), "publish": MessageLookupByLibrary.simpleMessage("Опубликовать"), diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 6076d4b0..d5c739d2 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -4264,6 +4264,56 @@ class S { ); } + /// `Promo code applied!` + String get promo_applied_title { + return Intl.message( + 'Promo code applied!', + name: 'promo_applied_title', + desc: '', + args: [], + ); + } + + /// `The discount will be applied at checkout` + String get promo_applied_subtitle { + return Intl.message( + 'The discount will be applied at checkout', + name: 'promo_applied_subtitle', + desc: '', + args: [], + ); + } + + /// `Remove promo code` + String get promo_remove { + return Intl.message( + 'Remove promo code', + name: 'promo_remove', + desc: '', + args: [], + ); + } + + /// `Discount {percent}% — you save {amount} ₽` + String promo_discount_hint(int percent, String amount) { + return Intl.message( + 'Discount $percent% — you save $amount ₽', + name: 'promo_discount_hint', + desc: '', + args: [percent, amount], + ); + } + + /// `Could not remove the promo code. Try again later` + String get promo_remove_failed { + return Intl.message( + 'Could not remove the promo code. Try again later', + name: 'promo_remove_failed', + desc: '', + args: [], + ); + } + /// `Loading…` String get video_call_screen_loading_title { return Intl.message( diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index d884d273..2c971d99 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -502,6 +502,17 @@ "promo_code": "Promo code", "enter_promo_code": "Enter promo code", "promo_invalid": "Promo code not found or expired", + "promo_applied_title": "Promo code applied!", + "promo_applied_subtitle": "The discount will be applied at checkout", + "promo_remove": "Remove promo code", + "promo_discount_hint": "Discount {percent}% — you save {amount} ₽", + "@promo_discount_hint": { + "placeholders": { + "percent": {"type": "int"}, + "amount": {"type": "String"} + } + }, + "promo_remove_failed": "Could not remove the promo code. Try again later", "video_call_screen_loading_title": "Loading…", "video_call_screen_connecting_title": "Connecting to\nthe consultation", "video_call_screen_connecting_subtitle": "This may take a few seconds", diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index 1a6bf995..738bac08 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -246,6 +246,17 @@ "promo_code": "Промокод", "enter_promo_code": "Введите промокод", "promo_invalid": "Промокод не найден или истёк", + "promo_applied_title": "Промокод применён!", + "promo_applied_subtitle": "Скидка учтётся при оплате", + "promo_remove": "Убрать промокод", + "promo_discount_hint": "Скидка {percent}% — вы экономите {amount} ₽", + "@promo_discount_hint": { + "placeholders": { + "percent": {"type": "int"}, + "amount": {"type": "String"} + } + }, + "promo_remove_failed": "Не удалось убрать промокод. Попробуйте позже", "video_call_permission_microphone_description": "Чтобы вас слышали во время видеозвонка, разрешите доступ к микрофону. При отказе можно включить доступ в настройках устройства.", "video_call_screen_loading_title": "Загрузка…", "video_call_screen_connecting_title": "Подключение\nк консультации", diff --git a/test/features/payment/consultation_payment_flow_test.dart b/test/features/payment/consultation_payment_flow_test.dart index f0544e6d..8b532853 100644 --- a/test/features/payment/consultation_payment_flow_test.dart +++ b/test/features/payment/consultation_payment_flow_test.dart @@ -1,6 +1,9 @@ -import 'package:eatvio_app/generated/l10n.dart'; +import 'package:eatvio_app/features/payment/models/promo_apply_response.dart'; +import 'package:eatvio_app/features/payment/models/promo_code_model.dart'; +import 'package:eatvio_app/features/payment/models/promo_validate_response.dart'; import 'package:eatvio_app/features/payment/presentation/payment_flow.dart'; import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_modal.dart'; +import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -26,8 +29,17 @@ void main() { late bool? gateResult; late bool formCalled; late PromoCodeResult? formResult; + late PromoCodeModel? formInitialPromo; late bool sbpCalled; late int sbpPlanId; + late int validateCalls; + late int applyCalls; + late String? lastValidatedCode; + late String? lastAppliedCode; + late PromoValidateResponse validateResponse; + late PromoApplyResponse applyResponse; + late bool applyThrows; + late PromoCodeModel? listAppliedResult; Future mockGate(BuildContext context) async { gateCalled = true; @@ -37,8 +49,27 @@ void main() { Future mockForm( BuildContext context, { Future Function(String)? onValidate, + Future Function()? onRemove, + PromoCodeModel? initialPromo, }) async { formCalled = true; + formInitialPromo = initialPromo; + + if (formResult?.hasPromoCode == true && + !formResult!.alreadyApplied && + onValidate != null) { + final validation = await onValidate(formResult!.code!); + if (!validation.isValid) { + return null; + } + return PromoCodeResult( + code: formResult!.code, + discountPercent: validation.discountPercent, + discountAmountRub: validation.discountAmountRub, + originalAmountRub: validation.originalAmountRub, + finalAmountRub: validation.finalAmountRub, + ); + } return formResult; } @@ -47,31 +78,86 @@ void main() { sbpPlanId = planId; } + Future mockValidate({ + required int planId, + required String promoCode, + }) async { + validateCalls++; + lastValidatedCode = promoCode; + return validateResponse; + } + + Future mockApply({ + required int planId, + required String promoCode, + }) async { + applyCalls++; + lastAppliedCode = promoCode; + if (applyThrows) { + throw Exception('network'); + } + return applyResponse; + } + + Future mockListApplied(int planId) async { + return listAppliedResult; + } + + Future mockRemove(int planId) async { + return true; + } + setUp(() { gateCalled = false; gateResult = null; formCalled = false; formResult = null; + formInitialPromo = null; sbpCalled = false; sbpPlanId = 0; + validateCalls = 0; + applyCalls = 0; + lastValidatedCode = null; + lastAppliedCode = null; + listAppliedResult = null; + validateResponse = const PromoValidateResponse( + valid: true, + promoCode: 'TEST123', + discountPercent: 5, + discountAmountRub: 60, + originalAmountRub: 1200, + finalAmountRub: 1140, + ); + applyResponse = const PromoApplyResponse(success: true); + applyThrows = false; }); - testWidgets('Нет → вызывает SbpBanks (не форму)', (tester) async { - gateResult = false; - await tester.pumpWidget(createTestApp()); - - await showConsultationPaymentFlow( + Future runFlow(WidgetTester tester) { + return showConsultationPaymentFlow( tester.element(find.byType(SizedBox)), planId: 42, showGate: mockGate, showForm: mockForm, showSbp: mockSbp, + validatePromo: mockValidate, + applyPromo: mockApply, + listApplied: mockListApplied, + removePromo: mockRemove, ); + } + + testWidgets('Нет → вызывает SbpBanks (не форму)', (tester) async { + gateResult = false; + await tester.pumpWidget(createTestApp()); + + await runFlow(tester); expect(gateCalled, isTrue); expect(formCalled, isFalse); expect(sbpCalled, isTrue); expect(sbpPlanId, 42); + expect(validateCalls, 0); + expect(applyCalls, 0); }); testWidgets('Да → форма → Пропустить → вызывает SbpBanks', (tester) async { @@ -79,18 +165,13 @@ void main() { formResult = const PromoCodeResult(); await tester.pumpWidget(createTestApp()); - await showConsultationPaymentFlow( - tester.element(find.byType(SizedBox)), - planId: 42, - showGate: mockGate, - showForm: mockForm, - showSbp: mockSbp, - ); + await runFlow(tester); expect(gateCalled, isTrue); expect(formCalled, isTrue); expect(sbpCalled, isTrue); expect(sbpPlanId, 42); + expect(applyCalls, 0); }); testWidgets('Да → форма отменена (null) → Sbp не вызывается', (tester) async { @@ -98,13 +179,7 @@ void main() { formResult = null; await tester.pumpWidget(createTestApp()); - await showConsultationPaymentFlow( - tester.element(find.byType(SizedBox)), - planId: 42, - showGate: mockGate, - showForm: mockForm, - showSbp: mockSbp, - ); + await runFlow(tester); expect(gateCalled, isTrue); expect(formCalled, isTrue); @@ -115,17 +190,121 @@ void main() { gateResult = null; await tester.pumpWidget(createTestApp()); - await showConsultationPaymentFlow( - tester.element(find.byType(SizedBox)), - planId: 42, - showGate: mockGate, - showForm: mockForm, - showSbp: mockSbp, - ); + await runFlow(tester); expect(gateCalled, isTrue); expect(formCalled, isFalse); expect(sbpCalled, isFalse); }); + + testWidgets('Да → код → validate + apply → SbpBanks', (tester) async { + gateResult = true; + formResult = const PromoCodeResult(code: 'TEST123'); + await tester.pumpWidget(createTestApp()); + + await runFlow(tester); + + expect(validateCalls, 1); + expect(lastValidatedCode, 'TEST123'); + expect(applyCalls, 1); + expect(lastAppliedCode, 'TEST123'); + expect(sbpCalled, isTrue); + expect(sbpPlanId, 42); + }); + + testWidgets('Да → код → apply fail → Sbp не вызывается', (tester) async { + gateResult = true; + formResult = const PromoCodeResult(code: 'BAD'); + applyResponse = const PromoApplyResponse( + success: false, + error: 'Промокод уже использован', + ); + await tester.pumpWidget(createTestApp()); + + await runFlow(tester); + + expect(applyCalls, 1); + expect(sbpCalled, isFalse); + }); + + testWidgets('applied rehydrate → Continue → SBP без apply и без гейта', (tester) async { + listAppliedResult = const PromoCodeModel( + id: 1, + chosenPlanId: 42, + promoCode: 'SAVE5', + discountPercent: 5, + discountAmountRub: 60, + state: 'applied', + ); + formResult = const PromoCodeResult( + code: 'SAVE5', + discountPercent: 5, + discountAmountRub: 60, + alreadyApplied: true, + ); + await tester.pumpWidget(createTestApp()); + + await runFlow(tester); + + expect(gateCalled, isFalse); + expect(formCalled, isTrue); + expect(formInitialPromo?.promoCode, 'SAVE5'); + expect(applyCalls, 0); + expect(sbpCalled, isTrue); + }); + + testWidgets('list пустой → идёт через гейт', (tester) async { + listAppliedResult = null; + gateResult = false; + await tester.pumpWidget(createTestApp()); + + await runFlow(tester); + + expect(gateCalled, isTrue); + expect(formCalled, isFalse); + expect(sbpCalled, isTrue); + }); + + testWidgets('rehydrate → Remove → новый код → apply → SBP', (tester) async { + listAppliedResult = const PromoCodeModel( + id: 1, + chosenPlanId: 42, + promoCode: 'OLD', + discountPercent: 5, + discountAmountRub: 60, + state: 'applied', + ); + formResult = const PromoCodeResult(code: 'NEW123'); + await tester.pumpWidget(createTestApp()); + + await runFlow(tester); + + expect(gateCalled, isFalse); + expect(formCalled, isTrue); + expect(validateCalls, 1); + expect(applyCalls, 1); + expect(lastAppliedCode, 'NEW123'); + expect(sbpCalled, isTrue); + }); + + testWidgets('rehydrate → Remove → Skip → SBP без apply', (tester) async { + listAppliedResult = const PromoCodeModel( + id: 1, + chosenPlanId: 42, + promoCode: 'OLD', + discountPercent: 5, + discountAmountRub: 60, + state: 'applied', + ); + formResult = const PromoCodeResult(); + await tester.pumpWidget(createTestApp()); + + await runFlow(tester); + + expect(gateCalled, isFalse); + expect(formCalled, isTrue); + expect(applyCalls, 0); + expect(sbpCalled, isTrue); + }); }); } diff --git a/test/features/payment/models/promo_response_models_test.dart b/test/features/payment/models/promo_response_models_test.dart new file mode 100644 index 00000000..d9c65468 --- /dev/null +++ b/test/features/payment/models/promo_response_models_test.dart @@ -0,0 +1,108 @@ +import 'package:eatvio_app/features/payment/models/promo_applied_list_response.dart'; +import 'package:eatvio_app/features/payment/models/promo_apply_response.dart'; +import 'package:eatvio_app/features/payment/models/promo_code_model.dart'; +import 'package:eatvio_app/features/payment/models/promo_validate_response.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('PromoValidateResponse', () { + test('parses valid preview', () { + final response = PromoValidateResponse.fromJson({ + 'valid': true, + 'promo_code': 'PROMO_100117_A', + 'discount_percent': 5, + 'discount_amount_rub': 60.0, + 'original_amount_rub': 1200.0, + 'final_amount_rub': 1140.0, + }); + + expect(response.valid, isTrue); + expect(response.promoCode, 'PROMO_100117_A'); + expect(response.discountPercent, 5); + expect(response.finalAmountRub, 1140.0); + expect(response.error, isNull); + }); + + test('parses invalid preview with HTTP 200 body', () { + final response = PromoValidateResponse.fromJson({ + 'valid': false, + 'error': 'Промокод «UNKNOWN» не найден в системе.', + }); + + expect(response.valid, isFalse); + expect(response.error, contains('не найден')); + }); + }); + + group('PromoApplyResponse', () { + test('parses success with nested promo_code', () { + final response = PromoApplyResponse.fromJson({ + 'success': true, + 'promo_code': { + 'id': 1, + 'chosen_plan_id': 42, + 'promo_code': 'PROMO_100117_A', + 'discount_percent': 5, + 'discount_amount_rub': 60.0, + 'state': 'applied', + 'applied_at': '2024-01-15T10:30:00+00:00', + 'confirmed_at': null, + 'cancelled_at': null, + }, + 'discount_percent': 5, + 'discount_amount_rub': 60.0, + 'original_amount_rub': 1200.0, + 'final_amount_rub': 1140.0, + }); + + expect(response.success, isTrue); + expect(response.promoCode, isA()); + expect(response.promoCode!.promoCode, 'PROMO_100117_A'); + expect(response.promoCode!.state, 'applied'); + expect(response.finalAmountRub, 1140.0); + }); + + test('parses failure body', () { + final response = PromoApplyResponse.fromJson({ + 'success': false, + 'error': 'Промокод «X» уже был использован вами ранее.', + }); + + expect(response.success, isFalse); + expect(response.error, contains('уже был использован')); + expect(response.promoCode, isNull); + }); + }); + + group('PromoAppliedListResponse', () { + test('parses promo_codes list', () { + final response = PromoAppliedListResponse.fromJson({ + 'promo_codes': [ + { + 'id': 2, + 'chosen_plan_id': 42, + 'promo_code': 'PROMO_LIST_B', + 'discount_percent': 3, + 'discount_amount_rub': 36.0, + 'state': 'applied', + 'applied_at': '2024-01-15T10:30:00+00:00', + 'confirmed_at': null, + 'cancelled_at': null, + }, + ], + }); + + expect(response.promoCodes, hasLength(1)); + expect(response.promoCodes.first.promoCode, 'PROMO_LIST_B'); + expect(response.promoCodes.first.chosenPlanId, 42); + }); + + test('parses empty list', () { + final response = PromoAppliedListResponse.fromJson({ + 'promo_codes': [], + }); + + expect(response.promoCodes, isEmpty); + }); + }); +} diff --git a/test/features/payment/presentation/widgets/promo_code_modal_test.dart b/test/features/payment/presentation/widgets/promo_code_modal_test.dart index e1b2e923..56d10633 100644 --- a/test/features/payment/presentation/widgets/promo_code_modal_test.dart +++ b/test/features/payment/presentation/widgets/promo_code_modal_test.dart @@ -1,4 +1,5 @@ import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/payment/models/promo_code_model.dart'; import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_modal.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; @@ -11,6 +12,8 @@ void main() { group('ItvioPromoCodeModal', () { Widget createTestWidget({ Future Function(String)? onValidate, + Future Function()? onRemove, + PromoCodeModel? initialPromo, }) { return MaterialApp( theme: ThemeHelper().themeData(), @@ -26,6 +29,8 @@ void main() { onPressed: () => ItvioPromoCodeModal.show( context, onValidate: onValidate, + onRemove: onRemove, + initialPromo: initialPromo, ), child: const Text('Open'), ), @@ -88,14 +93,84 @@ void main() { expect(find.text('Есть промокод?'), findsNothing); }); - testWidgets('отсутствует UI applied-состояния (Промокод применён)', (tester) async { - await tester.pumpWidget(createTestWidget()); + testWidgets('initialPromo показывает applied UI', (tester) async { + await tester.pumpWidget( + createTestWidget( + initialPromo: const PromoCodeModel( + id: 1, + chosenPlanId: 42, + promoCode: 'SAVE5', + discountPercent: 5, + discountAmountRub: 60, + state: 'applied', + ), + onRemove: () async => true, + ), + ); await tester.tap(find.text('Open')); await tester.pumpAndSettle(); + expect(find.text('Промокод применён!'), findsOneWidget); + expect(find.text('Продолжить'), findsOneWidget); + expect(find.text('Убрать промокод'), findsOneWidget); + expect(find.text('Применить'), findsNothing); + }); + + testWidgets('Убрать промокод переводит в input', (tester) async { + var removeCalled = false; + await tester.pumpWidget( + createTestWidget( + initialPromo: const PromoCodeModel( + id: 1, + chosenPlanId: 42, + promoCode: 'SAVE5', + discountPercent: 5, + discountAmountRub: 60, + state: 'applied', + ), + onRemove: () async { + removeCalled = true; + return true; + }, + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Убрать промокод')); + await tester.pumpAndSettle(); + + expect(removeCalled, isTrue); + expect(find.text('Есть промокод?'), findsOneWidget); + expect(find.text('Применить'), findsOneWidget); + expect(find.text('Пропустить'), findsOneWidget); expect(find.text('Промокод применён!'), findsNothing); - expect(find.text('Продолжить'), findsNothing); - expect(find.text('Убрать промокод'), findsNothing); + }); + + testWidgets('Применить с успешным onValidate закрывает шторку', (tester) async { + String? validatedCode; + await tester.pumpWidget( + createTestWidget( + onValidate: (code) async { + validatedCode = code; + return const PromoValidation.success( + discountPercent: 5, + discountAmountRub: 60, + originalAmountRub: 1200, + finalAmountRub: 1140, + ); + }, + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'save5'); + await tester.tap(find.text('Применить')); + await tester.pumpAndSettle(); + + expect(validatedCode, 'SAVE5'); + expect(find.text('Есть промокод?'), findsNothing); }); }); } -- 2.49.1 From 3210f38d4a157c638b874dde839f5a76efbd7c6e Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 4 Aug 2026 14:22:33 +0300 Subject: [PATCH 73/88] Version changed --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index ae4effdd..21b16284 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.36+125 +version: 1.3.37+126 publish_to: 'none' environment: -- 2.49.1 From 04de95676984c6344b5e1116a1a5eef77f55eaeb Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 4 Aug 2026 17:47:43 +0300 Subject: [PATCH 74/88] feat(post_creation): added multiple image posts creation and validation --- lib/core/widgets/custom_popup.dart | 19 +- .../blog_post_create_cubit.dart | 168 ++++- .../blog_post_create_state.dart | 24 +- .../pages/blog_post_create_screen.dart | 695 +++++++++++++----- .../repositories/blog_post_repository.dart | 149 ++-- .../services/blog_post_service.dart | 18 +- .../validation/blog_post_form_validator.dart | 158 ++++ .../bloc/profile_cubit/profile_cubit.dart | 20 + .../presentation/pages/profile_screen.dart | 7 +- .../widgets/profile_blog_post_widget.dart | 1 + .../advanced_widget_test.dart | 253 +++++-- .../blog_post_create/basic_widget_test.dart | 347 ++++++--- .../blog_post_create_cubit_test.dart | 255 ++++++- .../integration_widget_test.dart | 422 +++++++---- .../pages/blog_post_create_screen_test.dart | 655 +++++++++++++---- .../blog_post_repository_test.dart | 308 ++++---- .../services/blog_post_service_test.dart | 32 +- .../blog_post_form_validator_test.dart | 191 +++++ .../blog_post_create/create_request_full.json | 3 +- .../blog_post_create/update_request.json | 5 +- test/fixtures/doubles/blog_post_doubles.dart | 43 +- 21 files changed, 2791 insertions(+), 982 deletions(-) create mode 100644 lib/features/blog_post_create/validation/blog_post_form_validator.dart create mode 100644 test/features/blog_post_create/validation/blog_post_form_validator_test.dart diff --git a/lib/core/widgets/custom_popup.dart b/lib/core/widgets/custom_popup.dart index dbe0090b..51f6f4ff 100644 --- a/lib/core/widgets/custom_popup.dart +++ b/lib/core/widgets/custom_popup.dart @@ -6,6 +6,7 @@ import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:eatvio_app/core/widgets/show_error_dialog.dart'; import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart'; import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart'; +import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; import 'package:eatvio_app/features/feed/models/recipes_model.dart'; @@ -13,9 +14,11 @@ import 'package:eatvio_app/features/feed/presentation/bloc/diets_cubit/diets_cub import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart'; import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart'; import 'package:eatvio_app/features/my_plans/presentation/cubit/my_plans_cubit.dart'; +import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.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_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; import 'package:share_plus/share_plus.dart'; @@ -30,6 +33,7 @@ class CustomPopup extends StatelessWidget { final Recipe? recipe; final Diet? diet; final String userSlug; + final VoidCallback? onPostsChanged; const CustomPopup({ super.key, @@ -41,7 +45,8 @@ class CustomPopup extends StatelessWidget { this.feedBlogPostModel, this.recipe, this.diet, - required this.userSlug + required this.userSlug, + this.onPostsChanged, }); @override @@ -103,7 +108,8 @@ class CustomPopup extends StatelessWidget { if (complainedContentType == ComplainedContent.posts) { addItem('assets/icons/edit_paint.svg', 'Редактировать', () async { if (complainedContentType == ComplainedContent.posts) { - GoRouter.of(context).pushNamed('blogPostCreateScreen', extra: feedBlogPostModel); + await GoRouter.of(context).pushNamed('blogPostCreateScreen', extra: feedBlogPostModel); + onPostsChanged?.call(); } }); } @@ -114,7 +120,14 @@ class CustomPopup extends StatelessWidget { showErrorDialog(context, title: S.current.could_not_delete_blog_post); return; } - showSuccessToast('Пост удален, обновите ленту'); + onPostsChanged?.call(); + ProfileCubit.myProfileCubit?.reloadBlogPosts(); + if (context.mounted) { + try { + context.read().loadFeedBlogPostList(); + } catch (_) {} + } + showSuccessToast('Пост удален'); } else if (complainedContentType == ComplainedContent.recipe) { final result = await sl().deleteRecipe(slug!); if (!result && context.mounted) { diff --git a/lib/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart b/lib/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart index 5d04bdaf..33a584ac 100644 --- a/lib/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart +++ b/lib/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart @@ -3,38 +3,184 @@ import 'dart:developer'; import 'package:eatvio_app/core/error_reporting/report_from.dart'; import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_state.dart'; import 'package:eatvio_app/features/blog_post_create/repositories/blog_post_repository.dart'; +import 'package:eatvio_app/features/blog_post_create/validation/blog_post_form_validator.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_image_compress/flutter_image_compress.dart'; class BlogPostCreateCubit extends Cubit { final BlogPostRepository blogPostRepository; - BlogPostCreateCubit({required this.blogPostRepository}) : super(const BlogPostCreateState()); + BlogPostCreateCubit({required this.blogPostRepository}) + : super(const BlogPostCreateState()); + + void clearTitleError() { + if (state.titleError != null || state.formError != null) { + emit(state.copyWith(titleError: null, formError: null)); + } + } + + void clearDescriptionError() { + if (state.descriptionError != null || state.formError != null) { + emit(state.copyWith(descriptionError: null, formError: null)); + } + } + + void clearVideoUrlError() { + if (state.videoUrlError != null || state.formError != null) { + emit(state.copyWith(videoUrlError: null, formError: null)); + } + } + + void clearImagesError() { + if (state.imagesError != null || state.formError != null) { + emit(state.copyWith(imagesError: null, formError: null)); + } + } + + void resetFormErrors() { + if (!state.hasValidationErrors && + !state.isSuccess && + state.errorMessage == null && + !state.isLoading) { + return; + } + emit(const BlogPostCreateState()); + } + + /// Validates form fields and emits error messages on [BlogPostCreateState]. + /// Returns `true` when the form is valid. + bool validateForm({ + required String title, + required String description, + required String videoUrl, + required int imageCount, + Iterable imagePaths = const [], + }) { + final titleError = BlogPostFormValidator.title(title); + final descriptionError = BlogPostFormValidator.description(description); + final videoUrlError = BlogPostFormValidator.videoUrl(videoUrl); + final imagesError = + BlogPostFormValidator.galleryCount(imageCount) ?? + BlogPostFormValidator.imagePaths(imagePaths); + final formError = BlogPostFormValidator.contentRequiredMessage( + title: title, + description: description, + videoUrl: videoUrl, + imageCount: imageCount, + ); + + final hasFieldErrors = + titleError != null || + descriptionError != null || + videoUrlError != null || + imagesError != null; + final resolvedFormError = hasFieldErrors ? null : formError; + + emit( + state.copyWith( + titleError: titleError, + descriptionError: descriptionError, + videoUrlError: videoUrlError, + imagesError: imagesError, + // Form-level message only when individual fields are fine but content is empty. + formError: resolvedFormError, + isSuccess: false, + errorMessage: null, + ), + ); + + return titleError == null && + descriptionError == null && + videoUrlError == null && + imagesError == null && + resolvedFormError == null; + } + + Future createBlogPost({ + String? title, + String? videoUrl, + String? description, + List images = const [], + }) async { + final trimmedTitle = title?.trim() ?? ''; + final trimmedDescription = description?.trim() ?? ''; + final trimmedVideoUrl = videoUrl?.trim() ?? ''; + + final isValid = validateForm( + title: trimmedTitle, + description: trimmedDescription, + videoUrl: trimmedVideoUrl, + imageCount: images.length, + imagePaths: images.map((image) => image.path), + ); + if (!isValid) { + return false; + } - Future createBlogPost({String? title, String? videoUrl, String? description, XFile? image}) async { try { - emit(const BlogPostCreateState.loading()); - await blogPostRepository.createBlogPost(title, videoUrl, description, image); - emit(const BlogPostCreateState.loaded()); + emit( + state.copyWith(isLoading: true, isSuccess: false, errorMessage: null), + ); + await blogPostRepository.createBlogPost( + title: trimmedTitle.isEmpty ? null : trimmedTitle, + videoUrl: trimmedVideoUrl.isEmpty ? null : trimmedVideoUrl, + description: trimmedDescription.isEmpty ? null : trimmedDescription, + images: images, + ); + emit(const BlogPostCreateState(isSuccess: true)); return true; } catch (error, stackTrace) { log('BlogPostCreateCubit: $error', stackTrace: stackTrace); await reportFrom(error, stackTrace, this); - emit(BlogPostCreateState.error(errorMessage: error.toString())); + emit(BlogPostCreateState(errorMessage: error.toString())); return false; } } - Future updateBlogPost({required int id, String? title, String? videoUrl, String? description, XFile? image, String? oldImageUrl}) async { + Future updateBlogPost({ + required int id, + String? title, + String? videoUrl, + String? description, + List existingMediaIds = const [], + List newImages = const [], + List mediaIdsToDelete = const [], + }) async { + final trimmedTitle = title?.trim() ?? ''; + final trimmedDescription = description?.trim() ?? ''; + final trimmedVideoUrl = videoUrl?.trim() ?? ''; + final imageCount = existingMediaIds.length + newImages.length; + + final isValid = validateForm( + title: trimmedTitle, + description: trimmedDescription, + videoUrl: trimmedVideoUrl, + imageCount: imageCount, + imagePaths: newImages.map((image) => image.path), + ); + if (!isValid) { + return false; + } + try { - emit(const BlogPostCreateState.loading()); - await blogPostRepository.updateBlogPost(id, title, videoUrl, description, image, oldImageUrl); - emit(const BlogPostCreateState.loaded()); + emit( + state.copyWith(isLoading: true, isSuccess: false, errorMessage: null), + ); + await blogPostRepository.updateBlogPost( + id: id, + title: trimmedTitle, + videoUrl: trimmedVideoUrl, + description: trimmedDescription, + existingMediaIds: existingMediaIds, + newImages: newImages, + mediaIdsToDelete: mediaIdsToDelete, + ); + emit(const BlogPostCreateState(isSuccess: true)); return true; } catch (error, stackTrace) { log('BlogPostCreateCubit: $error', stackTrace: stackTrace); await reportFrom(error, stackTrace, this); - emit(BlogPostCreateState.error(errorMessage: error.toString())); + emit(BlogPostCreateState(errorMessage: error.toString())); return false; } } diff --git a/lib/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_state.dart b/lib/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_state.dart index 05919499..d4445fbf 100644 --- a/lib/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_state.dart +++ b/lib/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_state.dart @@ -4,13 +4,23 @@ part 'blog_post_create_state.freezed.dart'; @freezed abstract class BlogPostCreateState with _$BlogPostCreateState { - const factory BlogPostCreateState() = _BlogPostCreateState; + const BlogPostCreateState._(); - const factory BlogPostCreateState.loading() = BlogPostCreateStateLoading; + const factory BlogPostCreateState({ + @Default(false) bool isLoading, + @Default(false) bool isSuccess, + String? errorMessage, + String? titleError, + String? descriptionError, + String? videoUrlError, + String? imagesError, + String? formError, + }) = _BlogPostCreateState; - const factory BlogPostCreateState.loaded() = BlogPostCreateStateLoaded; - - const factory BlogPostCreateState.error({ - required String errorMessage, - }) = BlogPostCreateStateError; + bool get hasValidationErrors => + titleError != null || + descriptionError != null || + videoUrlError != null || + imagesError != null || + formError != null; } diff --git a/lib/features/blog_post_create/presentation/pages/blog_post_create_screen.dart b/lib/features/blog_post_create/presentation/pages/blog_post_create_screen.dart index 6da09b9e..b3c9b42c 100644 --- a/lib/features/blog_post_create/presentation/pages/blog_post_create_screen.dart +++ b/lib/features/blog_post_create/presentation/pages/blog_post_create_screen.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:io'; + 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/permission_handler/permission_handler.dart'; import 'package:eatvio_app/core/theme/app_decoration.dart'; @@ -12,8 +12,13 @@ import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/widgets/custom_text_form_field.dart'; +import 'package:eatvio_app/features/blog_post_create/repositories/blog_post_repository.dart'; +import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart'; import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; +import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart'; +import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; +import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -36,20 +41,31 @@ class _BlogPostCreateScreenState extends State { final blogPostCubit = sl(); bool shrinkImage = false; late StreamSubscription keyboardSubscription; - XFile? image; + final List savedImages = []; + final List pendingImages = []; + final List mediaIdsToDelete = []; final titleController = TextEditingController(); final descriptionController = TextEditingController(); final videoUrlController = TextEditingController(); - bool isLoading = false; bool enableMdPanel = false; bool isPreviewMode = false; final FocusNode descriptionFocusNode = FocusNode(); + bool get _isEditMode => widget.feedBlogPostModel != null; + + int get _galleryCount => savedImages.length + pendingImages.length; + + int get _remainingSlots => + BlogPostRepository.maxGalleryImages - _galleryCount; + @override void initState() { super.initState(); + blogPostCubit.resetFormErrors(); var keyboardVisibilityController = KeyboardVisibilityController(); - keyboardSubscription = keyboardVisibilityController.onChange.listen((bool visible) { + keyboardSubscription = keyboardVisibilityController.onChange.listen(( + bool visible, + ) { setState(() { shrinkImage = visible; enableMdPanel = visible; @@ -59,6 +75,9 @@ class _BlogPostCreateScreenState extends State { titleController.text = widget.feedBlogPostModel!.title; descriptionController.text = widget.feedBlogPostModel!.description; videoUrlController.text = widget.feedBlogPostModel!.videoUrl; + savedImages.addAll( + widget.feedBlogPostModel!.images.where((image) => image.id != null), + ); } } @@ -66,6 +85,9 @@ class _BlogPostCreateScreenState extends State { void dispose() { keyboardSubscription.cancel(); descriptionFocusNode.dispose(); + titleController.dispose(); + descriptionController.dispose(); + videoUrlController.dispose(); super.dispose(); } @@ -78,9 +100,16 @@ class _BlogPostCreateScreenState extends State { return selectedText.startsWith(prefix) && selectedText.endsWith(suffix); } else { final cursorPosition = selection.baseOffset; - if (cursorPosition >= prefix.length && text.length >= cursorPosition + suffix.length) { - final beforeCursor = text.substring(cursorPosition - prefix.length, cursorPosition); - final afterCursor = text.substring(cursorPosition, cursorPosition + suffix.length); + if (cursorPosition >= prefix.length && + text.length >= cursorPosition + suffix.length) { + final beforeCursor = text.substring( + cursorPosition - prefix.length, + cursorPosition, + ); + final afterCursor = text.substring( + cursorPosition, + cursorPosition + suffix.length, + ); return beforeCursor == prefix && afterCursor == suffix; } } @@ -93,23 +122,36 @@ class _BlogPostCreateScreenState extends State { if (selection.isValid && !selection.isCollapsed) { final selectedText = text.substring(selection.start, selection.end); - final unformattedText = selectedText.substring(prefix.length, selectedText.length - suffix.length); + final unformattedText = selectedText.substring( + prefix.length, + selectedText.length - suffix.length, + ); - final newText = text.replaceRange(selection.start, selection.end, unformattedText); + final newText = text.replaceRange( + selection.start, + selection.end, + unformattedText, + ); descriptionController.text = newText; - final newSelection = TextSelection(baseOffset: selection.start, extentOffset: selection.start + unformattedText.length); + final newSelection = TextSelection( + baseOffset: selection.start, + extentOffset: selection.start + unformattedText.length, + ); descriptionController.selection = newSelection; } else { final cursorPosition = selection.baseOffset; - if (cursorPosition >= prefix.length && text.length >= cursorPosition + suffix.length) { + if (cursorPosition >= prefix.length && + text.length >= cursorPosition + suffix.length) { final beforeCursor = text.substring(0, cursorPosition - prefix.length); final afterCursor = text.substring(cursorPosition + suffix.length); final newText = beforeCursor + afterCursor; descriptionController.text = newText; final newCursorPosition = cursorPosition - prefix.length; - descriptionController.selection = TextSelection.collapsed(offset: newCursorPosition); + descriptionController.selection = TextSelection.collapsed( + offset: newCursorPosition, + ); } } } @@ -122,18 +164,28 @@ class _BlogPostCreateScreenState extends State { final selectedText = text.substring(selection.start, selection.end); final formattedText = '$prefix$selectedText$suffix'; - final newText = text.replaceRange(selection.start, selection.end, formattedText); + final newText = text.replaceRange( + selection.start, + selection.end, + formattedText, + ); descriptionController.text = newText; - final newSelection = TextSelection(baseOffset: selection.start, extentOffset: selection.start + formattedText.length); + final newSelection = TextSelection( + baseOffset: selection.start, + extentOffset: selection.start + formattedText.length, + ); descriptionController.selection = newSelection; } else { final cursorPosition = selection.baseOffset; - final newText = '${text.substring(0, cursorPosition)}$prefix$suffix${text.substring(cursorPosition)}'; + final newText = + '${text.substring(0, cursorPosition)}$prefix$suffix${text.substring(cursorPosition)}'; descriptionController.text = newText; final newCursorPosition = cursorPosition + prefix.length; - descriptionController.selection = TextSelection.collapsed(offset: newCursorPosition); + descriptionController.selection = TextSelection.collapsed( + offset: newCursorPosition, + ); } } @@ -188,17 +240,31 @@ class _BlogPostCreateScreenState extends State { if (linkRegex.hasMatch(selectedText)) { final match = linkRegex.firstMatch(selectedText); final linkText = match?.group(1) ?? selectedText; - final newText = text.replaceRange(selection.start, selection.end, linkText); + final newText = text.replaceRange( + selection.start, + selection.end, + linkText, + ); descriptionController.text = newText; - final newSelection = TextSelection(baseOffset: selection.start, extentOffset: selection.start + linkText.length); + final newSelection = TextSelection( + baseOffset: selection.start, + extentOffset: selection.start + linkText.length, + ); descriptionController.selection = newSelection; } else { final formattedText = '[$selectedText](url)'; - final newText = text.replaceRange(selection.start, selection.end, formattedText); + final newText = text.replaceRange( + selection.start, + selection.end, + formattedText, + ); descriptionController.text = newText; - final newSelection = TextSelection(baseOffset: selection.start + selectedText.length + 2, extentOffset: selection.start + selectedText.length + 5); + final newSelection = TextSelection( + baseOffset: selection.start + selectedText.length + 2, + extentOffset: selection.start + selectedText.length + 5, + ); descriptionController.selection = newSelection; } } else { @@ -209,11 +275,14 @@ class _BlogPostCreateScreenState extends State { void _applyHorizontalRule() { final cursorPosition = descriptionController.selection.baseOffset; final text = descriptionController.text; - final newText = '${text.substring(0, cursorPosition)}\n---\n${text.substring(cursorPosition)}'; + final newText = + '${text.substring(0, cursorPosition)}\n---\n${text.substring(cursorPosition)}'; descriptionController.text = newText; final newCursorPosition = cursorPosition + 5; - descriptionController.selection = TextSelection.collapsed(offset: newCursorPosition); + descriptionController.selection = TextSelection.collapsed( + offset: newCursorPosition, + ); } void _togglePreview() { @@ -240,7 +309,9 @@ class _BlogPostCreateScreenState extends State { borderRadius: BorderRadius.circular(20), border: Border.all(color: const Color(0xFFE0E0E0)), ), - child: SingleChildScrollView(child: _parseMarkdown(descriptionController.text)), + child: SingleChildScrollView( + child: _parseMarkdown(descriptionController.text), + ), ); } @@ -248,7 +319,11 @@ class _BlogPostCreateScreenState extends State { if (text.isEmpty) { return const Text( 'Описание', - style: TextStyle(fontSize: 16, color: Color(0xFF323F4B), fontWeight: FontWeight.w500), + style: TextStyle( + fontSize: 16, + color: Color(0xFF323F4B), + fontWeight: FontWeight.w500, + ), ); } @@ -267,21 +342,33 @@ class _BlogPostCreateScreenState extends State { widgets.add( Text( line.substring(2), - style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.black), + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.black, + ), ), ); } else if (line.startsWith('## ')) { widgets.add( Text( line.substring(3), - style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.black), + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.black, + ), ), ); } else if (line.startsWith('### ')) { widgets.add( Text( line.substring(4), - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.black, + ), ), ); } else if (line.startsWith('- ') || line.startsWith('* ')) { @@ -307,7 +394,11 @@ class _BlogPostCreateScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('$number. ', style: const TextStyle(fontSize: 16)), - Expanded(child: _parseInlineMarkdown(line.substring(number.length + 2))), + Expanded( + child: _parseInlineMarkdown( + line.substring(number.length + 2), + ), + ), ], ), ), @@ -323,7 +414,10 @@ class _BlogPostCreateScreenState extends State { } } - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: widgets); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: widgets, + ); } Widget _parseInlineMarkdown(String text) { @@ -347,14 +441,22 @@ class _BlogPostCreateScreenState extends State { spans.add( TextSpan( text: matchedText.substring(2, matchedText.length - 2), - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.black), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.black, + ), ), ); } else if (matchedText.startsWith('*') && matchedText.endsWith('*')) { spans.add( TextSpan( text: matchedText.substring(1, matchedText.length - 1), - style: const TextStyle(fontSize: 16, fontStyle: FontStyle.italic, color: Colors.black), + style: const TextStyle( + fontSize: 16, + fontStyle: FontStyle.italic, + color: Colors.black, + ), ), ); } else if (matchedText.startsWith('[') && matchedText.contains('](')) { @@ -364,7 +466,11 @@ class _BlogPostCreateScreenState extends State { spans.add( TextSpan( text: linkText, - style: const TextStyle(fontSize: 16, color: Colors.blue, decoration: TextDecoration.underline), + style: const TextStyle( + fontSize: 16, + color: Colors.blue, + decoration: TextDecoration.underline, + ), ), ); } @@ -390,9 +496,11 @@ class _BlogPostCreateScreenState extends State { return BlocProvider( create: (BuildContext context) => blogPostCubit, child: BlocListener( + bloc: blogPostCubit, listener: (BuildContext context, BlogPostCreateState state) { - if (state is BlogPostCreateStateLoaded) { + if (state.isSuccess) { context.read().loadFeedBlogPostList(); + ProfileCubit.myProfileCubit?.reloadBlogPosts(); } }, child: _buildBlogPostCreate(context), @@ -401,6 +509,10 @@ class _BlogPostCreateScreenState extends State { } void _showImagePicker() { + if (_remainingSlots <= 0) { + return; + } + showCupertinoModalPopup( context: context, builder: (context) { @@ -415,14 +527,27 @@ class _BlogPostCreateScreenState extends State { description: s.allowAccessToCameraDescription, onGranted: () async { final picker = ImagePicker(); - image = await picker.pickImage(source: ImageSource.camera); - setState(() {}); + final image = await picker.pickImage( + source: ImageSource.camera, + ); + if (image != null && _remainingSlots > 0) { + setState(() { + pendingImages.add(image); + }); + blogPostCubit.clearImagesError(); + } AppRouter.navigatorKey.currentContext?.pop(); }, context: context, ); }, - child: const Row(children: [Icon(Icons.photo_camera), SizedBox(width: 8), Text('Фото')]), + child: const Row( + children: [ + Icon(Icons.photo_camera), + SizedBox(width: 8), + Text('Фото'), + ], + ), ), CupertinoActionSheetAction( onPressed: () async { @@ -432,30 +557,88 @@ class _BlogPostCreateScreenState extends State { description: s.allowAcessToGaleryDescription, onGranted: () async { final picker = ImagePicker(); - image = await picker.pickImage(source: ImageSource.gallery); - setState(() {}); + final images = await picker.pickMultiImage( + limit: _remainingSlots, + ); + if (images.isNotEmpty) { + setState(() { + pendingImages.addAll(images.take(_remainingSlots)); + }); + blogPostCubit.clearImagesError(); + } if (!context.mounted) return; AppRouter.navigatorKey.currentContext?.pop(); }, context: context, ); }, - child: const Row(children: [Icon(Icons.photo), SizedBox(width: 8), Text('Галерея')]), + child: const Row( + children: [ + Icon(Icons.photo), + SizedBox(width: 8), + Text('Галерея'), + ], + ), ), ], - cancelButton: CupertinoActionSheetAction(onPressed: () => Navigator.of(context).pop(), isDestructiveAction: true, child: const Text('Отмена')), + cancelButton: CupertinoActionSheetAction( + onPressed: () => Navigator.of(context).pop(), + isDestructiveAction: true, + child: const Text('Отмена'), + ), ); }, ); } + void _removeSavedImage(int index) { + final image = savedImages[index]; + final mediaId = image.id; + setState(() { + savedImages.removeAt(index); + if (mediaId != null) { + mediaIdsToDelete.add(mediaId); + } + }); + } + + void _removePendingImage(int index) { + setState(() { + pendingImages.removeAt(index); + }); + } + + void _onReorderGallery(int oldIndex, int newIndex) { + setState(() { + if (newIndex > oldIndex) { + newIndex -= 1; + } + + if (oldIndex < savedImages.length && newIndex < savedImages.length) { + final item = savedImages.removeAt(oldIndex); + savedImages.insert(newIndex, item); + return; + } + + final pendingOld = oldIndex - savedImages.length; + final pendingNew = newIndex - savedImages.length; + if (pendingOld >= 0 && pendingNew >= 0) { + final item = pendingImages.removeAt(pendingOld); + pendingImages.insert(pendingNew, item); + } + }); + } + Widget _mdButton(IconData icon, Function() onTap) { return Expanded( child: GestureDetector( onTap: onTap, child: Container( height: 36, - decoration: BoxDecoration(color: const Color(0xFFF1F2F6), borderRadius: BorderRadius.circular(10)), + decoration: BoxDecoration( + color: const Color(0xFFF1F2F6), + borderRadius: BorderRadius.circular(10), + ), child: Icon(icon), ), ), @@ -468,11 +651,18 @@ class _BlogPostCreateScreenState extends State { onTap: onTap, child: Container( height: 36, - decoration: BoxDecoration(color: const Color(0xFFF1F2F6), borderRadius: BorderRadius.circular(10)), + decoration: BoxDecoration( + color: const Color(0xFFF1F2F6), + borderRadius: BorderRadius.circular(10), + ), child: Center( child: Text( text, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w900, color: Colors.black87), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w900, + color: Colors.black87, + ), ), ), ), @@ -496,7 +686,12 @@ class _BlogPostCreateScreenState extends State { color: Colors.white, borderRadius: BorderRadius.circular(20), boxShadow: [ - BoxShadow(color: const Color(0x00000000).withValues(alpha: 0.2), spreadRadius: 0, blurRadius: 32, offset: const Offset(0, 0)), + BoxShadow( + color: const Color(0x00000000).withValues(alpha: 0.2), + spreadRadius: 0, + blurRadius: 32, + offset: const Offset(0, 0), + ), ], ), child: Row( @@ -508,9 +703,15 @@ class _BlogPostCreateScreenState extends State { const SizedBox(width: 8), _mdButton(Icons.format_italic, _applyItalic), const SizedBox(width: 8), - _mdButton(Icons.format_list_numbered, _applyNumberedList), + _mdButton( + Icons.format_list_numbered, + _applyNumberedList, + ), const SizedBox(width: 8), - _mdButton(Icons.format_list_bulleted, _applyBulletedList), + _mdButton( + Icons.format_list_bulleted, + _applyBulletedList, + ), const SizedBox(width: 8), _mdButton(Icons.link, _applyLink), const SizedBox(width: 8), @@ -528,157 +729,297 @@ class _BlogPostCreateScreenState extends State { decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(20), - boxShadow: [BoxShadow(color: const Color(0x00000000).withValues(alpha: 0.2), spreadRadius: 0, blurRadius: 32, offset: const Offset(0, 0))], + boxShadow: [ + BoxShadow( + color: const Color(0x00000000).withValues(alpha: 0.2), + spreadRadius: 0, + blurRadius: 32, + offset: const Offset(0, 0), + ), + ], + ), + child: Row( + children: [ + _mdButton( + isPreviewMode ? Icons.edit : Icons.visibility, + _togglePreview, + ), + ], ), - child: Row(children: [_mdButton(isPreviewMode ? Icons.edit : Icons.visibility, _togglePreview)]), ), ], ) : null, appBar: AppBar(), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16), + body: BlocBuilder( + bloc: blogPostCubit, + builder: (context, state) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + const Text('Заголовок'), + const SizedBox(height: 8), + CustomTextFormField( + key: AppKeys.blogPostCreateScreenTitleTextField, + controller: titleController, + hasError: state.titleError != null, + onChanged: (_) => blogPostCubit.clearTitleError(), + ), + if (state.titleError != null) ...[ + const SizedBox(height: 4), + ValidationErrorWidget(error: state.titleError!), + ], + const SizedBox(height: 16), + const Text('Описание'), + const SizedBox(height: 8), + isPreviewMode + ? _buildMarkdownPreview() + : CustomTextFormField( + key: AppKeys.blogPostCreateScreenContentTextField, + controller: descriptionController, + focusNode: descriptionFocusNode, + maxLines: 6, + minLines: 4, + hasError: state.descriptionError != null, + onChanged: (_) => blogPostCubit.clearDescriptionError(), + ), + if (state.descriptionError != null) ...[ + const SizedBox(height: 4), + ValidationErrorWidget(error: state.descriptionError!), + ], + const SizedBox(height: 16), + Text( + 'Изображения ($_galleryCount/${BlogPostRepository.maxGalleryImages})', + ), + const SizedBox(height: 8), + _buildGallery(), + if (state.imagesError != null) ...[ + const SizedBox(height: 4), + ValidationErrorWidget(error: state.imagesError!), + ], + const SizedBox(height: 16), + const Text('Ссылка на видео'), + const SizedBox(height: 4), + Text( + 'Rutube, VK или OK', + style: TextStyle(fontSize: 13, color: appTheme.blueGray300), + ), + const SizedBox(height: 8), + CustomTextFormField( + key: AppKeys.blogPostCreateScreenVideoLinkTextField, + controller: videoUrlController, + hasError: state.videoUrlError != null, + keyboardType: TextInputType.url, + onChanged: (_) => blogPostCubit.clearVideoUrlError(), + ), + if (state.videoUrlError != null) ...[ + const SizedBox(height: 4), + ValidationErrorWidget(error: state.videoUrlError!), + ], + if (state.formError != null) ...[ + const SizedBox(height: 16), + ValidationErrorWidget(error: state.formError!), + ], + const SizedBox(height: 80), + _createBlogPostButton(isSubmitting: state.isLoading), + const SizedBox(height: 80), + ], + ), + ); + }, + ), + ); + } + + Future _submitBlogPost() async { + if (blogPostCubit.state.isLoading) { + return; + } + + final title = titleController.text.trim(); + final description = descriptionController.text.trim(); + final videoUrl = videoUrlController.text.trim(); + final existingMediaIds = savedImages.map((image) => image.id!).toList(); + + final bool result; + if (_isEditMode) { + result = await blogPostCubit.updateBlogPost( + id: widget.feedBlogPostModel!.id, + title: title, + description: description, + videoUrl: videoUrl, + existingMediaIds: existingMediaIds, + newImages: pendingImages, + mediaIdsToDelete: List.from(mediaIdsToDelete), + ); + } else { + result = await blogPostCubit.createBlogPost( + title: title, + description: description, + videoUrl: videoUrl, + images: pendingImages, + ); + } + + if (!result) { + if (!mounted) return; + if (blogPostCubit.state.hasValidationErrors) { + return; + } + showErrorDialog( + context, + title: _isEditMode + ? S.current.could_not_update_blog_post + : S.current.could_not_publish_blog_post, + ); + } else { + if (!mounted) return; + if (context.canPop()) { + context.pop(); + } + } + } + + Widget _createBlogPostButton({required bool isSubmitting}) { + return InkWell( + key: AppKeys.blogPostCreateScreenCreateButton, + onTap: isSubmitting ? null : _submitBlogPost, + borderRadius: BorderRadius.circular(16), + child: isSubmitting + ? const Center(child: Loader()) + : Ink( + width: MediaQuery.of(context).size.width - 32, + height: 52, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: appTheme.blueA400, + ), + child: Center( + child: Text( + _isEditMode ? 'Сохранить' : 'Создать', + style: const TextStyle(color: Colors.white), + ), + ), + ), + ); + } + + Widget _buildGallery() { + final itemHeight = shrinkImage ? 72.0 : 112.0; + + return SizedBox( + height: itemHeight, + child: ReorderableListView.builder( + scrollDirection: Axis.horizontal, + buildDefaultDragHandles: false, + onReorder: _onReorderGallery, + itemCount: _galleryCount + (_remainingSlots > 0 ? 1 : 0), + itemBuilder: (context, index) { + if (index == _galleryCount) { + return KeyedSubtree( + key: const ValueKey('add-photo'), + child: _buildAddPhotoTile(itemHeight), + ); + } + + if (index < savedImages.length) { + final image = savedImages[index]; + final url = PostFeedMapper.resolveMediaUrl(image.url); + return KeyedSubtree( + key: ValueKey('saved-${image.id}'), + child: _buildGalleryTile( + index: index, + height: itemHeight, + imageProvider: NetworkImage(url), + onRemove: () => _removeSavedImage(index), + ), + ); + } + + final pendingIndex = index - savedImages.length; + final pending = pendingImages[pendingIndex]; + return KeyedSubtree( + key: ValueKey('pending-${pending.path}'), + child: _buildGalleryTile( + index: index, + height: itemHeight, + imageProvider: FileImage(File(pending.path)), + onRemove: () => _removePendingImage(pendingIndex), + ), + ); + }, + ), + ); + } + + Widget _buildAddPhotoTile(double height) { + return GestureDetector( + onTap: _showImagePicker, + child: Container( + width: height, + height: height, + margin: const EdgeInsets.only(right: 8), + decoration: AppDecoration.fillGray.copyWith( + borderRadius: BorderRadius.circular(16), + ), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, children: [ - const SizedBox(height: 16), - const Text('Заголовок'), - const SizedBox(height: 8), - CustomTextFormField(key: AppKeys.blogPostCreateScreenTitleTextField, controller: titleController), - const SizedBox(height: 16), - const Text('Описание'), - const SizedBox(height: 8), - isPreviewMode - ? _buildMarkdownPreview() - : CustomTextFormField( - key: AppKeys.blogPostCreateScreenContentTextField, - controller: descriptionController, - focusNode: descriptionFocusNode, - maxLines: 6, - minLines: 4, - ), - const SizedBox(height: 16), - if (videoUrlController.text.isEmpty) const Text('Изображение поста'), - if (videoUrlController.text.isEmpty) const SizedBox(height: 8), - if (videoUrlController.text.isEmpty) _buildAddPhoto(), - const SizedBox(height: 16), - if (image == null && widget.feedBlogPostModel?.image == null) const Text('Ссылка на видео'), - if (image == null && widget.feedBlogPostModel?.image == null) const SizedBox(height: 8), - if (image == null && widget.feedBlogPostModel?.image == null) - CustomTextFormField(key: AppKeys.blogPostCreateScreenVideoLinkTextField, controller: videoUrlController), - const SizedBox(height: 80), - _createBlogPostButton(), - const SizedBox(height: 80), + Icon(Icons.photo_library, color: appTheme.blueGray300), + const SizedBox(height: 4), + Text( + 'Добавить', + style: TextStyle(fontSize: 12, color: appTheme.blueGray300), + ), ], ), ), ); } - Widget _createBlogPostButton() { - return InkWell( - onTap: isLoading - ? null - : () async { - setState(() { - isLoading = true; - }); - if (widget.feedBlogPostModel != null) { - final result = await blogPostCubit.updateBlogPost( - id: widget.feedBlogPostModel!.id, - title: titleController.text, - description: descriptionController.text, - videoUrl: videoUrlController.text, - image: image, - oldImageUrl: widget.feedBlogPostModel!.image, - ); - setState(() { - isLoading = false; - }); - if (!result) { - if (!mounted) return; - showErrorDialog(context, title: S.current.could_not_update_blog_post); - } else { - if (!mounted) return; - if (context.canPop()) { - context.pop(); - } - } - } else { - final result = await blogPostCubit.createBlogPost( - title: titleController.text, - description: descriptionController.text, - videoUrl: videoUrlController.text, - image: image, - ); - setState(() { - isLoading = false; - }); - if (!result) { - if (!mounted) return; - showErrorDialog(context, title: S.current.could_not_publish_blog_post); - } else { - if (!mounted) return; - if (context.canPop()) { - context.pop(); - } - } - } - }, - borderRadius: BorderRadius.circular(16), - child: isLoading - ? const Center(child: Loader()) - : Ink( - width: MediaQuery.of(context).size.width - 32, - height: 52, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(16), color: appTheme.blueA400), - child: const Center( - child: Text('Создать', style: TextStyle(color: Colors.white)), - ), - ), - ); - } - - GestureDetector _buildAddPhoto() { - return GestureDetector( - onTap: () { - _showImagePicker(); - }, - child: Stack( - children: [ - if (image == null && widget.feedBlogPostModel?.image == null) - AnimatedContainer( - height: shrinkImage ? 52 : 128, - curve: Curves.easeIn, - duration: const Duration(milliseconds: 150), - decoration: AppDecoration.fillGray.copyWith(borderRadius: BorderRadius.circular(16)), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon(Icons.photo_library, color: appTheme.blueGray300), - const SizedBox(width: 8), - Text("Добавить фото", selectionColor: appTheme.blueA400), - ], - ), - ), - if (image != null) - SizedBox( - width: shrinkImage ? 52 : MediaQuery.of(context).size.width - 32, + Widget _buildGalleryTile({ + required int index, + required double height, + required ImageProvider imageProvider, + required VoidCallback onRemove, + }) { + return ReorderableDelayedDragStartListener( + index: index, + child: Container( + width: height, + height: height, + margin: const EdgeInsets.only(right: 8), + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( child: ClipRRect( borderRadius: BorderRadius.circular(16), - child: Image(image: FileImage(File(image!.path))), + child: Image(image: imageProvider, fit: BoxFit.cover), ), ), - if (widget.feedBlogPostModel?.image != null && image == null) - SizedBox( - width: shrinkImage ? 52 : MediaQuery.of(context).size.width - 32, - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: Image(image: NetworkImage('${sl().baseUrl}/storage/${widget.feedBlogPostModel!.image}')), + Positioned( + top: -6, + right: -6, + child: Material( + color: Colors.white, + shape: const CircleBorder(), + elevation: 1, + child: InkWell( + customBorder: const CircleBorder(), + onTap: onRemove, + child: const Padding( + padding: EdgeInsets.all(4), + child: Icon(Icons.close, size: 16), + ), + ), ), ), - ], + ], + ), ), ); } diff --git a/lib/features/blog_post_create/repositories/blog_post_repository.dart b/lib/features/blog_post_create/repositories/blog_post_repository.dart index 337854dc..6cd544a6 100644 --- a/lib/features/blog_post_create/repositories/blog_post_repository.dart +++ b/lib/features/blog_post_create/repositories/blog_post_repository.dart @@ -1,79 +1,122 @@ import 'dart:io'; import 'package:dio/dio.dart'; -import 'package:eatvio_app/core/app_config.dart'; -import 'package:eatvio_app/core/locator_service.dart'; -import 'package:http/http.dart' as http; import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart'; import 'package:eatvio_app/features/blog_post_create/services/blog_post_service.dart'; import 'package:flutter_image_compress/flutter_image_compress.dart'; -import 'package:path_provider/path_provider.dart'; class BlogPostRepository { + static const maxGalleryImages = 10; + final BlogPostService blogPostService; - BlogPostRepository({ - required this.blogPostService, - }); + BlogPostRepository({required this.blogPostService}); - Future createBlogPost( + Future createBlogPost({ String? title, String? videoUrl, String? description, - XFile? image, - ) async { - final formData = FormData.fromMap({ - if (title != null && title.isNotEmpty) 'title': title, - if (description != null && description.isNotEmpty) 'description_md': description, - if (videoUrl != null && videoUrl.isNotEmpty) 'video_url': videoUrl, - if (image != null) 'image': await MultipartFile.fromFile((await ImageCompressUtility.compressFile(File(image.path))).path, filename: image.name), - }); - - return blogPostService.createBlogPost( - formData: formData, + List images = const [], + }) async { + final formData = await _buildFormData( + title: title, + videoUrl: videoUrl, + description: description, + newImages: images, ); + + return blogPostService.createBlogPost(formData: formData); } - Future downloadImage(String imageUrl) async { - final response = await http.get(Uri.parse('${sl().baseUrl}/storage/$imageUrl')); - - if (response.statusCode == 200) { - final directory = await getTemporaryDirectory(); - final filePath = '${directory.path}/image.jpg'; - final file = File(filePath); - await file.writeAsBytes(response.bodyBytes); - - return XFile(file.path); + Future updateBlogPost({ + required int id, + String? title, + String? videoUrl, + String? description, + List existingMediaIds = const [], + List newImages = const [], + List mediaIdsToDelete = const [], + }) async { + if (mediaIdsToDelete.isNotEmpty) { + await Future.wait( + mediaIdsToDelete.map( + (mediaId) => + blogPostService.deleteBlogPostMedia(postId: id, mediaId: mediaId), + ), + ); } - return null; + + final formData = await _buildFormData( + title: title ?? '', + videoUrl: videoUrl ?? '', + description: description ?? '', + existingMediaIds: existingMediaIds, + newImages: newImages, + includeExistingMediaIds: true, + includeEmptyTextFields: true, + ); + + return blogPostService.updateBlogPost(id: id, formData: formData); } - Future updateBlogPost( - int id, - String? title, - String? videoUrl, - String? description, - XFile? image, - String? oldImageUrl, - ) async { - final formData = FormData.fromMap({ - 'title': title, - 'description_md': description, - 'video_url': videoUrl, - 'image': image != null - ? await MultipartFile.fromFile((await ImageCompressUtility.compressFile(File(image.path))).path, filename: image.name) - : oldImageUrl != '' && oldImageUrl != null - ? await MultipartFile.fromFile((await downloadImage(oldImageUrl))?.path ?? '', filename: oldImageUrl.split('/').last) - : null, - }); - - return blogPostService.updateBlogPost( - id: id, - formData: formData, - ); + Future deleteBlogPostMedia({ + required int postId, + required int mediaId, + }) async { + await blogPostService.deleteBlogPostMedia(postId: postId, mediaId: mediaId); } Future deleteBlogPost(int id) async { await blogPostService.deleteBlogPost(id: id); } + + Future _buildFormData({ + String? title, + String? videoUrl, + String? description, + List existingMediaIds = const [], + List newImages = const [], + bool includeExistingMediaIds = false, + bool includeEmptyTextFields = false, + }) async { + final formData = FormData(); + + if (includeEmptyTextFields) { + formData.fields.add(MapEntry('title', title ?? '')); + formData.fields.add(MapEntry('description_md', description ?? '')); + formData.fields.add(MapEntry('video_url', videoUrl ?? '')); + } else { + if (title != null && title.isNotEmpty) { + formData.fields.add(MapEntry('title', title)); + } + if (description != null && description.isNotEmpty) { + formData.fields.add(MapEntry('description_md', description)); + } + if (videoUrl != null && videoUrl.isNotEmpty) { + formData.fields.add(MapEntry('video_url', videoUrl)); + } + } + + if (includeExistingMediaIds) { + for (final mediaId in existingMediaIds) { + formData.fields.add( + MapEntry('existing_media_ids[]', mediaId.toString()), + ); + } + } + + for (final image in newImages) { + final compressed = await ImageCompressUtility.compressFile( + File(image.path), + ); + formData.files.add( + MapEntry( + 'images[]', + await MultipartFile.fromFile(compressed.path, filename: image.name), + ), + ); + } + + return formData; + } } diff --git a/lib/features/blog_post_create/services/blog_post_service.dart b/lib/features/blog_post_create/services/blog_post_service.dart index 56fc5461..72c0be12 100644 --- a/lib/features/blog_post_create/services/blog_post_service.dart +++ b/lib/features/blog_post_create/services/blog_post_service.dart @@ -7,21 +7,23 @@ part 'blog_post_service.g.dart'; abstract class BlogPostService { factory BlogPostService(Dio dio, {String baseUrl}) = _BlogPostService; - @POST('/api/blogPosts/create/published') + @POST('/api/v2/blogPosts/create/published') @MultiPart() - Future createBlogPost({ - @Body() required FormData formData, - }); + Future createBlogPost({@Body() required FormData formData}); - @POST('/api/blogPosts/update/published/{id}') + @PUT('/api/v2/blogPosts/update/published/{id}') @MultiPart() Future updateBlogPost({ @Path('id') required int id, @Body() required FormData formData, }); - @DELETE('/api/blogPosts/delete/{id}') - Future deleteBlogPost({ - @Path('id') required int id, + @DELETE('/api/v2/blogPosts/{postId}/media/{mediaId}') + Future deleteBlogPostMedia({ + @Path('postId') required int postId, + @Path('mediaId') required int mediaId, }); + + @DELETE('/api/blogPosts/delete/{id}') + Future deleteBlogPost({@Path('id') required int id}); } diff --git a/lib/features/blog_post_create/validation/blog_post_form_validator.dart b/lib/features/blog_post_create/validation/blog_post_form_validator.dart new file mode 100644 index 00000000..bb4fbb86 --- /dev/null +++ b/lib/features/blog_post_create/validation/blog_post_form_validator.dart @@ -0,0 +1,158 @@ +/// Client-side validation aligned with backend BlogPost V2 create/update rules. +abstract final class BlogPostFormValidator { + static const minTextLength = 3; + static const maxVideoUrlLength = 2048; + static const maxGalleryImages = 10; + static const allowedImageExtensions = {'jpg', 'jpeg', 'png'}; + + static const titleTooShort = 'Заголовок должен содержать минимум 3 символа'; + static const descriptionTooShort = + 'Описание должно содержать минимум 3 символа'; + static const contentRequired = + 'Добавьте заголовок, описание, изображение или ссылку на видео'; + static const galleryTooLarge = 'Можно загрузить не более 10 изображений'; + static const imageTypeInvalid = 'Допустимы только изображения JPG или PNG'; + static const videoUrlTooLong = 'Ссылка на видео слишком длинная'; + static const videoUrlInvalid = 'Укажите корректную ссылку на видео'; + static const videoUrlUnsupportedHost = + 'Поддерживаются только Rutube, VK и OK'; + static const videoUrlRutubeFormat = 'Неверный формат ссылки Rutube'; + static const videoUrlVkFormat = 'Неверный формат ссылки VK'; + static const videoUrlOkFormat = 'Неверный формат ссылки OK'; + + /// Empty is allowed; non-empty must be at least [minTextLength] chars (after trim). + static String? title(String? value) { + final text = value?.trim() ?? ''; + if (text.isEmpty) { + return null; + } + if (text.length < minTextLength) { + return titleTooShort; + } + return null; + } + + /// Empty is allowed; non-empty must be at least [minTextLength] chars (after trim). + static String? description(String? value) { + final text = value?.trim() ?? ''; + if (text.isEmpty) { + return null; + } + if (text.length < minTextLength) { + return descriptionTooShort; + } + return null; + } + + /// Empty is allowed; non-empty must match allowed video hosts/paths. + static String? videoUrl(String? value) { + final url = value?.trim() ?? ''; + if (url.isEmpty) { + return null; + } + if (url.length > maxVideoUrlLength) { + return videoUrlTooLong; + } + + final uri = Uri.tryParse(url); + if (uri == null || !uri.hasScheme || uri.host.isEmpty) { + return videoUrlInvalid; + } + if (uri.scheme != 'http' && uri.scheme != 'https') { + return videoUrlInvalid; + } + + final host = uri.host.toLowerCase(); + if (!_hostMatchesRootAllowlist(host, const [ + 'vk.com', + 'vkvideo.ru', + 'ok.ru', + 'rutube.ru', + ])) { + return videoUrlUnsupportedHost; + } + + final path = uri.path; + if (_hostMatchesRootAllowlist(host, const ['rutube.ru'])) { + if (!_rutubePath.hasMatch(path)) { + return videoUrlRutubeFormat; + } + return null; + } + if (_hostMatchesRootAllowlist(host, const ['vk.com', 'vkvideo.ru'])) { + if (!_vkPath.hasMatch(path)) { + return videoUrlVkFormat; + } + return null; + } + if (_hostMatchesRootAllowlist(host, const ['ok.ru'])) { + if (!_okPath.hasMatch(path)) { + return videoUrlOkFormat; + } + } + return null; + } + + /// At least one of title / description / images / video must be meaningfully filled. + static String? contentRequiredMessage({ + required String title, + required String description, + required String videoUrl, + required int imageCount, + }) { + final hasTitle = title.trim().length >= minTextLength; + final hasDescription = description.trim().length >= minTextLength; + final trimmedVideo = videoUrl.trim(); + final hasVideo = + trimmedVideo.isNotEmpty && + BlogPostFormValidator.videoUrl(trimmedVideo) == null; + final hasImages = imageCount > 0; + + if (hasTitle || hasDescription || hasVideo || hasImages) { + return null; + } + return contentRequired; + } + + static String? galleryCount(int count) { + if (count > maxGalleryImages) { + return galleryTooLarge; + } + return null; + } + + /// Returns error if any path has a non-allowed extension. Empty path is ignored. + static String? imagePaths(Iterable paths) { + for (final path in paths) { + final name = path.split(RegExp(r'[\\/]')).last; + final dot = name.lastIndexOf('.'); + if (dot < 0 || dot == name.length - 1) { + return imageTypeInvalid; + } + final ext = name.substring(dot + 1).toLowerCase(); + if (!allowedImageExtensions.contains(ext)) { + return imageTypeInvalid; + } + } + return null; + } + + static final _rutubePath = RegExp( + r'^/(video|shorts)/[a-z0-9]+/?$', + caseSensitive: false, + ); + static final _vkPath = RegExp(r'^/video-?\d+_\d+/?$', caseSensitive: false); + static final _okPath = RegExp(r'^/video/\d+/?$'); + + static bool _hostMatchesRootAllowlist( + String host, + List allowedRootHosts, + ) { + for (final allowed in allowedRootHosts) { + if (host == allowed || host.endsWith('.$allowed')) { + return true; + } + } + return false; + } +} 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 db3bf384..2801c2b4 100644 --- a/lib/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart +++ b/lib/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart @@ -19,6 +19,9 @@ class ProfileCubit extends Cubit { final ConsultantRepository consultantRepository; String _currentPeriod = '1'; + /// Bottom-nav / own profile instance, used to refresh posts after create/update/delete. + static ProfileCubit? myProfileCubit; + ProfileCubit({required this.profileRepository, required this.chatRepository, required this.consultantRepository}) : super(const ProfileState.loading()); Future getProfileBySlug(String slug) async { @@ -49,6 +52,7 @@ class ProfileCubit extends Cubit { } Future getMyProfile({String period = '1'}) async { + myProfileCubit = this; emit(const ProfileState.loading()); _currentPeriod = period; try { @@ -74,6 +78,22 @@ class ProfileCubit extends Cubit { } } + /// Reloads only the blog posts list without resetting the whole profile screen. + Future reloadBlogPosts() async { + final currentState = state; + if (currentState is! ProfileStateLoaded) { + return; + } + + try { + final blogPosts = await profileRepository.getBlogPostsByAuthorSlug(slug: currentState.profile.slug); + emit(currentState.copyWith(blogPosts: blogPosts)); + } catch (e, st) { + await reportFrom(e, st, this); + log(e.toString(), stackTrace: st); + } + } + void updateFeedBlogPostList(FeedBlogPostModel feedBlogPostModel, bool wasLikedByMe) { List feedBlogPostList = [...(state as ProfileStateLoaded).blogPosts]; final int index = feedBlogPostList.indexOf(feedBlogPostModel); diff --git a/lib/features/profile/presentation/pages/profile_screen.dart b/lib/features/profile/presentation/pages/profile_screen.dart index 7d4359f8..c7dae0f5 100644 --- a/lib/features/profile/presentation/pages/profile_screen.dart +++ b/lib/features/profile/presentation/pages/profile_screen.dart @@ -436,8 +436,11 @@ class _ProfileScreenState extends State { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: InkWell( - onTap: () { - GoRouter.of(context).pushNamed('blogPostCreateScreen'); + onTap: () async { + await GoRouter.of(context).pushNamed('blogPostCreateScreen'); + if (mounted) { + await profileCubit.reloadBlogPosts(); + } }, borderRadius: BorderRadius.circular(8), child: Ink( diff --git a/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart b/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart index 9ae218cc..2f19f12a 100644 --- a/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart +++ b/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart @@ -101,6 +101,7 @@ class _ProfileBlogPostWidgetState extends State { id: post.id, feedBlogPostModel: post, userSlug: post.author.authorSlug, + onPostsChanged: widget.profileCubit.reloadBlogPosts, ), ], ), diff --git a/test/features/blog_post_create/advanced_widget_test.dart b/test/features/blog_post_create/advanced_widget_test.dart index e052fa3c..aafb9aa8 100644 --- a/test/features/blog_post_create/advanced_widget_test.dart +++ b/test/features/blog_post_create/advanced_widget_test.dart @@ -21,10 +21,18 @@ void main() { setUp(() { blogPostRepository = FixtureBlogPostRepository(); - blogPostCreateCubit = BlogPostCreateCubit(blogPostRepository: blogPostRepository); - fullCreateFixture = FixtureReader.readAsMap('blog_post_create/create_request_full.json'); - updateFixture = FixtureReader.readAsMap('blog_post_create/update_request.json'); - deleteFixture = FixtureReader.readAsMap('blog_post_create/delete_request.json'); + blogPostCreateCubit = BlogPostCreateCubit( + blogPostRepository: blogPostRepository, + ); + fullCreateFixture = FixtureReader.readAsMap( + 'blog_post_create/create_request_full.json', + ); + updateFixture = FixtureReader.readAsMap( + 'blog_post_create/update_request.json', + ); + deleteFixture = FixtureReader.readAsMap( + 'blog_post_create/delete_request.json', + ); }); tearDown(() { @@ -41,7 +49,9 @@ void main() { ); } - testWidgets('should handle blog post creation with all fields', (WidgetTester tester) async { + testWidgets('should handle blog post creation with all fields', ( + WidgetTester tester, + ) async { // Arrange Widget formWidget = Column( children: [ @@ -69,7 +79,6 @@ void main() { title: fullCreateFixture['title'] as String?, description: fullCreateFixture['description_md'] as String?, videoUrl: fullCreateFixture['video_url'] as String?, - image: null, ); }, child: const Text('Опубликовать'), @@ -81,9 +90,18 @@ void main() { await tester.pumpWidget(createTestWidget(formWidget)); await tester.pumpAndSettle(); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), fullCreateFixture['title'] as String); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), fullCreateFixture['description_md'] as String); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), fullCreateFixture['video_url'] as String); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenTitleTextField), + fullCreateFixture['title'] as String, + ); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenContentTextField), + fullCreateFixture['description_md'] as String, + ); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), + fullCreateFixture['video_url'] as String, + ); await tester.pump(); await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton)); @@ -96,7 +114,9 @@ void main() { ); }); - testWidgets('should handle blog post creation with image', (WidgetTester tester) async { + testWidgets('should handle blog post creation with image', ( + WidgetTester tester, + ) async { // Arrange final expectedPayload = Map.from(fullCreateFixture); expectedPayload['video_url'] = null; @@ -129,7 +149,11 @@ void main() { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.add_photo_alternate, size: 48, color: Colors.grey), + Icon( + Icons.add_photo_alternate, + size: 48, + color: Colors.grey, + ), SizedBox(height: 8), Text('Добавить изображение'), ], @@ -145,7 +169,6 @@ void main() { title: fullCreateFixture['title'] as String?, description: fullCreateFixture['description_md'] as String?, videoUrl: null, - image: null, // В реальном тесте здесь был бы XFile ); }, child: const Text('Опубликовать'), @@ -157,15 +180,24 @@ void main() { await tester.pumpWidget(createTestWidget(formWidget)); await tester.pumpAndSettle(); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), fullCreateFixture['title'] as String); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), fullCreateFixture['description_md'] as String); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenTitleTextField), + fullCreateFixture['title'] as String, + ); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenContentTextField), + fullCreateFixture['description_md'] as String, + ); await tester.pump(); await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton)); await tester.pump(); // Assert - expect(blogPostRepository.createPayloads.single.toJson(), expectedPayload); + expect( + blogPostRepository.createPayloads.single.toJson(), + expectedPayload, + ); }); testWidgets('should handle blog post update', (WidgetTester tester) async { @@ -175,7 +207,12 @@ void main() { state: 'published', image: 'original_image.jpg', description: 'Original Description', - author: const Author(authorSlug: 'test-author', authorName: 'Test Author', authorProfileIcon: 'avatar.jpg', authorIsMe: true), + author: const Author( + authorSlug: 'test-author', + authorName: 'Test Author', + authorProfileIcon: 'avatar.jpg', + authorIsMe: true, + ), videoUrl: 'https://youtube.com/watch?v=original', createdAt: DateTime.now(), updatedAt: DateTime.now(), @@ -216,8 +253,9 @@ void main() { title: updateFixture['title'] as String?, description: updateFixture['description_md'] as String?, videoUrl: updateFixture['video_url'] as String?, - image: null, - oldImageUrl: updateFixture['old_image_url'] as String?, + existingMediaIds: List.from( + updateFixture['existing_media_ids'] as List, + ), ); }, child: const Text('Обновить'), @@ -229,9 +267,18 @@ void main() { await tester.pumpWidget(createTestWidget(formWidget)); await tester.pumpAndSettle(); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), updateFixture['title'] as String); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), updateFixture['description_md'] as String); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), updateFixture['video_url'] as String); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenTitleTextField), + updateFixture['title'] as String, + ); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenContentTextField), + updateFixture['description_md'] as String, + ); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), + updateFixture['video_url'] as String, + ); await tester.pump(); await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton)); @@ -241,7 +288,9 @@ void main() { expect(blogPostRepository.updatePayloads.single.toJson(), updateFixture); }); - testWidgets('should handle blog post deletion', (WidgetTester tester) async { + testWidgets('should handle blog post deletion', ( + WidgetTester tester, + ) async { // Arrange Widget deleteWidget = ElevatedButton( key: const Key('deleteBlogPostButton'), @@ -262,18 +311,21 @@ void main() { expect(blogPostRepository.deleteCalls.single, deleteFixture['id']); }); - testWidgets('should handle loading state during blog post creation', (WidgetTester tester) async { + testWidgets('should handle loading state during blog post creation', ( + WidgetTester tester, + ) async { // Arrange blogPostRepository.createDelay = const Duration(milliseconds: 50); - Widget loadingWidget = BlocBuilder( - builder: (context, state) { - if (state is BlogPostCreateStateLoading) { - return const Center(child: CircularProgressIndicator()); - } - return const Text('Not Loading'); - }, - ); + Widget loadingWidget = + BlocBuilder( + builder: (context, state) { + if (state.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + return const Text('Not Loading'); + }, + ); // Act await tester.pumpWidget(createTestWidget(loadingWidget)); @@ -284,7 +336,6 @@ void main() { title: fullCreateFixture['title'] as String?, videoUrl: null, description: fullCreateFixture['description_md'] as String?, - image: null, ); await tester.pump(); await tester.pump(); // Need to pump twice to get the loading state @@ -297,18 +348,21 @@ void main() { await tester.pumpAndSettle(); }); - testWidgets('should handle error state during blog post creation', (WidgetTester tester) async { + testWidgets('should handle error state during blog post creation', ( + WidgetTester tester, + ) async { // Arrange blogPostRepository.createError = Exception('Network error'); - Widget errorWidget = BlocBuilder( - builder: (context, state) { - if (state is BlogPostCreateStateError) { - return Center(child: Text('Error: ${state.errorMessage}')); - } - return const Text('No Error'); - }, - ); + Widget errorWidget = + BlocBuilder( + builder: (context, state) { + if (state.errorMessage != null) { + return Center(child: Text('Error: ${state.errorMessage}')); + } + return const Text('No Error'); + }, + ); // Act await tester.pumpWidget(createTestWidget(errorWidget)); @@ -319,7 +373,6 @@ void main() { title: fullCreateFixture['title'] as String?, videoUrl: null, description: fullCreateFixture['description_md'] as String?, - image: null, ); await tester.pump(); await tester.pump(); // Need to pump twice to get the error state @@ -328,16 +381,21 @@ void main() { expect(find.textContaining('Error:'), findsOneWidget); }); - testWidgets('should handle success state after blog post creation', (WidgetTester tester) async { + testWidgets('should handle success state after blog post creation', ( + WidgetTester tester, + ) async { // Arrange - Widget successWidget = BlocBuilder( - builder: (context, state) { - if (state is BlogPostCreateStateLoaded) { - return const Center(child: Text('Blog post created successfully!')); - } - return const Text('Not Loaded'); - }, - ); + Widget successWidget = + BlocBuilder( + builder: (context, state) { + if (state.isSuccess) { + return const Center( + child: Text('Blog post created successfully!'), + ); + } + return const Text('Not Loaded'); + }, + ); // Act await tester.pumpWidget(createTestWidget(successWidget)); @@ -348,7 +406,6 @@ void main() { title: fullCreateFixture['title'] as String?, videoUrl: null, description: fullCreateFixture['description_md'] as String?, - image: null, ); await tester.pump(); await tester.pump(); // Need to pump twice to get the success state @@ -357,23 +414,35 @@ void main() { expect(find.text('Blog post created successfully!'), findsOneWidget); }); - testWidgets('should validate form fields before submission', (WidgetTester tester) async { + testWidgets('should validate form fields before submission', ( + WidgetTester tester, + ) async { // Arrange Widget validationWidget = Column( children: [ TextFormField( key: AppKeys.blogPostCreateScreenTitleTextField, - decoration: const InputDecoration(labelText: 'Заголовок', errorText: 'Заголовок не может быть пустым'), + decoration: const InputDecoration( + labelText: 'Заголовок', + errorText: 'Заголовок не может быть пустым', + ), ), const SizedBox(height: 16), TextFormField( key: AppKeys.blogPostCreateScreenContentTextField, - decoration: const InputDecoration(labelText: 'Описание', errorText: 'Описание не может быть пустым'), + decoration: const InputDecoration( + labelText: 'Описание', + errorText: 'Описание не может быть пустым', + ), maxLines: 6, minLines: 4, ), const SizedBox(height: 16), - ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать')), + ElevatedButton( + key: AppKeys.blogPostCreateScreenCreateButton, + onPressed: () {}, + child: const Text('Опубликовать'), + ), ], ); @@ -386,7 +455,9 @@ void main() { expect(find.text('Описание не может быть пустым'), findsOneWidget); }); - testWidgets('should handle image picker interaction', (WidgetTester tester) async { + testWidgets('should handle image picker interaction', ( + WidgetTester tester, + ) async { // Arrange bool imagePickerCalled = false; Widget imagePickerWidget = GestureDetector( @@ -425,16 +496,25 @@ void main() { expect(imagePickerCalled, isTrue); }); - testWidgets('should handle video URL validation', (WidgetTester tester) async { + testWidgets('should handle video URL validation', ( + WidgetTester tester, + ) async { // Arrange Widget videoValidationWidget = Column( children: [ TextFormField( key: AppKeys.blogPostCreateScreenVideoLinkTextField, - decoration: const InputDecoration(labelText: 'Ссылка на видео', errorText: 'Введите корректную ссылку на видео'), + decoration: const InputDecoration( + labelText: 'Ссылка на видео', + errorText: 'Введите корректную ссылку на видео', + ), ), const SizedBox(height: 16), - ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать')), + ElevatedButton( + key: AppKeys.blogPostCreateScreenCreateButton, + onPressed: () {}, + child: const Text('Опубликовать'), + ), ], ); @@ -446,7 +526,9 @@ void main() { expect(find.text('Введите корректную ссылку на видео'), findsOneWidget); }); - testWidgets('should handle form submission with empty fields', (WidgetTester tester) async { + testWidgets('should handle form submission with empty fields', ( + WidgetTester tester, + ) async { // Arrange Widget emptyFormWidget = Column( children: [ @@ -465,7 +547,11 @@ void main() { ElevatedButton( key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () async { - await blogPostCreateCubit.createBlogPost(title: '', videoUrl: '', description: '', image: null); + await blogPostCreateCubit.createBlogPost( + title: '', + videoUrl: '', + description: '', + ); }, child: const Text('Опубликовать'), ), @@ -479,14 +565,14 @@ void main() { await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton)); await tester.pump(); - // Assert - expect( - blogPostRepository.createPayloads.single.toJson(), - {'title': '', 'description_md': '', 'video_url': '', 'image_name': null}, - ); + // Assert — invalid empty payload must not reach the repository + expect(blogPostRepository.createPayloads, isEmpty); + expect(blogPostCreateCubit.state.hasValidationErrors, isTrue); }); - testWidgets('should handle blog post creation with long content', (WidgetTester tester) async { + testWidgets('should handle blog post creation with long content', ( + WidgetTester tester, + ) async { // Arrange const longContent = 'Это очень длинное описание блог поста, которое содержит много текста и должно быть правильно обработано системой. ' @@ -510,7 +596,11 @@ void main() { ElevatedButton( key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () async { - await blogPostCreateCubit.createBlogPost(title: 'Test Title', videoUrl: null, description: longContent, image: null); + await blogPostCreateCubit.createBlogPost( + title: 'Test Title', + videoUrl: null, + description: longContent, + ); }, child: const Text('Опубликовать'), ), @@ -521,23 +611,26 @@ void main() { await tester.pumpWidget(createTestWidget(longContentWidget)); await tester.pumpAndSettle(); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), 'Test Title'); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), longContent); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenTitleTextField), + 'Test Title', + ); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenContentTextField), + longContent, + ); await tester.pump(); await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton)); await tester.pump(); // Assert - expect( - blogPostRepository.createPayloads.single.toJson(), - { - 'title': 'Test Title', - 'description_md': longContent, - 'video_url': null, - 'image_name': null, - }, - ); + expect(blogPostRepository.createPayloads.single.toJson(), { + 'title': 'Test Title', + 'description_md': longContent, + 'video_url': null, + 'image_name': null, + }); }); }); } diff --git a/test/features/blog_post_create/basic_widget_test.dart b/test/features/blog_post_create/basic_widget_test.dart index 1e91998f..3cd4aa38 100644 --- a/test/features/blog_post_create/basic_widget_test.dart +++ b/test/features/blog_post_create/basic_widget_test.dart @@ -14,7 +14,9 @@ void main() { setUp(() { blogPostRepository = FixtureBlogPostRepository(); - blogPostCreateCubit = BlogPostCreateCubit(blogPostRepository: blogPostRepository); + blogPostCreateCubit = BlogPostCreateCubit( + blogPostRepository: blogPostRepository, + ); }); tearDown(() { @@ -31,7 +33,9 @@ void main() { ); } - testWidgets('should create BlogPostCreateCubit successfully', (WidgetTester tester) async { + testWidgets('should create BlogPostCreateCubit successfully', ( + WidgetTester tester, + ) async { // Arrange & Act final cubit = BlogPostCreateCubit(blogPostRepository: blogPostRepository); @@ -41,13 +45,18 @@ void main() { cubit.close(); }); - testWidgets('should display basic blog post form fields', (WidgetTester tester) async { + testWidgets('should display basic blog post form fields', ( + WidgetTester tester, + ) async { // Arrange Widget formWidget = Column( children: [ TextFormField( key: AppKeys.blogPostCreateScreenTitleTextField, - decoration: const InputDecoration(labelText: 'Заголовок', hintText: 'Введите заголовок поста'), + decoration: const InputDecoration( + labelText: 'Заголовок', + hintText: 'Введите заголовок поста', + ), ), const SizedBox(height: 16), TextFormField( @@ -62,7 +71,11 @@ void main() { decoration: const InputDecoration(labelText: 'Ссылка на видео'), ), const SizedBox(height: 16), - ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать')), + ElevatedButton( + key: AppKeys.blogPostCreateScreenCreateButton, + onPressed: () {}, + child: const Text('Опубликовать'), + ), ], ); @@ -71,17 +84,34 @@ void main() { await tester.pumpAndSettle(); // Assert - expect(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), findsOneWidget); - expect(find.byKey(AppKeys.blogPostCreateScreenContentTextField), findsOneWidget); - expect(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), findsOneWidget); - expect(find.byKey(AppKeys.blogPostCreateScreenCreateButton), findsOneWidget); + expect( + find.byKey(AppKeys.blogPostCreateScreenTitleTextField), + findsOneWidget, + ); + expect( + find.byKey(AppKeys.blogPostCreateScreenContentTextField), + findsOneWidget, + ); + expect( + find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), + findsOneWidget, + ); + expect( + find.byKey(AppKeys.blogPostCreateScreenCreateButton), + findsOneWidget, + ); }); - testWidgets('should update title when user types in title field', (WidgetTester tester) async { + testWidgets('should update title when user types in title field', ( + WidgetTester tester, + ) async { // Arrange Widget formWidget = TextFormField( key: AppKeys.blogPostCreateScreenTitleTextField, - decoration: const InputDecoration(labelText: 'Заголовок', hintText: 'Введите заголовок поста'), + decoration: const InputDecoration( + labelText: 'Заголовок', + hintText: 'Введите заголовок поста', + ), ); await tester.pumpWidget(createTestWidget(formWidget)); @@ -90,36 +120,47 @@ void main() { const testTitle = 'Тестовый заголовок поста'; // Act - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), testTitle); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenTitleTextField), + testTitle, + ); await tester.pump(); // Assert expect(find.text(testTitle), findsOneWidget); }); - testWidgets('should update description when user types in description field', (WidgetTester tester) async { - // Arrange - Widget formWidget = TextFormField( - key: AppKeys.blogPostCreateScreenContentTextField, - decoration: const InputDecoration(labelText: 'Описание'), - maxLines: 6, - minLines: 4, - ); + testWidgets( + 'should update description when user types in description field', + (WidgetTester tester) async { + // Arrange + Widget formWidget = TextFormField( + key: AppKeys.blogPostCreateScreenContentTextField, + decoration: const InputDecoration(labelText: 'Описание'), + maxLines: 6, + minLines: 4, + ); - await tester.pumpWidget(createTestWidget(formWidget)); - await tester.pumpAndSettle(); + await tester.pumpWidget(createTestWidget(formWidget)); + await tester.pumpAndSettle(); - const testDescription = 'Это тестовое описание блог поста'; + const testDescription = 'Это тестовое описание блог поста'; - // Act - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), testDescription); - await tester.pump(); + // Act + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenContentTextField), + testDescription, + ); + await tester.pump(); - // Assert - expect(find.text(testDescription), findsOneWidget); - }); + // Assert + expect(find.text(testDescription), findsOneWidget); + }, + ); - testWidgets('should update video URL when user types in video field', (WidgetTester tester) async { + testWidgets('should update video URL when user types in video field', ( + WidgetTester tester, + ) async { // Arrange Widget formWidget = TextFormField( key: AppKeys.blogPostCreateScreenVideoLinkTextField, @@ -132,16 +173,27 @@ void main() { const testVideoUrl = 'https://youtube.com/watch?v=example'; // Act - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), testVideoUrl); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), + testVideoUrl, + ); await tester.pump(); // Assert expect(find.text(testVideoUrl), findsOneWidget); }); - testWidgets('should display form labels correctly', (WidgetTester tester) async { + testWidgets('should display form labels correctly', ( + WidgetTester tester, + ) async { // Arrange - Widget formWidget = const Column(children: [Text('Заголовок'), Text('Описание'), Text('Ссылка на видео')]); + Widget formWidget = const Column( + children: [ + Text('Заголовок'), + Text('Описание'), + Text('Ссылка на видео'), + ], + ); // Act await tester.pumpWidget(createTestWidget(formWidget)); @@ -153,11 +205,16 @@ void main() { expect(find.text('Ссылка на видео'), findsOneWidget); }); - testWidgets('should display placeholder text in title field', (WidgetTester tester) async { + testWidgets('should display placeholder text in title field', ( + WidgetTester tester, + ) async { // Arrange Widget formWidget = TextFormField( key: AppKeys.blogPostCreateScreenTitleTextField, - decoration: const InputDecoration(labelText: 'Заголовок', hintText: 'Введите заголовок поста'), + decoration: const InputDecoration( + labelText: 'Заголовок', + hintText: 'Введите заголовок поста', + ), ); // Act @@ -170,14 +227,21 @@ void main() { testWidgets('should display create button', (WidgetTester tester) async { // Arrange - Widget formWidget = ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать')); + Widget formWidget = ElevatedButton( + key: AppKeys.blogPostCreateScreenCreateButton, + onPressed: () {}, + child: const Text('Опубликовать'), + ); // Act await tester.pumpWidget(createTestWidget(formWidget)); await tester.pumpAndSettle(); // Assert - expect(find.byKey(AppKeys.blogPostCreateScreenCreateButton), findsOneWidget); + expect( + find.byKey(AppKeys.blogPostCreateScreenCreateButton), + findsOneWidget, + ); expect(find.text('Опубликовать'), findsOneWidget); }); @@ -217,19 +281,26 @@ void main() { testWidgets('should display error message', (WidgetTester tester) async { // Arrange - Widget errorWidget = const Center(child: Text('Произошла ошибка при создании блог поста')); + Widget errorWidget = const Center( + child: Text('Произошла ошибка при создании блог поста'), + ); // Act await tester.pumpWidget(createTestWidget(errorWidget)); await tester.pumpAndSettle(); // Assert - expect(find.text('Произошла ошибка при создании блог поста'), findsOneWidget); + expect( + find.text('Произошла ошибка при создании блог поста'), + findsOneWidget, + ); }); testWidgets('should display success message', (WidgetTester tester) async { // Arrange - Widget successWidget = const Center(child: Text('Блог пост успешно создан!')); + Widget successWidget = const Center( + child: Text('Блог пост успешно создан!'), + ); // Act await tester.pumpWidget(createTestWidget(successWidget)); @@ -245,12 +316,18 @@ void main() { children: [ TextFormField( key: AppKeys.blogPostCreateScreenTitleTextField, - decoration: const InputDecoration(labelText: 'Заголовок', errorText: 'Заголовок обязателен'), + decoration: const InputDecoration( + labelText: 'Заголовок', + errorText: 'Заголовок обязателен', + ), ), const SizedBox(height: 16), TextFormField( key: AppKeys.blogPostCreateScreenContentTextField, - decoration: const InputDecoration(labelText: 'Описание', errorText: 'Описание обязательно'), + decoration: const InputDecoration( + labelText: 'Описание', + errorText: 'Описание обязательно', + ), ), ], ); @@ -264,13 +341,18 @@ void main() { expect(find.text('Описание обязательно'), findsOneWidget); }); - testWidgets('should display form with all required fields', (WidgetTester tester) async { + testWidgets('should display form with all required fields', ( + WidgetTester tester, + ) async { // Arrange Widget completeForm = Column( children: [ TextFormField( key: AppKeys.blogPostCreateScreenTitleTextField, - decoration: const InputDecoration(labelText: 'Заголовок', hintText: 'Введите заголовок поста'), + decoration: const InputDecoration( + labelText: 'Заголовок', + hintText: 'Введите заголовок поста', + ), ), const SizedBox(height: 16), TextFormField( @@ -285,7 +367,11 @@ void main() { decoration: const InputDecoration(labelText: 'Ссылка на видео'), ), const SizedBox(height: 32), - ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать')), + ElevatedButton( + key: AppKeys.blogPostCreateScreenCreateButton, + onPressed: () {}, + child: const Text('Опубликовать'), + ), ], ); @@ -294,13 +380,27 @@ void main() { await tester.pumpAndSettle(); // Assert - expect(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), findsOneWidget); - expect(find.byKey(AppKeys.blogPostCreateScreenContentTextField), findsOneWidget); - expect(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), findsOneWidget); - expect(find.byKey(AppKeys.blogPostCreateScreenCreateButton), findsOneWidget); + expect( + find.byKey(AppKeys.blogPostCreateScreenTitleTextField), + findsOneWidget, + ); + expect( + find.byKey(AppKeys.blogPostCreateScreenContentTextField), + findsOneWidget, + ); + expect( + find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), + findsOneWidget, + ); + expect( + find.byKey(AppKeys.blogPostCreateScreenCreateButton), + findsOneWidget, + ); }); - testWidgets('should display image upload widget', (WidgetTester tester) async { + testWidgets('should display image upload widget', ( + WidgetTester tester, + ) async { // Arrange Widget imageWidget = GestureDetector( key: AppKeys.imageWidget, @@ -373,68 +473,101 @@ void main() { expect(imageUploadTapped, isTrue); }); - testWidgets('should display blog post creation form with image and video options', (WidgetTester tester) async { - // Arrange - Widget completeForm = Column( - children: [ - const Text('Заголовок'), - const SizedBox(height: 8), - TextFormField( - key: AppKeys.blogPostCreateScreenTitleTextField, - decoration: const InputDecoration(hintText: 'Введите заголовок поста'), - ), - const SizedBox(height: 16), - const Text('Описание'), - const SizedBox(height: 8), - TextFormField(key: AppKeys.blogPostCreateScreenContentTextField, decoration: const InputDecoration(), maxLines: 6, minLines: 4), - const SizedBox(height: 16), - const Text('Изображение поста'), - const SizedBox(height: 8), - GestureDetector( - key: AppKeys.imageWidget, - onTap: () {}, - child: Container( - height: 200, - width: double.infinity, - decoration: BoxDecoration( - border: Border.all(color: Colors.grey), - borderRadius: BorderRadius.circular(8), + testWidgets( + 'should display blog post creation form with image and video options', + (WidgetTester tester) async { + // Arrange + Widget completeForm = Column( + children: [ + const Text('Заголовок'), + const SizedBox(height: 8), + TextFormField( + key: AppKeys.blogPostCreateScreenTitleTextField, + decoration: const InputDecoration( + hintText: 'Введите заголовок поста', ), - child: const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.add_photo_alternate, size: 48, color: Colors.grey), - SizedBox(height: 8), - Text('Добавить изображение'), - ], + ), + const SizedBox(height: 16), + const Text('Описание'), + const SizedBox(height: 8), + TextFormField( + key: AppKeys.blogPostCreateScreenContentTextField, + decoration: const InputDecoration(), + maxLines: 6, + minLines: 4, + ), + const SizedBox(height: 16), + const Text('Изображение поста'), + const SizedBox(height: 8), + GestureDetector( + key: AppKeys.imageWidget, + onTap: () {}, + child: Container( + height: 200, + width: double.infinity, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(8), + ), + child: const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.add_photo_alternate, + size: 48, + color: Colors.grey, + ), + SizedBox(height: 8), + Text('Добавить изображение'), + ], + ), ), ), ), - ), - const SizedBox(height: 16), - const Text('Ссылка на видео'), - const SizedBox(height: 8), - TextFormField(key: AppKeys.blogPostCreateScreenVideoLinkTextField, decoration: const InputDecoration()), - const SizedBox(height: 32), - ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать')), - ], - ); + const SizedBox(height: 16), + const Text('Ссылка на видео'), + const SizedBox(height: 8), + TextFormField( + key: AppKeys.blogPostCreateScreenVideoLinkTextField, + decoration: const InputDecoration(), + ), + const SizedBox(height: 32), + ElevatedButton( + key: AppKeys.blogPostCreateScreenCreateButton, + onPressed: () {}, + child: const Text('Опубликовать'), + ), + ], + ); - // Act - await tester.pumpWidget(createTestWidget(completeForm)); - await tester.pumpAndSettle(); + // Act + await tester.pumpWidget(createTestWidget(completeForm)); + await tester.pumpAndSettle(); - // Assert - expect(find.text('Заголовок'), findsOneWidget); - expect(find.text('Описание'), findsOneWidget); - expect(find.text('Изображение поста'), findsOneWidget); - expect(find.text('Ссылка на видео'), findsOneWidget); - expect(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), findsOneWidget); - expect(find.byKey(AppKeys.blogPostCreateScreenContentTextField), findsOneWidget); - expect(find.byKey(AppKeys.imageWidget), findsOneWidget); - expect(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), findsOneWidget); - expect(find.byKey(AppKeys.blogPostCreateScreenCreateButton), findsOneWidget); - }); + // Assert + expect(find.text('Заголовок'), findsOneWidget); + expect(find.text('Описание'), findsOneWidget); + expect(find.text('Изображение поста'), findsOneWidget); + expect(find.text('Ссылка на видео'), findsOneWidget); + expect( + find.byKey(AppKeys.blogPostCreateScreenTitleTextField), + findsOneWidget, + ); + expect( + find.byKey(AppKeys.blogPostCreateScreenContentTextField), + findsOneWidget, + ); + expect(find.byKey(AppKeys.imageWidget), findsOneWidget); + expect( + find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), + findsOneWidget, + ); + expect( + find.byKey(AppKeys.blogPostCreateScreenCreateButton), + findsOneWidget, + ); + }, + ); }); } diff --git a/test/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit_test.dart b/test/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit_test.dart index 63699ebc..ef66ebd7 100644 --- a/test/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit_test.dart +++ b/test/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit_test.dart @@ -1,6 +1,8 @@ import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart'; import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_state.dart'; import 'package:eatvio_app/features/blog_post_create/repositories/blog_post_repository.dart'; +import 'package:eatvio_app/features/blog_post_create/validation/blog_post_form_validator.dart'; +import 'package:flutter_image_compress/flutter_image_compress.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; @@ -14,6 +16,8 @@ void main() { setUp(() { mockRepository = MockBlogPostRepository(); cubit = BlogPostCreateCubit(blogPostRepository: mockRepository); + registerFallbackValue([]); + registerFallbackValue([]); }); tearDown(() { @@ -21,63 +25,246 @@ void main() { }); test('начальное состояние - BlogPostCreateState', () { - expect(cubit.state, isA()); + expect(cubit.state, const BlogPostCreateState()); }); - test('createBlogPost - успешное создание', () async { - when(() => mockRepository.createBlogPost(any(), any(), any(), any())) - .thenAnswer((_) async => {}); + test('validateForm - пустая форма заполняет formError', () { + final isValid = cubit.validateForm( + title: '', + description: '', + videoUrl: '', + imageCount: 0, + ); - final result = await cubit.createBlogPost(); + expect(isValid, false); + expect(cubit.state.formError, BlogPostFormValidator.contentRequired); + verifyNever( + () => mockRepository.createBlogPost( + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + images: any(named: 'images'), + ), + ); + }); + + test('validateForm - невалидное видео заполняет videoUrlError', () { + final isValid = cubit.validateForm( + title: 'Valid title', + description: '', + videoUrl: 'https://youtube.com/watch?v=abc', + imageCount: 0, + ); + + expect(isValid, false); + expect( + cubit.state.videoUrlError, + BlogPostFormValidator.videoUrlUnsupportedHost, + ); + expect(cubit.state.formError, isNull); + }); + + test( + 'createBlogPost - не вызывает репозиторий при ошибке валидации', + () async { + final result = await cubit.createBlogPost(); + + expect(result, false); + expect(cubit.state.hasValidationErrors, true); + verifyNever( + () => mockRepository.createBlogPost( + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + images: any(named: 'images'), + ), + ); + }, + ); + + test('createBlogPost - успешное создание', () async { + when( + () => mockRepository.createBlogPost( + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + images: any(named: 'images'), + ), + ).thenAnswer((_) async => {}); + + final result = await cubit.createBlogPost( + title: 'Test Title', + description: 'Test Description', + ); expect(result, true); - expect(cubit.state, isA()); - verify(() => mockRepository.createBlogPost(null, null, null, null)).called(1); + expect(cubit.state.isSuccess, true); + verify( + () => mockRepository.createBlogPost( + title: 'Test Title', + videoUrl: null, + description: 'Test Description', + images: const [], + ), + ).called(1); }); test('createBlogPost - обработка ошибки', () async { - when(() => mockRepository.createBlogPost(any(), any(), any(), any())) - .thenThrow(Exception('Error')); + when( + () => mockRepository.createBlogPost( + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + images: any(named: 'images'), + ), + ).thenThrow(Exception('Error')); - final result = await cubit.createBlogPost(); + final result = await cubit.createBlogPost( + title: 'Test Title', + description: 'Test Description', + ); expect(result, false); - expect(cubit.state, isA()); - final errorState = cubit.state as BlogPostCreateStateError; - expect(errorState.errorMessage, contains('Error')); + expect(cubit.state.errorMessage, contains('Error')); + expect(cubit.state.isSuccess, false); }); + test( + 'updateBlogPost - не вызывает репозиторий при ошибке валидации', + () async { + final result = await cubit.updateBlogPost(id: 1); + + expect(result, false); + expect(cubit.state.hasValidationErrors, true); + expect(cubit.state.formError, BlogPostFormValidator.contentRequired); + verifyNever( + () => mockRepository.updateBlogPost( + id: any(named: 'id'), + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + existingMediaIds: any(named: 'existingMediaIds'), + newImages: any(named: 'newImages'), + mediaIdsToDelete: any(named: 'mediaIdsToDelete'), + ), + ); + }, + ); + + test('updateBlogPost - невалидное видео не уходит в репозиторий', () async { + final result = await cubit.updateBlogPost( + id: 1, + title: 'Valid title', + videoUrl: 'https://youtube.com/watch?v=abc', + existingMediaIds: const [10], + ); + + expect(result, false); + expect( + cubit.state.videoUrlError, + BlogPostFormValidator.videoUrlUnsupportedHost, + ); + verifyNever( + () => mockRepository.updateBlogPost( + id: any(named: 'id'), + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + existingMediaIds: any(named: 'existingMediaIds'), + newImages: any(named: 'newImages'), + mediaIdsToDelete: any(named: 'mediaIdsToDelete'), + ), + ); + }); + + test( + 'updateBlogPost - короткий заголовок не уходит в репозиторий', + () async { + final result = await cubit.updateBlogPost( + id: 1, + title: 'ab', + existingMediaIds: const [10], + ); + + expect(result, false); + expect(cubit.state.titleError, BlogPostFormValidator.titleTooShort); + verifyNever( + () => mockRepository.updateBlogPost( + id: any(named: 'id'), + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + existingMediaIds: any(named: 'existingMediaIds'), + newImages: any(named: 'newImages'), + mediaIdsToDelete: any(named: 'mediaIdsToDelete'), + ), + ); + }, + ); + test('updateBlogPost - успешное обновление', () async { const id = 1; const title = 'Updated Title'; - when(() => mockRepository.updateBlogPost(any(), any(), any(), any(), any(), any())) - .thenAnswer((_) async => {}); + when( + () => mockRepository.updateBlogPost( + id: any(named: 'id'), + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + existingMediaIds: any(named: 'existingMediaIds'), + newImages: any(named: 'newImages'), + mediaIdsToDelete: any(named: 'mediaIdsToDelete'), + ), + ).thenAnswer((_) async => {}); - final result = await cubit.updateBlogPost(id: id, title: title); + final result = await cubit.updateBlogPost( + id: id, + title: title, + existingMediaIds: [2, 1], + mediaIdsToDelete: [9], + ); expect(result, true); - expect(cubit.state, isA()); - verify(() => mockRepository.updateBlogPost(id, title, null, null, null, null)).called(1); + expect(cubit.state.isSuccess, true); + verify( + () => mockRepository.updateBlogPost( + id: id, + title: title, + videoUrl: '', + description: '', + existingMediaIds: [2, 1], + newImages: const [], + mediaIdsToDelete: [9], + ), + ).called(1); }); test('updateBlogPost - обработка ошибки', () async { const id = 1; - when(() => mockRepository.updateBlogPost(any(), any(), any(), any(), any(), any())) - .thenThrow(Exception('Error')); + when( + () => mockRepository.updateBlogPost( + id: any(named: 'id'), + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + existingMediaIds: any(named: 'existingMediaIds'), + newImages: any(named: 'newImages'), + mediaIdsToDelete: any(named: 'mediaIdsToDelete'), + ), + ).thenThrow(Exception('Error')); - final result = await cubit.updateBlogPost(id: id); + final result = await cubit.updateBlogPost(id: id, title: 'Valid title'); expect(result, false); - expect(cubit.state, isA()); + expect(cubit.state.errorMessage, contains('Error')); }); test('deleteBlogPost - успешное удаление', () async { const id = 1; - when(() => mockRepository.deleteBlogPost(id)) - .thenAnswer((_) async => {}); + when(() => mockRepository.deleteBlogPost(id)).thenAnswer((_) async => {}); final result = await cubit.deleteBlogPost(id); @@ -88,13 +275,27 @@ void main() { test('deleteBlogPost - обработка ошибки', () async { const id = 1; - when(() => mockRepository.deleteBlogPost(id)) - .thenThrow(Exception('Error')); + when( + () => mockRepository.deleteBlogPost(id), + ).thenThrow(Exception('Error')); final result = await cubit.deleteBlogPost(id); expect(result, false); }); + + test('clearTitleError очищает titleError и formError', () { + cubit.validateForm( + title: '', + description: '', + videoUrl: '', + imageCount: 0, + ); + expect(cubit.state.formError, isNotNull); + + cubit.clearTitleError(); + + expect(cubit.state.formError, isNull); + }); }); } - diff --git a/test/features/blog_post_create/integration_widget_test.dart b/test/features/blog_post_create/integration_widget_test.dart index d4fd63f8..1eaad104 100644 --- a/test/features/blog_post_create/integration_widget_test.dart +++ b/test/features/blog_post_create/integration_widget_test.dart @@ -21,10 +21,16 @@ void main() { setUp(() { blogPostRepository = FixtureBlogPostRepository(); - blogPostCreateCubit = BlogPostCreateCubit(blogPostRepository: blogPostRepository); + blogPostCreateCubit = BlogPostCreateCubit( + blogPostRepository: blogPostRepository, + ); feedProbe = FeedReloadProbe(); - fullCreateFixture = FixtureReader.readAsMap('blog_post_create/create_request_full.json'); - updateFixture = FixtureReader.readAsMap('blog_post_create/update_request.json'); + fullCreateFixture = FixtureReader.readAsMap( + 'blog_post_create/create_request_full.json', + ); + updateFixture = FixtureReader.readAsMap( + 'blog_post_create/update_request.json', + ); }); tearDown(() { @@ -41,11 +47,14 @@ void main() { ); } - testWidgets('should complete full blog post creation flow', (WidgetTester tester) async { + testWidgets('should complete full blog post creation flow', ( + WidgetTester tester, + ) async { // Arrange - Widget fullFlowWidget = BlocListener( + Widget + fullFlowWidget = BlocListener( listener: (context, state) { - if (state is BlogPostCreateStateLoaded) { + if (state.isSuccess) { feedProbe.loadFeedBlogPostList(); } }, @@ -82,7 +91,11 @@ void main() { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.add_photo_alternate, size: 48, color: Colors.grey), + Icon( + Icons.add_photo_alternate, + size: 48, + color: Colors.grey, + ), SizedBox(height: 8), Text('Добавить изображение'), ], @@ -98,7 +111,6 @@ void main() { title: fullCreateFixture['title'] as String?, videoUrl: fullCreateFixture['video_url'] as String?, description: fullCreateFixture['description_md'] as String?, - image: null, ); }, child: const Text('Опубликовать'), @@ -111,27 +123,46 @@ void main() { await tester.pumpWidget(createTestWidget(fullFlowWidget)); await tester.pumpAndSettle(); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), fullCreateFixture['title'] as String); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), fullCreateFixture['description_md'] as String); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), fullCreateFixture['video_url'] as String); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenTitleTextField), + fullCreateFixture['title'] as String, + ); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenContentTextField), + fullCreateFixture['description_md'] as String, + ); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), + fullCreateFixture['video_url'] as String, + ); await tester.pump(); await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton)); await tester.pump(); // Assert - expect(blogPostRepository.createPayloads.single.toJson(), fullCreateFixture); + expect( + blogPostRepository.createPayloads.single.toJson(), + fullCreateFixture, + ); expect(feedProbe.loadRequests, 1); }); - testWidgets('should handle blog post update flow', (WidgetTester tester) async { + testWidgets('should handle blog post update flow', ( + WidgetTester tester, + ) async { // Arrange final existingBlogPost = FeedBlogPostModel( id: 1, state: 'published', image: 'original_image.jpg', description: 'Original Description', - author: const Author(authorSlug: 'test-author', authorName: 'Test Author', authorProfileIcon: 'avatar.jpg', authorIsMe: true), + author: const Author( + authorSlug: 'test-author', + authorName: 'Test Author', + authorProfileIcon: 'avatar.jpg', + authorIsMe: true, + ), videoUrl: 'https://youtube.com/watch?v=original', createdAt: DateTime.now(), updatedAt: DateTime.now(), @@ -142,9 +173,10 @@ void main() { wasLikedByMe: false, ); - Widget updateFlowWidget = BlocListener( + Widget + updateFlowWidget = BlocListener( listener: (context, state) { - if (state is BlogPostCreateStateLoaded) { + if (state.isSuccess) { feedProbe.loadFeedBlogPostList(); } }, @@ -161,13 +193,17 @@ void main() { decoration: const InputDecoration(labelText: 'Описание'), maxLines: 6, minLines: 4, - controller: TextEditingController(text: existingBlogPost.description), + controller: TextEditingController( + text: existingBlogPost.description, + ), ), const SizedBox(height: 16), TextFormField( key: AppKeys.blogPostCreateScreenVideoLinkTextField, decoration: const InputDecoration(labelText: 'Ссылка на видео'), - controller: TextEditingController(text: existingBlogPost.videoUrl), + controller: TextEditingController( + text: existingBlogPost.videoUrl, + ), ), const SizedBox(height: 16), ElevatedButton( @@ -178,8 +214,9 @@ void main() { title: updateFixture['title'] as String?, videoUrl: updateFixture['video_url'] as String?, description: updateFixture['description_md'] as String?, - image: null, - oldImageUrl: updateFixture['old_image_url'] as String?, + existingMediaIds: List.from( + updateFixture['existing_media_ids'] as List, + ), ); }, child: const Text('Обновить'), @@ -192,9 +229,18 @@ void main() { await tester.pumpWidget(createTestWidget(updateFlowWidget)); await tester.pumpAndSettle(); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), updateFixture['title'] as String); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), updateFixture['description_md'] as String); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), updateFixture['video_url'] as String); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenTitleTextField), + updateFixture['title'] as String, + ); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenContentTextField), + updateFixture['description_md'] as String, + ); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), + updateFixture['video_url'] as String, + ); await tester.pump(); await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton)); @@ -205,7 +251,9 @@ void main() { expect(feedProbe.loadRequests, 1); }); - testWidgets('should handle blog post deletion flow', (WidgetTester tester) async { + testWidgets('should handle blog post deletion flow', ( + WidgetTester tester, + ) async { // Arrange Widget deleteFlowWidget = Column( children: [ @@ -230,35 +278,44 @@ void main() { await tester.pump(); // Assert - final deleteFixture = FixtureReader.readAsMap('blog_post_create/delete_request.json'); + final deleteFixture = FixtureReader.readAsMap( + 'blog_post_create/delete_request.json', + ); expect(blogPostRepository.deleteCalls.single, deleteFixture['id']); expect(feedProbe.loadRequests, 1); }); - testWidgets('should handle error flow during blog post creation', (WidgetTester tester) async { + testWidgets('should handle error flow during blog post creation', ( + WidgetTester tester, + ) async { // Arrange blogPostRepository.createError = Exception('Network error'); - Widget errorFlowWidget = BlocBuilder( - builder: (context, state) { - if (state is BlogPostCreateStateError) { - return Column( - children: [ - Text('Error: ${state.errorMessage}'), - const SizedBox(height: 16), - ElevatedButton( - key: const Key('retryButton'), - onPressed: () async { - await blogPostCreateCubit.createBlogPost(title: 'Retry Title', videoUrl: null, description: 'Retry Description', image: null); - }, - child: const Text('Повторить'), - ), - ], - ); - } - return const Text('No Error'); - }, - ); + Widget errorFlowWidget = + BlocBuilder( + builder: (context, state) { + if (state.errorMessage != null) { + return Column( + children: [ + Text('Error: ${state.errorMessage}'), + const SizedBox(height: 16), + ElevatedButton( + key: const Key('retryButton'), + onPressed: () async { + await blogPostCreateCubit.createBlogPost( + title: 'Retry Title', + videoUrl: null, + description: 'Retry Description', + ); + }, + child: const Text('Повторить'), + ), + ], + ); + } + return const Text('No Error'); + }, + ); // Act await tester.pumpWidget(createTestWidget(errorFlowWidget)); @@ -269,7 +326,6 @@ void main() { title: fullCreateFixture['title'] as String?, videoUrl: null, description: fullCreateFixture['description_md'] as String?, - image: null, ); await tester.pump(); @@ -278,18 +334,27 @@ void main() { expect(find.byKey(const Key('retryButton')), findsOneWidget); }); - testWidgets('should handle loading state during blog post creation', (WidgetTester tester) async { + testWidgets('should handle loading state during blog post creation', ( + WidgetTester tester, + ) async { // Arrange blogPostRepository.createDelay = const Duration(milliseconds: 50); - Widget loadingFlowWidget = BlocBuilder( - builder: (context, state) { - if (state is BlogPostCreateStateLoading) { - return const Column(children: [CircularProgressIndicator(), SizedBox(height: 16), Text('Создание блог поста...')]); - } - return const Text('Not Loading'); - }, - ); + Widget loadingFlowWidget = + BlocBuilder( + builder: (context, state) { + if (state.isLoading) { + return const Column( + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Создание блог поста...'), + ], + ); + } + return const Text('Not Loading'); + }, + ); // Act await tester.pumpWidget(createTestWidget(loadingFlowWidget)); @@ -300,7 +365,6 @@ void main() { title: fullCreateFixture['title'] as String?, videoUrl: null, description: fullCreateFixture['description_md'] as String?, - image: null, ); await tester.pump(); await tester.pump(); // Need to pump twice to get the loading state @@ -314,29 +378,32 @@ void main() { await tester.pumpAndSettle(); }); - testWidgets('should handle success state after blog post creation', (WidgetTester tester) async { + testWidgets('should handle success state after blog post creation', ( + WidgetTester tester, + ) async { // Arrange - Widget successFlowWidget = BlocListener( - listener: (context, state) { - if (state is BlogPostCreateStateLoaded) { - feedProbe.loadFeedBlogPostList(); - } - }, - child: BlocBuilder( - builder: (context, state) { - if (state is BlogPostCreateStateLoaded) { - return const Column( - children: [ - Icon(Icons.check_circle, color: Colors.green, size: 48), - SizedBox(height: 16), - Text('Блог пост успешно создан!'), - ], - ); - } - return const Text('Not Loaded'); - }, - ), - ); + Widget successFlowWidget = + BlocListener( + listener: (context, state) { + if (state.isSuccess) { + feedProbe.loadFeedBlogPostList(); + } + }, + child: BlocBuilder( + builder: (context, state) { + if (state.isSuccess) { + return const Column( + children: [ + Icon(Icons.check_circle, color: Colors.green, size: 48), + SizedBox(height: 16), + Text('Блог пост успешно создан!'), + ], + ); + } + return const Text('Not Loaded'); + }, + ), + ); // Act await tester.pumpWidget(createTestWidget(successFlowWidget)); @@ -347,7 +414,6 @@ void main() { title: fullCreateFixture['title'] as String?, videoUrl: null, description: fullCreateFixture['description_md'] as String?, - image: null, ); await tester.pump(); @@ -357,28 +423,43 @@ void main() { expect(feedProbe.loadRequests, 1); }); - testWidgets('should handle form validation flow', (WidgetTester tester) async { + testWidgets('should handle form validation flow', ( + WidgetTester tester, + ) async { // Arrange Widget validationFlowWidget = Column( children: [ TextFormField( key: AppKeys.blogPostCreateScreenTitleTextField, - decoration: const InputDecoration(labelText: 'Заголовок', errorText: 'Заголовок обязателен'), + decoration: const InputDecoration( + labelText: 'Заголовок', + errorText: 'Заголовок обязателен', + ), ), const SizedBox(height: 16), TextFormField( key: AppKeys.blogPostCreateScreenContentTextField, - decoration: const InputDecoration(labelText: 'Описание', errorText: 'Описание обязательно'), + decoration: const InputDecoration( + labelText: 'Описание', + errorText: 'Описание обязательно', + ), maxLines: 6, minLines: 4, ), const SizedBox(height: 16), TextFormField( key: AppKeys.blogPostCreateScreenVideoLinkTextField, - decoration: const InputDecoration(labelText: 'Ссылка на видео', errorText: 'Введите корректную ссылку'), + decoration: const InputDecoration( + labelText: 'Ссылка на видео', + errorText: 'Введите корректную ссылку', + ), ), const SizedBox(height: 16), - ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать')), + ElevatedButton( + key: AppKeys.blogPostCreateScreenCreateButton, + onPressed: () {}, + child: const Text('Опубликовать'), + ), ], ); @@ -415,7 +496,11 @@ void main() { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.add_photo_alternate, size: 48, color: Colors.grey), + Icon( + Icons.add_photo_alternate, + size: 48, + color: Colors.grey, + ), SizedBox(height: 8), Text('Добавить изображение'), ], @@ -431,7 +516,6 @@ void main() { title: 'Test Title', videoUrl: null, description: 'Test Description', - image: null, // В реальном тесте здесь был бы XFile ); }, child: const Text('Опубликовать'), @@ -450,90 +534,122 @@ void main() { expect(imageUploadInitiated, isTrue); }); - testWidgets('should handle complete blog post creation with all features', (WidgetTester tester) async { + testWidgets('should handle complete blog post creation with all features', ( + WidgetTester tester, + ) async { // Arrange - Widget completeFlowWidget = BlocListener( - listener: (context, state) { - if (state is BlogPostCreateStateLoaded) { - feedProbe.loadFeedBlogPostList(); - } - }, - child: Column( - children: [ - const Text('Заголовок'), - const SizedBox(height: 8), - TextFormField( - key: AppKeys.blogPostCreateScreenTitleTextField, - decoration: const InputDecoration(hintText: 'Введите заголовок поста'), - ), - const SizedBox(height: 16), - const Text('Описание'), - const SizedBox(height: 8), - TextFormField(key: AppKeys.blogPostCreateScreenContentTextField, decoration: const InputDecoration(), maxLines: 6, minLines: 4), - const SizedBox(height: 16), - const Text('Изображение поста'), - const SizedBox(height: 8), - GestureDetector( - key: AppKeys.imageWidget, - onTap: () {}, - child: Container( - height: 200, - width: double.infinity, - decoration: BoxDecoration( - border: Border.all(color: Colors.grey), - borderRadius: BorderRadius.circular(8), - ), - child: const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.add_photo_alternate, size: 48, color: Colors.grey), - SizedBox(height: 8), - Text('Добавить изображение'), - ], + Widget completeFlowWidget = + BlocListener( + listener: (context, state) { + if (state.isSuccess) { + feedProbe.loadFeedBlogPostList(); + } + }, + child: Column( + children: [ + const Text('Заголовок'), + const SizedBox(height: 8), + TextFormField( + key: AppKeys.blogPostCreateScreenTitleTextField, + decoration: const InputDecoration( + hintText: 'Введите заголовок поста', ), ), - ), + const SizedBox(height: 16), + const Text('Описание'), + const SizedBox(height: 8), + TextFormField( + key: AppKeys.blogPostCreateScreenContentTextField, + decoration: const InputDecoration(), + maxLines: 6, + minLines: 4, + ), + const SizedBox(height: 16), + const Text('Изображение поста'), + const SizedBox(height: 8), + GestureDetector( + key: AppKeys.imageWidget, + onTap: () {}, + child: Container( + height: 200, + width: double.infinity, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(8), + ), + child: const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.add_photo_alternate, + size: 48, + color: Colors.grey, + ), + SizedBox(height: 8), + Text('Добавить изображение'), + ], + ), + ), + ), + ), + const SizedBox(height: 16), + const Text('Ссылка на видео'), + const SizedBox(height: 8), + TextFormField( + key: AppKeys.blogPostCreateScreenVideoLinkTextField, + decoration: const InputDecoration(), + ), + const SizedBox(height: 32), + ElevatedButton( + key: AppKeys.blogPostCreateScreenCreateButton, + onPressed: () async { + await blogPostCreateCubit.createBlogPost( + title: fullCreateFixture['title'] as String?, + videoUrl: fullCreateFixture['video_url'] as String?, + description: + fullCreateFixture['description_md'] as String?, + ); + }, + child: const Text('Опубликовать'), + ), + ], ), - const SizedBox(height: 16), - const Text('Ссылка на видео'), - const SizedBox(height: 8), - TextFormField(key: AppKeys.blogPostCreateScreenVideoLinkTextField, decoration: const InputDecoration()), - const SizedBox(height: 32), - ElevatedButton( - key: AppKeys.blogPostCreateScreenCreateButton, - onPressed: () async { - await blogPostCreateCubit.createBlogPost( - title: fullCreateFixture['title'] as String?, - videoUrl: fullCreateFixture['video_url'] as String?, - description: fullCreateFixture['description_md'] as String?, - image: null, - ); - }, - child: const Text('Опубликовать'), - ), - ], - ), - ); + ); // Act await tester.pumpWidget(createTestWidget(completeFlowWidget)); await tester.pumpAndSettle(); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), fullCreateFixture['title'] as String); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), fullCreateFixture['description_md'] as String); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), fullCreateFixture['video_url'] as String); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenTitleTextField), + fullCreateFixture['title'] as String, + ); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenContentTextField), + fullCreateFixture['description_md'] as String, + ); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), + fullCreateFixture['video_url'] as String, + ); await tester.pump(); // Scroll to make sure the button is visible - await tester.drag(find.byType(SingleChildScrollView), const Offset(0, -200)); + await tester.drag( + find.byType(SingleChildScrollView), + const Offset(0, -200), + ); await tester.pump(); await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton)); await tester.pump(); // Assert - expect(blogPostRepository.createPayloads.single.toJson(), fullCreateFixture); + expect( + blogPostRepository.createPayloads.single.toJson(), + fullCreateFixture, + ); expect(feedProbe.loadRequests, 1); }); }); diff --git a/test/features/blog_post_create/presentation/pages/blog_post_create_screen_test.dart b/test/features/blog_post_create/presentation/pages/blog_post_create_screen_test.dart index 13b86880..f429bf80 100644 --- a/test/features/blog_post_create/presentation/pages/blog_post_create_screen_test.dart +++ b/test/features/blog_post_create/presentation/pages/blog_post_create_screen_test.dart @@ -11,6 +11,7 @@ import 'package:eatvio_app/features/blog_post_create/presentation/pages/blog_pos import 'package:eatvio_app/features/blog_post_create/repositories/blog_post_repository.dart'; import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; +import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart'; import 'package:eatvio_app/generated/l10n.dart'; @@ -19,14 +20,15 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; -import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_image_compress/flutter_image_compress.dart'; import 'package:network_image_mock/network_image_mock.dart'; -import 'blog_post_create_screen_test.mocks.dart'; +class MockBlogPostRepository extends Mock implements BlogPostRepository {} + +class MockFeedCubit extends Mock implements FeedCubit {} -@GenerateMocks([BlogPostRepository, BlogPostCreateCubit, FeedCubit]) void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -37,13 +39,22 @@ void main() { setUp(() async { await sl.reset(); - dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); + dotenv.loadFromString( + envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost', + ); AppConfigRegistration.registerAppConfig(buildType: AppBuildType.tests); + registerFallbackValue([]); + registerFallbackValue([]); mockBlogPostRepository = MockBlogPostRepository(); - blogPostCreateCubit = BlogPostCreateCubit(blogPostRepository: mockBlogPostRepository); + blogPostCreateCubit = BlogPostCreateCubit( + blogPostRepository: mockBlogPostRepository, + ); mockFeedCubit = MockFeedCubit(); - when(mockFeedCubit.state).thenReturn(const FeedState.loading()); - when(mockFeedCubit.stream).thenAnswer((_) => Stream.value(const FeedState.loading())); + when(() => mockFeedCubit.state).thenReturn(const FeedState.loading()); + when( + () => mockFeedCubit.stream, + ).thenAnswer((_) => Stream.value(const FeedState.loading())); + when(() => mockFeedCubit.loadFeedBlogPostList()).thenAnswer((_) async {}); sl.registerLazySingleton(() => mockFeedCubit); sl.registerLazySingleton(() => blogPostCreateCubit); }); @@ -52,19 +63,25 @@ void main() { blogPostCreateCubit.close(); }); - Widget createTestWidget({FeedBlogPostModel? feedBlogPostModel, bool canPop = false}) { + Widget createTestWidget({ + FeedBlogPostModel? feedBlogPostModel, + bool canPop = false, + }) { final router = GoRouter( initialLocation: '/blog-post-create', routes: [ GoRoute( path: '/home', - builder: (context, state) => const Scaffold(body: Center(child: Text('Home'))), + builder: (context, state) => + const Scaffold(body: Center(child: Text('Home'))), ), GoRoute( path: '/blog-post-create', builder: (context, state) => MultiBlocProvider( providers: [ - BlocProvider.value(value: blogPostCreateCubit), + BlocProvider.value( + value: blogPostCreateCubit, + ), BlocProvider.value(value: mockFeedCubit), ], child: BlogPostCreateScreen(feedBlogPostModel: feedBlogPostModel), @@ -87,28 +104,46 @@ void main() { ); } - testWidgets('displays all form fields when creating new blog post', (tester) async { + testWidgets('displays all form fields when creating new blog post', ( + tester, + ) async { await tester.pumpWidget(createTestWidget()); await tester.pumpAndSettle(); expect(find.text('Заголовок'), findsOneWidget); expect(find.text('Описание'), findsOneWidget); - expect(find.text('Изображение поста'), findsOneWidget); + expect(find.textContaining('Изображения'), findsOneWidget); expect(find.text('Ссылка на видео'), findsOneWidget); - expect(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), findsOneWidget); - expect(find.byKey(AppKeys.blogPostCreateScreenContentTextField), findsOneWidget); - expect(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), findsOneWidget); + expect( + find.byKey(AppKeys.blogPostCreateScreenTitleTextField), + findsOneWidget, + ); + expect( + find.byKey(AppKeys.blogPostCreateScreenContentTextField), + findsOneWidget, + ); + expect( + find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), + findsOneWidget, + ); }); - testWidgets('pre-fills form fields when editing existing blog post', (tester) async { + testWidgets('pre-fills form fields when editing existing blog post', ( + tester, + ) async { final existingPost = FeedBlogPostModel( id: 1, title: 'Test Title', description: 'Test Description', - videoUrl: 'https://youtube.com/watch?v=test', - image: '', // Changed: removed image so video field is visible + videoUrl: 'https://rutube.ru/video/12345/', + image: '', state: 'published', - author: const Author(authorSlug: 'test-author', authorName: 'Test Author', authorProfileIcon: 'avatar.jpg', authorIsMe: true), + author: const Author( + authorSlug: 'test-author', + authorName: 'Test Author', + authorProfileIcon: 'avatar.jpg', + authorIsMe: true, + ), createdAt: DateTime.now(), updatedAt: DateTime.now(), totalLikes: 0, @@ -117,24 +152,299 @@ void main() { wasLikedByMe: false, ); - await tester.pumpWidget(createTestWidget(feedBlogPostModel: existingPost)); + await tester.pumpWidget( + createTestWidget(feedBlogPostModel: existingPost), + ); await tester.pumpAndSettle(); - final titleField = tester.widget(find.byKey(AppKeys.blogPostCreateScreenTitleTextField)); + final titleField = tester.widget( + find.byKey(AppKeys.blogPostCreateScreenTitleTextField), + ); expect(titleField.controller?.text, 'Test Title'); - final descriptionField = tester.widget(find.byKey(AppKeys.blogPostCreateScreenContentTextField)); + final descriptionField = tester.widget( + find.byKey(AppKeys.blogPostCreateScreenContentTextField), + ); expect(descriptionField.controller?.text, 'Test Description'); - final videoFieldFinder = find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField); - if (videoFieldFinder.evaluate().isNotEmpty) { - final videoField = tester.widget(videoFieldFinder); - expect(videoField.controller?.text, 'https://youtube.com/watch?v=test'); - } + final videoField = tester.widget( + find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), + ); + expect(videoField.controller?.text, 'https://rutube.ru/video/12345/'); + expect(find.text('Сохранить'), findsOneWidget); }); - testWidgets('hides video field when image is present', (tester) async { + testWidgets('shows video and gallery fields together', (tester) async { await mockNetworkImagesFor(() async { + final existingPost = FeedBlogPostModel( + id: 1, + title: 'Test Title', + description: 'Test Description', + videoUrl: 'https://rutube.ru/video/12345/', + image: 'http://localhost/storage/test_image.jpg', + state: 'published', + author: const Author( + authorSlug: 'test-author', + authorName: 'Test Author', + authorProfileIcon: 'http://localhost/storage/avatar.jpg', + authorIsMe: true, + ), + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + totalLikes: 0, + totalViews: 0, + totalComments: 0, + wasLikedByMe: false, + images: const [ + FeedGalleryImage( + id: 10, + url: 'http://localhost/storage/test_image.jpg', + ), + ], + ); + + await tester.pumpWidget( + createTestWidget(feedBlogPostModel: existingPost), + ); + await tester.pumpAndSettle(); + + expect(find.text('Ссылка на видео'), findsOneWidget); + expect( + find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), + findsOneWidget, + ); + expect(find.textContaining('Изображения'), findsOneWidget); + }); + }); + + testWidgets('allows entering text in title field', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenTitleTextField), + 'New Title', + ); + await tester.pump(); + + final titleField = tester.widget( + find.byKey(AppKeys.blogPostCreateScreenTitleTextField), + ); + expect(titleField.controller?.text, 'New Title'); + }); + + testWidgets('allows entering text in description field', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenContentTextField), + 'New Description', + ); + await tester.pump(); + + final descriptionField = tester.widget( + find.byKey(AppKeys.blogPostCreateScreenContentTextField), + ); + expect(descriptionField.controller?.text, 'New Description'); + }); + + testWidgets('allows entering text in video URL field', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), + 'https://youtube.com/watch?v=new', + ); + await tester.pump(); + + final videoField = tester.widget( + find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), + ); + expect(videoField.controller?.text, 'https://youtube.com/watch?v=new'); + }); + + testWidgets('displays create button', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + expect(find.text('Создать'), findsOneWidget); + }); + + testWidgets('does not submit when form is empty', (tester) async { + when( + () => mockBlogPostRepository.createBlogPost( + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + images: any(named: 'images'), + ), + ).thenAnswer((_) async {}); + + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + final createButton = find.text('Создать'); + await tester.ensureVisible(createButton); + await tester.tap(createButton); + await tester.pumpAndSettle(); + + verifyNever( + () => mockBlogPostRepository.createBlogPost( + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + images: any(named: 'images'), + ), + ); + expect( + find.text( + 'Добавьте заголовок, описание, изображение или ссылку на видео', + ), + findsOneWidget, + ); + }); + + testWidgets('does not submit when video url is invalid', (tester) async { + when( + () => mockBlogPostRepository.createBlogPost( + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + images: any(named: 'images'), + ), + ).thenAnswer((_) async {}); + + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenTitleTextField), + 'Test Title', + ); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), + 'https://youtube.com/watch?v=abc', + ); + await tester.pump(); + + final createButton = find.text('Создать'); + await tester.ensureVisible(createButton); + await tester.tap(createButton); + await tester.pumpAndSettle(); + + verifyNever( + () => mockBlogPostRepository.createBlogPost( + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + images: any(named: 'images'), + ), + ); + expect( + find.text('Поддерживаются только Rutube, VK и OK'), + findsOneWidget, + ); + }); + + testWidgets('shows loading state when creating blog post', (tester) async { + when( + () => mockBlogPostRepository.createBlogPost( + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + images: any(named: 'images'), + ), + ).thenAnswer((_) async { + await Future.delayed(const Duration(milliseconds: 100)); + return; + }); + + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenTitleTextField), + 'Test Title', + ); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenContentTextField), + 'Test Description', + ); + await tester.pump(); + + final createButton = find.text('Создать'); + await tester.ensureVisible(createButton); + await tester.tap(createButton); + await tester.pump(); + + expect(find.byType(Loader), findsWidgets); + + await tester.pump(const Duration(milliseconds: 150)); + await tester.pumpAndSettle(); + }); + + testWidgets( + 'calls FeedCubit.loadFeedBlogPostList when blog post is created successfully', + (tester) async { + when( + () => mockBlogPostRepository.createBlogPost( + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + images: any(named: 'images'), + ), + ).thenAnswer((_) async { + return; + }); + + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + final titleField = find.byKey( + AppKeys.blogPostCreateScreenTitleTextField, + ); + final contentField = find.byKey( + AppKeys.blogPostCreateScreenContentTextField, + ); + + await tester.ensureVisible(titleField); + await tester.ensureVisible(contentField); + await tester.pumpAndSettle(); + + await tester.enterText(titleField, 'Test Title'); + await tester.enterText(contentField, 'Test Description'); + await tester.pump(); + + final createButton = find.text('Создать'); + await tester.ensureVisible(createButton); + await tester.tap(createButton, warnIfMissed: false); + await tester.pump(); + + await tester.pump(const Duration(milliseconds: 200)); + await tester.pumpAndSettle(); + + verify(() => mockFeedCubit.loadFeedBlogPostList()).called(1); + }, + ); + + testWidgets('does not submit update when video url is invalid', ( + tester, + ) async { + await mockNetworkImagesFor(() async { + when( + () => mockBlogPostRepository.updateBlogPost( + id: any(named: 'id'), + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + existingMediaIds: any(named: 'existingMediaIds'), + newImages: any(named: 'newImages'), + mediaIdsToDelete: any(named: 'mediaIdsToDelete'), + ), + ).thenAnswer((_) async {}); + final existingPost = FeedBlogPostModel( id: 1, title: 'Test Title', @@ -154,136 +464,140 @@ void main() { totalViews: 0, totalComments: 0, wasLikedByMe: false, + images: const [ + FeedGalleryImage( + id: 10, + url: 'http://localhost/storage/test_image.jpg', + ), + ], ); - await tester.pumpWidget(createTestWidget(feedBlogPostModel: existingPost)); + await tester.pumpWidget( + createTestWidget(feedBlogPostModel: existingPost), + ); await tester.pumpAndSettle(); - expect(find.text('Ссылка на видео'), findsNothing); - expect(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), findsNothing); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), + 'https://youtube.com/watch?v=abc', + ); + await tester.pump(); + + final saveButton = find.text('Сохранить'); + await tester.ensureVisible(saveButton); + await tester.tap(saveButton); + await tester.pumpAndSettle(); + + verifyNever( + () => mockBlogPostRepository.updateBlogPost( + id: any(named: 'id'), + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + existingMediaIds: any(named: 'existingMediaIds'), + newImages: any(named: 'newImages'), + mediaIdsToDelete: any(named: 'mediaIdsToDelete'), + ), + ); + expect( + find.text('Поддерживаются только Rutube, VK и OK'), + findsOneWidget, + ); }); }); - testWidgets('hides image field when video URL is present', (tester) async { - final existingPost = FeedBlogPostModel( - id: 1, - title: 'Test Title', - description: 'Test Description', - videoUrl: 'https://youtube.com/watch?v=test', - image: '', - state: 'published', - author: const Author(authorSlug: 'test-author', authorName: 'Test Author', authorProfileIcon: 'avatar.jpg', authorIsMe: true), - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - totalLikes: 0, - totalViews: 0, - totalComments: 0, - wasLikedByMe: false, - ); - - await tester.pumpWidget(createTestWidget(feedBlogPostModel: existingPost)); - await tester.pumpAndSettle(); - - expect(find.text('Изображение поста'), findsNothing); - }); - - testWidgets('allows entering text in title field', (tester) async { - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), 'New Title'); - await tester.pump(); - - final titleField = tester.widget(find.byKey(AppKeys.blogPostCreateScreenTitleTextField)); - expect(titleField.controller?.text, 'New Title'); - }); - - testWidgets('allows entering text in description field', (tester) async { - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), 'New Description'); - await tester.pump(); - - final descriptionField = tester.widget(find.byKey(AppKeys.blogPostCreateScreenContentTextField)); - expect(descriptionField.controller?.text, 'New Description'); - }); - - testWidgets('allows entering text in video URL field', (tester) async { - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), 'https://youtube.com/watch?v=new'); - await tester.pump(); - - final videoField = tester.widget(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField)); - expect(videoField.controller?.text, 'https://youtube.com/watch?v=new'); - }); - - testWidgets('displays create button', (tester) async { - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - expect(find.text('Создать'), findsOneWidget); - }); - - testWidgets('shows loading state when creating blog post', (tester) async { - when(mockBlogPostRepository.createBlogPost(any, any, any, any)).thenAnswer((_) async { - await Future.delayed(const Duration(milliseconds: 100)); - return; - }); - - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), 'Test Title'); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), 'Test Description'); - await tester.pump(); - - final createButton = find.text('Создать'); - await tester.ensureVisible(createButton); - await tester.tap(createButton); - await tester.pump(); - - expect(find.byType(Loader), findsWidgets); - - await tester.pump(const Duration(milliseconds: 150)); - await tester.pumpAndSettle(); - }); - - testWidgets('calls FeedCubit.loadFeedBlogPostList when blog post is created successfully', (tester) async { - when(mockBlogPostRepository.createBlogPost(any, any, any, any)).thenAnswer((_) async { - return; - }); - - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - final titleField = find.byKey(AppKeys.blogPostCreateScreenTitleTextField); - final contentField = find.byKey(AppKeys.blogPostCreateScreenContentTextField); - - await tester.ensureVisible(titleField); - await tester.ensureVisible(contentField); - await tester.pumpAndSettle(); - - await tester.enterText(titleField, 'Test Title'); - await tester.enterText(contentField, 'Test Description'); - await tester.pump(); - - final createButton = find.text('Создать'); - await tester.ensureVisible(createButton); - await tester.tap(createButton, warnIfMissed: false); - await tester.pump(); - - await tester.pump(const Duration(milliseconds: 200)); - await tester.pumpAndSettle(); - - verify(mockFeedCubit.loadFeedBlogPostList()).called(1); - }); - - testWidgets('calls updateBlogPost when editing existing post', (tester) async { + testWidgets('does not submit update when form content is cleared', ( + tester, + ) async { await mockNetworkImagesFor(() async { - when(mockBlogPostRepository.updateBlogPost(any, any, any, any, any, any)).thenAnswer((_) async { + when( + () => mockBlogPostRepository.updateBlogPost( + id: any(named: 'id'), + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + existingMediaIds: any(named: 'existingMediaIds'), + newImages: any(named: 'newImages'), + mediaIdsToDelete: any(named: 'mediaIdsToDelete'), + ), + ).thenAnswer((_) async {}); + + final existingPost = FeedBlogPostModel( + id: 1, + title: 'Test Title', + description: 'Test Description', + videoUrl: '', + image: '', + state: 'published', + author: const Author( + authorSlug: 'test-author', + authorName: 'Test Author', + authorProfileIcon: 'http://localhost/storage/avatar.jpg', + authorIsMe: true, + ), + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + totalLikes: 0, + totalViews: 0, + totalComments: 0, + wasLikedByMe: false, + ); + + await tester.pumpWidget( + createTestWidget(feedBlogPostModel: existingPost), + ); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenTitleTextField), + '', + ); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenContentTextField), + '', + ); + await tester.pump(); + + final saveButton = find.text('Сохранить'); + await tester.ensureVisible(saveButton); + await tester.tap(saveButton); + await tester.pumpAndSettle(); + + verifyNever( + () => mockBlogPostRepository.updateBlogPost( + id: any(named: 'id'), + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + existingMediaIds: any(named: 'existingMediaIds'), + newImages: any(named: 'newImages'), + mediaIdsToDelete: any(named: 'mediaIdsToDelete'), + ), + ); + expect( + find.text( + 'Добавьте заголовок, описание, изображение или ссылку на видео', + ), + findsOneWidget, + ); + }); + }); + + testWidgets('calls updateBlogPost when editing existing post', ( + tester, + ) async { + await mockNetworkImagesFor(() async { + when( + () => mockBlogPostRepository.updateBlogPost( + id: any(named: 'id'), + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + existingMediaIds: any(named: 'existingMediaIds'), + newImages: any(named: 'newImages'), + mediaIdsToDelete: any(named: 'mediaIdsToDelete'), + ), + ).thenAnswer((_) async { return; }); @@ -306,26 +620,46 @@ void main() { totalViews: 0, totalComments: 0, wasLikedByMe: false, + images: const [ + FeedGalleryImage( + id: 10, + url: 'http://localhost/storage/test_image.jpg', + ), + ], ); - await tester.pumpWidget(createTestWidget(feedBlogPostModel: existingPost, canPop: true)); + await tester.pumpWidget( + createTestWidget(feedBlogPostModel: existingPost, canPop: true), + ); await tester.pumpAndSettle(); - final titleField = find.byKey(AppKeys.blogPostCreateScreenTitleTextField); + final titleField = find.byKey( + AppKeys.blogPostCreateScreenTitleTextField, + ); expect(titleField, findsOneWidget); await tester.enterText(titleField, 'Updated Title'); await tester.pump(); - final createButton = find.text('Создать'); - await tester.ensureVisible(createButton); - await tester.tap(createButton, warnIfMissed: false); + final saveButton = find.text('Сохранить'); + await tester.ensureVisible(saveButton); + await tester.tap(saveButton, warnIfMissed: false); await tester.pump(); await tester.pump(const Duration(milliseconds: 200)); await tester.pumpAndSettle(); - verify(mockBlogPostRepository.updateBlogPost(any, any, any, any, any, any)).called(1); + verify( + () => mockBlogPostRepository.updateBlogPost( + id: any(named: 'id'), + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + existingMediaIds: any(named: 'existingMediaIds'), + newImages: any(named: 'newImages'), + mediaIdsToDelete: any(named: 'mediaIdsToDelete'), + ), + ).called(1); }); }); @@ -344,8 +678,9 @@ void main() { }); testWidgets('renders error state', (tester) async { - - blogPostCreateCubit.emit(const BlogPostCreateState.error(errorMessage: 'Error creating post')); + blogPostCreateCubit.emit( + const BlogPostCreateState(errorMessage: 'Error creating post'), + ); await tester.pumpWidget(createTestWidget()); await tester.pumpAndSettle(); @@ -354,7 +689,7 @@ void main() { }); testWidgets('renders loading state', (tester) async { - blogPostCreateCubit.emit(const BlogPostCreateState.loading()); + blogPostCreateCubit.emit(const BlogPostCreateState(isLoading: true)); await tester.pumpWidget(createTestWidget()); await tester.pumpAndSettle(); diff --git a/test/features/blog_post_create/repositories/blog_post_repository_test.dart b/test/features/blog_post_create/repositories/blog_post_repository_test.dart index 1c6ef9e1..252d6fed 100644 --- a/test/features/blog_post_create/repositories/blog_post_repository_test.dart +++ b/test/features/blog_post_create/repositories/blog_post_repository_test.dart @@ -25,204 +25,172 @@ void main() { test('deleteBlogPost вызывает сервис', () async { const id = 1; - when(() => mockService.deleteBlogPost(id: id)).thenAnswer((_) async => {}); + when( + () => mockService.deleteBlogPost(id: id), + ).thenAnswer((_) async => {}); await repository.deleteBlogPost(id); verify(() => mockService.deleteBlogPost(id: id)).called(1); }); + test('deleteBlogPostMedia вызывает сервис', () async { + when( + () => mockService.deleteBlogPostMedia(postId: 10, mediaId: 20), + ).thenAnswer((_) async => {}); + + await repository.deleteBlogPostMedia(postId: 10, mediaId: 20); + + verify( + () => mockService.deleteBlogPostMedia(postId: 10, mediaId: 20), + ).called(1); + }); + test('createBlogPost вызывает сервис с FormData', () async { - const title = 'Test Title'; - const description = 'Test Description'; - - when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {}); - - await repository.createBlogPost(title, null, description, null); - - verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1); - }); - - test('updateBlogPost вызывает сервис с FormData', () async { - const id = 1; - const title = 'Updated Title'; - when( - () => mockService.updateBlogPost( - id: id, - formData: any(named: 'formData'), - ), + () => mockService.createBlogPost(formData: any(named: 'formData')), ).thenAnswer((_) async => {}); - await repository.updateBlogPost(id, title, null, null, null, null); + await repository.createBlogPost( + title: 'Test Title', + description: 'Test Description', + ); - verify( - () => mockService.updateBlogPost( - id: id, - formData: any(named: 'formData'), - ), - ).called(1); - }); + final captured = + verify( + () => mockService.createBlogPost( + formData: captureAny(named: 'formData'), + ), + ).captured.single + as FormData; - test('createBlogPost - обрабатывает пустые поля', () async { - when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {}); - - await repository.createBlogPost(null, null, null, null); - - verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1); - }); - - test('createBlogPost - обрабатывает только title', () async { - const title = 'Test Title'; - - when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {}); - - await repository.createBlogPost(title, null, null, null); - - verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1); + expect( + captured.fields.map((e) => '${e.key}=${e.value}'), + containsAll(['title=Test Title', 'description_md=Test Description']), + ); + expect(captured.files, isEmpty); }); test('createBlogPost - обрабатывает только videoUrl', () async { - const videoUrl = 'https://example.com/video.mp4'; - - when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {}); - - await repository.createBlogPost(null, videoUrl, null, null); - - verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1); - }); - - test('createBlogPost - обрабатывает только description', () async { - const description = 'Test Description'; - - when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {}); - - await repository.createBlogPost(null, null, description, null); - - verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1); - }); - - test('updateBlogPost - обрабатывает все поля', () async { - const id = 1; - const title = 'Updated Title'; - const videoUrl = 'https://example.com/video.mp4'; - const description = 'Updated Description'; + const videoUrl = 'https://rutube.ru/video/12345/'; when( - () => mockService.updateBlogPost( - id: id, - formData: any(named: 'formData'), - ), + () => mockService.createBlogPost(formData: any(named: 'formData')), ).thenAnswer((_) async => {}); - await repository.updateBlogPost(id, title, videoUrl, description, null, null); + await repository.createBlogPost(videoUrl: videoUrl); - verify( - () => mockService.updateBlogPost( - id: id, - formData: any(named: 'formData'), - ), - ).called(1); + final captured = + verify( + () => mockService.createBlogPost( + formData: captureAny(named: 'formData'), + ), + ).captured.single + as FormData; + + expect( + captured.fields.map((e) => '${e.key}=${e.value}'), + contains('video_url=$videoUrl'), + ); }); + test( + 'updateBlogPost отправляет existing_media_ids и удаляет media пачкой', + () async { + when( + () => mockService.deleteBlogPostMedia( + postId: 1, + mediaId: any(named: 'mediaId'), + ), + ).thenAnswer((_) async => {}); + when( + () => mockService.updateBlogPost( + id: 1, + formData: any(named: 'formData'), + ), + ).thenAnswer((_) async => {}); + + await repository.updateBlogPost( + id: 1, + title: 'Updated Title', + existingMediaIds: [5, 3], + mediaIdsToDelete: [9, 8], + ); + + verify( + () => mockService.deleteBlogPostMedia(postId: 1, mediaId: 9), + ).called(1); + verify( + () => mockService.deleteBlogPostMedia(postId: 1, mediaId: 8), + ).called(1); + + final captured = + verify( + () => mockService.updateBlogPost( + id: 1, + formData: captureAny(named: 'formData'), + ), + ).captured.single + as FormData; + + final fields = captured.fields + .map((e) => '${e.key}=${e.value}') + .toList(); + expect(fields, contains('title=Updated Title')); + expect(fields, contains('description_md=')); + expect(fields, contains('video_url=')); + expect(fields, contains('existing_media_ids[]=5')); + expect(fields, contains('existing_media_ids[]=3')); + }, + ); + + test( + 'updateBlogPost отправляет пустые text fields для очистки на бэке', + () async { + when( + () => mockService.updateBlogPost( + id: 1, + formData: any(named: 'formData'), + ), + ).thenAnswer((_) async => {}); + + await repository.updateBlogPost( + id: 1, + title: '', + description: '', + videoUrl: '', + existingMediaIds: const [], + ); + + final captured = + verify( + () => mockService.updateBlogPost( + id: 1, + formData: captureAny(named: 'formData'), + ), + ).captured.single + as FormData; + + final fields = captured.fields + .map((e) => '${e.key}=${e.value}') + .toList(); + expect(fields, contains('title=')); + expect(fields, contains('description_md=')); + expect(fields, contains('video_url=')); + }, + ); + test('updateBlogPost - обрабатывает пустые поля', () async { - const id = 1; - when( - () => mockService.updateBlogPost( - id: id, - formData: any(named: 'formData'), - ), + () => + mockService.updateBlogPost(id: 1, formData: any(named: 'formData')), ).thenAnswer((_) async => {}); - await repository.updateBlogPost(id, null, null, null, null, null); + await repository.updateBlogPost(id: 1); verify( - () => mockService.updateBlogPost( - id: id, - formData: any(named: 'formData'), - ), - ).called(1); - }); - - test('createBlogPost - обрабатывает пустые поля', () async { - when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {}); - - await repository.createBlogPost(null, null, null, null); - - verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1); - }); - - test('createBlogPost - обрабатывает только title', () async { - const title = 'Test Title'; - - when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {}); - - await repository.createBlogPost(title, null, null, null); - - verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1); - }); - - test('createBlogPost - обрабатывает только videoUrl', () async { - const videoUrl = 'https://example.com/video.mp4'; - - when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {}); - - await repository.createBlogPost(null, videoUrl, null, null); - - verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1); - }); - - test('createBlogPost - обрабатывает только description', () async { - const description = 'Test Description'; - - when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {}); - - await repository.createBlogPost(null, null, description, null); - - verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1); - }); - - test('updateBlogPost - обрабатывает все поля', () async { - const id = 1; - const title = 'Updated Title'; - const videoUrl = 'https://example.com/video.mp4'; - const description = 'Updated Description'; - - when( - () => mockService.updateBlogPost( - id: id, - formData: any(named: 'formData'), - ), - ).thenAnswer((_) async => {}); - - await repository.updateBlogPost(id, title, videoUrl, description, null, null); - - verify( - () => mockService.updateBlogPost( - id: id, - formData: any(named: 'formData'), - ), - ).called(1); - }); - - test('updateBlogPost - обрабатывает пустые поля', () async { - const id = 1; - - when( - () => mockService.updateBlogPost( - id: id, - formData: any(named: 'formData'), - ), - ).thenAnswer((_) async => {}); - - await repository.updateBlogPost(id, null, null, null, null, null); - - verify( - () => mockService.updateBlogPost( - id: id, - formData: any(named: 'formData'), - ), + () => + mockService.updateBlogPost(id: 1, formData: any(named: 'formData')), ).called(1); }); }); diff --git a/test/features/blog_post_create/services/blog_post_service_test.dart b/test/features/blog_post_create/services/blog_post_service_test.dart index cbfcef01..8eca643f 100644 --- a/test/features/blog_post_create/services/blog_post_service_test.dart +++ b/test/features/blog_post_create/services/blog_post_service_test.dart @@ -18,8 +18,9 @@ void main() { test('createBlogPost - создает пост блога', () async { final formData = FormData.fromMap({}); - when(mockBlogPostService.createBlogPost(formData: formData)) - .thenAnswer((_) async => Future.value()); + when( + mockBlogPostService.createBlogPost(formData: formData), + ).thenAnswer((_) async => Future.value()); await mockBlogPostService.createBlogPost(formData: formData); @@ -30,19 +31,35 @@ void main() { const id = 1; final formData = FormData.fromMap({}); - when(mockBlogPostService.updateBlogPost(id: id, formData: formData)) - .thenAnswer((_) async => Future.value()); + when( + mockBlogPostService.updateBlogPost(id: id, formData: formData), + ).thenAnswer((_) async => Future.value()); await mockBlogPostService.updateBlogPost(id: id, formData: formData); - verify(mockBlogPostService.updateBlogPost(id: id, formData: formData)).called(1); + verify( + mockBlogPostService.updateBlogPost(id: id, formData: formData), + ).called(1); + }); + + test('deleteBlogPostMedia - удаляет изображение поста', () async { + when( + mockBlogPostService.deleteBlogPostMedia(postId: 1, mediaId: 2), + ).thenAnswer((_) async => Future.value()); + + await mockBlogPostService.deleteBlogPostMedia(postId: 1, mediaId: 2); + + verify( + mockBlogPostService.deleteBlogPostMedia(postId: 1, mediaId: 2), + ).called(1); }); test('deleteBlogPost - удаляет пост блога', () async { const id = 1; - when(mockBlogPostService.deleteBlogPost(id: id)) - .thenAnswer((_) async => Future.value()); + when( + mockBlogPostService.deleteBlogPost(id: id), + ).thenAnswer((_) async => Future.value()); await mockBlogPostService.deleteBlogPost(id: id); @@ -50,4 +67,3 @@ void main() { }); }); } - diff --git a/test/features/blog_post_create/validation/blog_post_form_validator_test.dart b/test/features/blog_post_create/validation/blog_post_form_validator_test.dart new file mode 100644 index 00000000..27bfe366 --- /dev/null +++ b/test/features/blog_post_create/validation/blog_post_form_validator_test.dart @@ -0,0 +1,191 @@ +import 'package:eatvio_app/features/blog_post_create/validation/blog_post_form_validator.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('BlogPostFormValidator.title', () { + test('allows empty', () { + expect(BlogPostFormValidator.title(''), isNull); + expect(BlogPostFormValidator.title(' '), isNull); + expect(BlogPostFormValidator.title(null), isNull); + }); + + test('rejects shorter than 3 chars', () { + expect( + BlogPostFormValidator.title('ab'), + BlogPostFormValidator.titleTooShort, + ); + expect( + BlogPostFormValidator.title(' a '), + BlogPostFormValidator.titleTooShort, + ); + }); + + test('allows 3+ chars', () { + expect(BlogPostFormValidator.title('abc'), isNull); + expect(BlogPostFormValidator.title(' hello '), isNull); + }); + }); + + group('BlogPostFormValidator.description', () { + test('allows empty and rejects short', () { + expect(BlogPostFormValidator.description(''), isNull); + expect( + BlogPostFormValidator.description('xy'), + BlogPostFormValidator.descriptionTooShort, + ); + expect(BlogPostFormValidator.description('xyz'), isNull); + }); + }); + + group('BlogPostFormValidator.videoUrl', () { + test('allows empty', () { + expect(BlogPostFormValidator.videoUrl(''), isNull); + expect(BlogPostFormValidator.videoUrl(' '), isNull); + }); + + test('rejects unsupported hosts', () { + expect( + BlogPostFormValidator.videoUrl('https://youtube.com/watch?v=abc'), + BlogPostFormValidator.videoUrlUnsupportedHost, + ); + expect( + BlogPostFormValidator.videoUrl('https://rutube.ru.evil.com/video/abc'), + BlogPostFormValidator.videoUrlUnsupportedHost, + ); + }); + + test('accepts rutube / vk / ok formats', () { + expect( + BlogPostFormValidator.videoUrl('https://rutube.ru/video/abc123/'), + isNull, + ); + expect( + BlogPostFormValidator.videoUrl('https://www.rutube.ru/shorts/abc123'), + isNull, + ); + expect( + BlogPostFormValidator.videoUrl('https://vk.com/video-123_456'), + isNull, + ); + expect( + BlogPostFormValidator.videoUrl('https://vkvideo.ru/video123_456/'), + isNull, + ); + expect( + BlogPostFormValidator.videoUrl('https://ok.ru/video/12345'), + isNull, + ); + }); + + test('rejects invalid path formats for allowed hosts', () { + expect( + BlogPostFormValidator.videoUrl('https://rutube.ru/channel/abc'), + BlogPostFormValidator.videoUrlRutubeFormat, + ); + expect( + BlogPostFormValidator.videoUrl('https://vk.com/feed'), + BlogPostFormValidator.videoUrlVkFormat, + ); + expect( + BlogPostFormValidator.videoUrl('https://ok.ru/profile/1'), + BlogPostFormValidator.videoUrlOkFormat, + ); + }); + + test('rejects malformed urls', () { + expect( + BlogPostFormValidator.videoUrl('not-a-url'), + BlogPostFormValidator.videoUrlInvalid, + ); + expect( + BlogPostFormValidator.videoUrl('ftp://rutube.ru/video/abc'), + BlogPostFormValidator.videoUrlInvalid, + ); + }); + }); + + group('BlogPostFormValidator.contentRequiredMessage', () { + test('requires at least one filled field', () { + expect( + BlogPostFormValidator.contentRequiredMessage( + title: '', + description: '', + videoUrl: '', + imageCount: 0, + ), + BlogPostFormValidator.contentRequired, + ); + }); + + test('accepts any valid content source', () { + expect( + BlogPostFormValidator.contentRequiredMessage( + title: 'abc', + description: '', + videoUrl: '', + imageCount: 0, + ), + isNull, + ); + expect( + BlogPostFormValidator.contentRequiredMessage( + title: '', + description: 'abc', + videoUrl: '', + imageCount: 0, + ), + isNull, + ); + expect( + BlogPostFormValidator.contentRequiredMessage( + title: '', + description: '', + videoUrl: '', + imageCount: 1, + ), + isNull, + ); + expect( + BlogPostFormValidator.contentRequiredMessage( + title: '', + description: '', + videoUrl: 'https://rutube.ru/video/abc123/', + imageCount: 0, + ), + isNull, + ); + }); + + test('does not count short title or invalid video as content', () { + expect( + BlogPostFormValidator.contentRequiredMessage( + title: 'ab', + description: '', + videoUrl: 'https://youtube.com/watch?v=1', + imageCount: 0, + ), + BlogPostFormValidator.contentRequired, + ); + }); + }); + + group('BlogPostFormValidator.imagePaths', () { + test('allows jpg/jpeg/png', () { + expect( + BlogPostFormValidator.imagePaths(['a.jpg', 'b.JPEG', 'c.png']), + isNull, + ); + }); + + test('rejects other extensions', () { + expect( + BlogPostFormValidator.imagePaths(['a.webp']), + BlogPostFormValidator.imageTypeInvalid, + ); + expect( + BlogPostFormValidator.imagePaths(['noext']), + BlogPostFormValidator.imageTypeInvalid, + ); + }); + }); +} diff --git a/test/fixtures/blog_post_create/create_request_full.json b/test/fixtures/blog_post_create/create_request_full.json index d329141e..bd6d15b6 100644 --- a/test/fixtures/blog_post_create/create_request_full.json +++ b/test/fixtures/blog_post_create/create_request_full.json @@ -1,7 +1,6 @@ { "title": "Complete Test Title", "description_md": "Complete Test Description", - "video_url": "https://youtube.com/watch?v=complete", + "video_url": "https://rutube.ru/video/12345/", "image_name": null } - diff --git a/test/fixtures/blog_post_create/update_request.json b/test/fixtures/blog_post_create/update_request.json index c3185f69..ae633780 100644 --- a/test/fixtures/blog_post_create/update_request.json +++ b/test/fixtures/blog_post_create/update_request.json @@ -2,8 +2,7 @@ "id": 1, "title": "Updated Title", "description_md": "Updated Description", - "video_url": "https://youtube.com/watch?v=updated", - "old_image_url": "original_image.jpg", + "video_url": "https://vk.com/video-1_1", + "existing_media_ids": [2, 1], "image_name": null } - diff --git a/test/fixtures/doubles/blog_post_doubles.dart b/test/fixtures/doubles/blog_post_doubles.dart index d79ca54c..ed59b75f 100644 --- a/test/fixtures/doubles/blog_post_doubles.dart +++ b/test/fixtures/doubles/blog_post_doubles.dart @@ -4,12 +4,12 @@ import 'package:eatvio_app/features/blog_post_create/services/blog_post_service. import 'package:flutter_image_compress/flutter_image_compress.dart'; class FixtureBlogPostRepository extends BlogPostRepository { - FixtureBlogPostRepository() - : super(blogPostService: _NoopBlogPostService()); + FixtureBlogPostRepository() : super(blogPostService: _NoopBlogPostService()); final List createPayloads = []; final List updatePayloads = []; final List deleteCalls = []; + final List<({int postId, int mediaId})> deleteMediaCalls = <({int postId, int mediaId})>[]; Duration createDelay = Duration.zero; Duration updateDelay = Duration.zero; @@ -18,15 +18,21 @@ class FixtureBlogPostRepository extends BlogPostRepository { Object? createError; Object? updateError; Object? deleteError; + Object? deleteMediaError; @override - Future createBlogPost(String? title, String? videoUrl, String? description, XFile? image) async { + Future createBlogPost({ + String? title, + String? videoUrl, + String? description, + List images = const [], + }) async { createPayloads.add( BlogPostCreatePayload( title: title, description: description, videoUrl: videoUrl, - imageName: image?.name, + imageName: images.isEmpty ? null : images.first.name, ), ); if (createDelay > Duration.zero) { @@ -38,15 +44,26 @@ class FixtureBlogPostRepository extends BlogPostRepository { } @override - Future updateBlogPost(int id, String? title, String? videoUrl, String? description, XFile? image, String? oldImageUrl) async { + Future updateBlogPost({ + required int id, + String? title, + String? videoUrl, + String? description, + List existingMediaIds = const [], + List newImages = const [], + List mediaIdsToDelete = const [], + }) async { + for (final mediaId in mediaIdsToDelete) { + deleteMediaCalls.add((postId: id, mediaId: mediaId)); + } updatePayloads.add( BlogPostUpdatePayload( id: id, title: title, description: description, videoUrl: videoUrl, - imageName: image?.name, - oldImageUrl: oldImageUrl, + imageName: newImages.isEmpty ? null : newImages.first.name, + existingMediaIds: existingMediaIds, ), ); if (updateDelay > Duration.zero) { @@ -97,7 +114,7 @@ class BlogPostUpdatePayload { required this.description, required this.videoUrl, required this.imageName, - required this.oldImageUrl, + required this.existingMediaIds, }); final int id; @@ -105,15 +122,15 @@ class BlogPostUpdatePayload { final String? description; final String? videoUrl; final String? imageName; - final String? oldImageUrl; + final List existingMediaIds; Map toJson() => { 'id': id, 'title': title, 'description_md': description, 'video_url': videoUrl, + 'existing_media_ids': existingMediaIds, 'image_name': imageName, - 'old_image_url': oldImageUrl, }; } @@ -128,6 +145,11 @@ class _NoopBlogPostService implements BlogPostService { throw UnimplementedError('Fixture overrides repository methods directly.'); } + @override + Future deleteBlogPostMedia({required int postId, required int mediaId}) { + throw UnimplementedError('Fixture overrides repository methods directly.'); + } + @override Future updateBlogPost({required int id, required FormData formData}) { throw UnimplementedError('Fixture overrides repository methods directly.'); @@ -141,4 +163,3 @@ class FeedReloadProbe { loadRequests += 1; } } - -- 2.49.1 From af5d182a7913ccbc277a5bc2a0bde414bcba400e Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 4 Aug 2026 17:48:08 +0300 Subject: [PATCH 75/88] fix: ci/cd fix --- .gitea/workflows/test-flight-cd.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/test-flight-cd.yaml b/.gitea/workflows/test-flight-cd.yaml index ddf59034..5662a00b 100644 --- a/.gitea/workflows/test-flight-cd.yaml +++ b/.gitea/workflows/test-flight-cd.yaml @@ -59,10 +59,11 @@ jobs: - name: Flutter build run: | flutter --version + rm -rf ios/Podfile.lock flutter clean flutter pub get flutter pub run build_runner build --delete-conflicting-outputs - flutter build ios --config-only --release + flutter build ios --config-only --release --no-spm cd ios pod install fastlane beta -- 2.49.1 From 6292db07a127e13c75b4185c4b8eb8d74443723a Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 4 Aug 2026 17:51:58 +0300 Subject: [PATCH 76/88] fix: removed spm flag --- .gitea/workflows/test-flight-cd.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/test-flight-cd.yaml b/.gitea/workflows/test-flight-cd.yaml index 5662a00b..e5655092 100644 --- a/.gitea/workflows/test-flight-cd.yaml +++ b/.gitea/workflows/test-flight-cd.yaml @@ -63,7 +63,7 @@ jobs: flutter clean flutter pub get flutter pub run build_runner build --delete-conflicting-outputs - flutter build ios --config-only --release --no-spm + flutter build ios --config-only --release cd ios pod install fastlane beta -- 2.49.1 From 5b1beafbabc5fae4fbcf2fd986e9e9ff15a60035 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 4 Aug 2026 18:12:55 +0300 Subject: [PATCH 77/88] fix: removed SPM --- .gitea/workflows/test-flight-cd.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitea/workflows/test-flight-cd.yaml b/.gitea/workflows/test-flight-cd.yaml index e5655092..776edda1 100644 --- a/.gitea/workflows/test-flight-cd.yaml +++ b/.gitea/workflows/test-flight-cd.yaml @@ -59,6 +59,7 @@ jobs: - name: Flutter build run: | flutter --version + flutter config --no-enable-swift-package-manager rm -rf ios/Podfile.lock flutter clean flutter pub get -- 2.49.1 From a5fcf62524c21c9f021c428aaaec71f23d82c256 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 4 Aug 2026 18:19:10 +0300 Subject: [PATCH 78/88] fix: added pods updating --- .gitea/workflows/test-flight-cd.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/test-flight-cd.yaml b/.gitea/workflows/test-flight-cd.yaml index 776edda1..808e463e 100644 --- a/.gitea/workflows/test-flight-cd.yaml +++ b/.gitea/workflows/test-flight-cd.yaml @@ -64,9 +64,10 @@ jobs: flutter clean flutter pub get flutter pub run build_runner build --delete-conflicting-outputs - flutter build ios --config-only --release cd ios - pod install + pod install --repo-update + cd .. + flutter build ios --config-only --release fastlane beta env: LANG: en_US.UTF-8 -- 2.49.1 From 53426c858c7591c34b11db3205d2c671fdc922fb Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 4 Aug 2026 18:35:32 +0300 Subject: [PATCH 79/88] fix: fixed script run time location --- .gitea/workflows/test-flight-cd.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitea/workflows/test-flight-cd.yaml b/.gitea/workflows/test-flight-cd.yaml index 808e463e..d93dc884 100644 --- a/.gitea/workflows/test-flight-cd.yaml +++ b/.gitea/workflows/test-flight-cd.yaml @@ -68,6 +68,7 @@ jobs: pod install --repo-update cd .. flutter build ios --config-only --release + cd ios fastlane beta env: LANG: en_US.UTF-8 -- 2.49.1 From 478a53674a3038eb71cb9e5c01b7250caf6d7f61 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 5 Aug 2026 15:21:23 +0300 Subject: [PATCH 80/88] feat(post_creatin): fixed animation widget for profile feed --- .../presentation/pages/blog_post_screen.dart | 287 ++++++++++++++---- .../presentation/pages/photo_view_screen.dart | 50 ++- .../widgets/blog_post_media_content.dart | 85 ++++-- .../widgets/blog_post_widget.dart | 126 ++++++-- .../widgets/profile_blog_post_widget.dart | 137 +++++++-- .../profile_blog_post_widget_smoke_test.dart | 204 ++++++++++++- 6 files changed, 727 insertions(+), 162 deletions(-) diff --git a/lib/features/blog_post/presentation/pages/blog_post_screen.dart b/lib/features/blog_post/presentation/pages/blog_post_screen.dart index 9067738c..e8840cd2 100644 --- a/lib/features/blog_post/presentation/pages/blog_post_screen.dart +++ b/lib/features/blog_post/presentation/pages/blog_post_screen.dart @@ -26,7 +26,11 @@ class BlogPostScreen extends StatefulWidget { final int id; final int? highlightedCommentId; - const BlogPostScreen({super.key, required this.id, this.highlightedCommentId}); + const BlogPostScreen({ + super.key, + required this.id, + this.highlightedCommentId, + }); @override State createState() => _BlogPostScreenState(); @@ -44,8 +48,11 @@ class _BlogPostScreenState extends State { super.initState(); } - void _openGallery(List imageUrls, int index) { - context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index)); + void _openGallery(List imageUrls, int index, {Object? heroTag}) { + context.pushTransparentRoute( + rootNavigator: true, + PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag), + ); } @override @@ -53,7 +60,8 @@ class _BlogPostScreenState extends State { return BlocProvider( create: (context) => sl()..getBlogPost(widget.id), child: BlocProvider( - create: (BuildContext context) => commentsCubit..getComments(widget.id.toString(), CommentSourceType.blogPost, true), + create: (BuildContext context) => commentsCubit + ..getComments(widget.id.toString(), CommentSourceType.blogPost, true), child: BlocBuilder( builder: (context, blogPostState) { return BlocConsumer( @@ -81,15 +89,23 @@ class _BlogPostScreenState extends State { body: const Loader(), ); } - final bool isLiked = blogPostState.blogPostModel?.wasLikedByMe ?? false; - final int totalLikes = blogPostState.blogPostModel?.totalLikes ?? 0; + final bool isLiked = + blogPostState.blogPostModel?.wasLikedByMe ?? false; + final int totalLikes = + blogPostState.blogPostModel?.totalLikes ?? 0; return Scaffold( resizeToAvoidBottomInset: true, bottomSheet: Container( - padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 8), + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).padding.bottom + 8, + ), decoration: BoxDecoration( color: Colors.white, - border: Border(top: BorderSide(color: Colors.black.withValues(alpha: 0.1))), + border: Border( + top: BorderSide( + color: Colors.black.withValues(alpha: 0.1), + ), + ), ), child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), @@ -107,10 +123,17 @@ class _BlogPostScreenState extends State { FocusManager.instance.primaryFocus?.unfocus(); }, child: Container( - decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle), + decoration: BoxDecoration( + color: appTheme.blueA400, + shape: BoxShape.circle, + ), height: 32, width: 32, - child: const Icon(Icons.close_rounded, size: 18, color: Colors.white), + child: const Icon( + Icons.close_rounded, + size: 18, + color: Colors.white, + ), ), ), if (replyId != null) const SizedBox(width: 8), @@ -118,7 +141,8 @@ class _BlogPostScreenState extends State { child: ConstrainedBox( constraints: const BoxConstraints(minHeight: 52), child: TextFormField( - textCapitalization: TextCapitalization.sentences, + textCapitalization: + TextCapitalization.sentences, onChanged: (_) { setState(() {}); }, @@ -126,26 +150,48 @@ class _BlogPostScreenState extends State { maxLines: 5, minLines: 1, keyboardType: TextInputType.multiline, - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: Colors.black, height: 1.2), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w400, + color: Colors.black, + height: 1.2, + ), decoration: InputDecoration( isDense: false, // убираем true, чтобы padding учитывался корректно - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 16, + ), // вертикальный паддинг увеличен hintText: S.of(context).comment_hint, - hintStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, height: 1.2, color: Color(0xFFAEAEB2)), + hintStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w400, + height: 1.2, + color: Color(0xFFAEAEB2), + ), filled: true, border: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), + borderSide: const BorderSide( + color: Color(0xFFD1D1D6), + width: 1, + ), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), + borderSide: const BorderSide( + color: Color(0xFFD1D1D6), + width: 1, + ), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), + borderSide: const BorderSide( + color: Color(0xFFD1D1D6), + width: 1, + ), ), ), ), @@ -163,7 +209,10 @@ class _BlogPostScreenState extends State { ); if (!context.mounted) return; - if (isSent) context.read().incrementCommentsState(widget.id); + if (isSent) + context + .read() + .incrementCommentsState(widget.id); context.read().setReplyId(null); setState(() { @@ -174,10 +223,19 @@ class _BlogPostScreenState extends State { } }, child: Container( - decoration: BoxDecoration(color: textEditingController.text.length > 2 ? appTheme.blueA400 : Colors.grey, shape: BoxShape.circle), + decoration: BoxDecoration( + color: textEditingController.text.length > 2 + ? appTheme.blueA400 + : Colors.grey, + shape: BoxShape.circle, + ), height: 32, width: 32, - child: const Icon(Icons.send_rounded, size: 18, color: Colors.white), + child: const Icon( + Icons.send_rounded, + size: 18, + color: Colors.white, + ), ), ), ], @@ -197,23 +255,33 @@ class _BlogPostScreenState extends State { ), actions: [ CustomPopup( - isAuthor: blogPostState.blogPostModel?.author.authorIsMe ?? false, + isAuthor: + blogPostState.blogPostModel?.author.authorIsMe ?? + false, complainedContentType: ComplainedContent.posts, path: 'blog_post/${(widget.id)}', id: widget.id, feedBlogPostModel: blogPostState.blogPostModel, - userSlug: blogPostState.blogPostModel?.author.authorSlug ?? '', + userSlug: + blogPostState.blogPostModel?.author.authorSlug ?? + '', ), const SizedBox(width: 16), ], ), body: SingleChildScrollView( - padding: EdgeInsets.only(bottom: 160 + MediaQuery.of(context).padding.bottom), // поле комментария (52-200) + padding (12+12+56) + safe area + padding: EdgeInsets.only( + bottom: 160 + MediaQuery.of(context).padding.bottom, + ), // поле комментария (52-200) + padding (12+12+56) + safe area physics: const BouncingScrollPhysics(), child: Column( children: [ GestureDetector( - onTap: () => GoRouter.of(context).pushNamed('profileScreen', extra: blogPostState.blogPostModel?.author.authorSlug), + onTap: () => GoRouter.of(context).pushNamed( + 'profileScreen', + extra: + blogPostState.blogPostModel?.author.authorSlug, + ), child: Ink( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( @@ -222,21 +290,46 @@ class _BlogPostScreenState extends State { children: [ CircleAvatar( radius: 16, - foregroundImage: NetworkImage(sl().baseUrl + (blogPostState.blogPostModel?.author.authorProfileIcon ?? '')), - backgroundImage: const AssetImage('assets/images/no_avatar.png'), + foregroundImage: NetworkImage( + sl().baseUrl + + (blogPostState + .blogPostModel + ?.author + .authorProfileIcon ?? + ''), + ), + backgroundImage: const AssetImage( + 'assets/images/no_avatar.png', + ), ), const SizedBox(width: 8), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - blogPostState.blogPostModel?.author.authorName ?? '', - style: const TextStyle(color: Colors.black, fontSize: 14, fontWeight: FontWeight.w400, height: 1), + blogPostState + .blogPostModel + ?.author + .authorName ?? + '', + style: const TextStyle( + color: Colors.black, + fontSize: 14, + fontWeight: FontWeight.w400, + height: 1, + ), ), const SizedBox(height: 2), Text( - (blogPostState.blogPostModel?.createdAt ?? DateTime.now()).format('dd MMMM', 'ru'), - style: TextStyle(color: Colors.grey.shade600, fontSize: 12, fontWeight: FontWeight.w400, height: 1), + (blogPostState.blogPostModel?.createdAt ?? + DateTime.now()) + .format('dd MMMM', 'ru'), + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 12, + fontWeight: FontWeight.w400, + height: 1, + ), ), ], ), @@ -244,10 +337,20 @@ class _BlogPostScreenState extends State { ), ), ), - if (blogPostState.blogPostModel?.description.isNotEmpty ?? false) + if (blogPostState + .blogPostModel + ?.description + .isNotEmpty ?? + false) Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: MarkdownHtmlWidget(markdownText: blogPostState.blogPostModel!.description), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: MarkdownHtmlWidget( + markdownText: + blogPostState.blogPostModel!.description, + ), ), const SizedBox(height: 8), Builder( @@ -256,15 +359,31 @@ class _BlogPostScreenState extends State { if (post == null) { return const SizedBox.shrink(); } - final imageUrl = PostFeedMapper.resolveMediaUrl(post.image); + final imageUrl = PostFeedMapper.resolveMediaUrl( + post.image, + ); final embedUrl = post.videoEmbedUrl?.trim(); return BlogPostMediaContent( galleryImages: post.images, imageUrl: imageUrl, - videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null, - videoUrlThumb: post.videoUrlThumb != null ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) : null, + videoEmbedUrl: + embedUrl != null && embedUrl.isNotEmpty + ? embedUrl + : null, + videoUrlThumb: post.videoUrlThumb != null + ? PostFeedMapper.resolveMediaUrl( + post.videoUrlThumb, + ) + : null, currentGalleryIndex: currentGalleryIndex, - onOpenGallery: _openGallery, + heroTag: 'blog-post-screen-${post.id}-cover', + onOpenGallery: (urls, index) => _openGallery( + urls, + index, + heroTag: urls.length == 1 + ? 'blog-post-screen-${post.id}-cover' + : null, + ), onGalleryChanged: (index) { setState(() { currentGalleryIndex = index; @@ -274,22 +393,43 @@ class _BlogPostScreenState extends State { }, ), Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), child: Row( children: [ _iconButton( - icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded, + icon: isLiked + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, text: totalLikes.toString(), - iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null, - backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null, - textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null, + iconColor: isLiked + ? Colors.red.withValues(alpha: 0.8) + : null, + backgroundColor: isLiked + ? Colors.red.withValues(alpha: 0.1) + : null, + textColor: isLiked + ? Colors.red.withValues(alpha: 0.8) + : null, onTap: () async { - await context.read().toggleLike(); + await context + .read() + .toggleLike(); // ignore: use_build_context_synchronously - final updated = context.read().state.blogPostModel; + final updated = context + .read() + .state + .blogPostModel; if (updated != null) { // ignore: use_build_context_synchronously - context.read().updateFeedBlogPostList(updated, updated.wasLikedByMe); + context + .read() + .updateFeedBlogPostList( + updated, + updated.wasLikedByMe, + ); } }, ), @@ -297,21 +437,36 @@ class _BlogPostScreenState extends State { _iconButton( icon: Icons.reply_rounded, onTap: () { - final box = context.findRenderObject() as RenderBox?; + final box = + context.findRenderObject() as RenderBox?; SharePlus.instance.share( ShareParams( - uri: Uri.parse('${sl().baseUrl}/ru/blog_post/${widget.id}'), - sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, + uri: Uri.parse( + '${sl().baseUrl}/ru/blog_post/${widget.id}', + ), + sharePositionOrigin: + box!.localToGlobal(Offset.zero) & + box.size, ), ); }, ), const Spacer(), - Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500), + Icon( + Icons.visibility_rounded, + size: 16, + color: appTheme.gray500, + ), const SizedBox(width: 4), Text( - blogPostState.blogPostModel?.totalViews.toString() ?? '', - style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400), + blogPostState.blogPostModel?.totalViews + .toString() ?? + '', + style: TextStyle( + fontSize: 12, + color: appTheme.gray500, + fontWeight: FontWeight.w400, + ), ), ], ), @@ -319,7 +474,10 @@ class _BlogPostScreenState extends State { Container( width: MediaQuery.of(context).size.width - 32, height: 0.4, - decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(16)), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(16), + ), ), const SizedBox(height: 8), Container( @@ -327,7 +485,11 @@ class _BlogPostScreenState extends State { alignment: Alignment.centerLeft, child: Text( '${blogPostState.blogPostModel?.totalComments} КОММЕНТАРИЕВ', - style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400), + style: TextStyle( + fontSize: 12, + color: appTheme.gray500, + fontWeight: FontWeight.w400, + ), ), ), const SizedBox(height: 16), @@ -352,13 +514,23 @@ class _BlogPostScreenState extends State { ); } - InkWell _iconButton({required IconData icon, String? text, Function()? onTap, Color? iconColor, Color? backgroundColor, Color? textColor}) { + InkWell _iconButton({ + required IconData icon, + String? text, + Function()? onTap, + Color? iconColor, + Color? backgroundColor, + Color? textColor, + }) { return InkWell( onTap: onTap ?? () {}, borderRadius: BorderRadius.circular(16), child: Ink( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - decoration: BoxDecoration(color: backgroundColor ?? appTheme.gray100, borderRadius: BorderRadius.circular(16)), + decoration: BoxDecoration( + color: backgroundColor ?? appTheme.gray100, + borderRadius: BorderRadius.circular(16), + ), height: 32, child: Row( mainAxisSize: MainAxisSize.min, @@ -368,7 +540,10 @@ class _BlogPostScreenState extends State { if (text != null) Text( text, - style: TextStyle(color: textColor ?? appTheme.gray600, fontWeight: FontWeight.w500), + style: TextStyle( + color: textColor ?? appTheme.gray600, + fontWeight: FontWeight.w500, + ), ), ], ), diff --git a/lib/features/chat/presentation/pages/photo_view_screen.dart b/lib/features/chat/presentation/pages/photo_view_screen.dart index 3cbdc744..b9910feb 100644 --- a/lib/features/chat/presentation/pages/photo_view_screen.dart +++ b/lib/features/chat/presentation/pages/photo_view_screen.dart @@ -14,11 +14,15 @@ class PhotoViewScreen extends StatefulWidget { final int index; final bool isLocal; + /// When set, used instead of the image URL so source/destination tags stay unique. + final Object? heroTag; + const PhotoViewScreen({ super.key, required this.imageUrls, required this.index, this.isLocal = false, + this.heroTag, }); @override @@ -36,6 +40,23 @@ class _PhotoViewScreenState extends State { super.initState(); } + PhotoViewHeroAttributes? _heroAttributesFor(int index) { + if (widget.heroTag != null) { + if (index != widget.index) { + return null; + } + return PhotoViewHeroAttributes( + tag: widget.heroTag!, + transitionOnUserGestures: true, + ); + } + + return PhotoViewHeroAttributes( + tag: widget.imageUrls[index], + transitionOnUserGestures: true, + ); + } + @override Widget build(BuildContext context) { return DismissiblePage( @@ -44,23 +65,19 @@ class _PhotoViewScreenState extends State { direction: DismissiblePageDismissDirection.vertical, isFullScreen: true, child: PhotoViewGallery.builder( - backgroundDecoration: const BoxDecoration( - color: Colors.transparent, - ), + backgroundDecoration: const BoxDecoration(color: Colors.transparent), scrollPhysics: const BouncingScrollPhysics(), builder: (BuildContext context, int index) { final imageProvider = widget.isLocal ? FileImage(widget.imageUrls[index]) as ImageProvider - : CachedNetworkImageProvider(widget.imageUrls[index]) as ImageProvider; + : CachedNetworkImageProvider(widget.imageUrls[index]) + as ImageProvider; return PhotoViewGalleryPageOptions( imageProvider: imageProvider, initialScale: PhotoViewComputedScale.contained, minScale: PhotoViewComputedScale.contained, - heroAttributes: PhotoViewHeroAttributes( - tag: widget.imageUrls[index], - transitionOnUserGestures: true, - ), + heroAttributes: _heroAttributesFor(index), errorBuilder: (context, object, stackTrace) { return Scaffold( backgroundColor: Colors.black, @@ -76,8 +93,12 @@ class _PhotoViewScreenState extends State { ), contentPadding: const EdgeInsets.all(16), onTap: () async { - if (!await launchUrl(Uri.parse(widget.imageUrls[index]))) { - throw Exception('Could not launch ${widget.imageUrls[index]}'); + if (!await launchUrl( + Uri.parse(widget.imageUrls[index]), + )) { + throw Exception( + 'Could not launch ${widget.imageUrls[index]}', + ); } }, leading: const Icon( @@ -89,7 +110,10 @@ class _PhotoViewScreenState extends State { color: Colors.white, ), title: Text( - widget.imageUrls[index].replaceAll('${sl().baseUrl}/storage/chat_files/', ''), + widget.imageUrls[index].replaceAll( + '${sl().baseUrl}/storage/chat_files/', + '', + ), style: const TextStyle( color: Colors.white, fontSize: 12, @@ -109,7 +133,9 @@ class _PhotoViewScreenState extends State { width: 20.0, height: 20.0, child: CircularProgressIndicator( - value: event == null ? 0 : event.cumulativeBytesLoaded / event.expectedTotalBytes!, + value: event == null + ? 0 + : event.cumulativeBytesLoaded / event.expectedTotalBytes!, ), ), ), diff --git a/lib/features/feed/presentation/widgets/blog_post_media_content.dart b/lib/features/feed/presentation/widgets/blog_post_media_content.dart index a0d8ddc8..e0e87f31 100644 --- a/lib/features/feed/presentation/widgets/blog_post_media_content.dart +++ b/lib/features/feed/presentation/widgets/blog_post_media_content.dart @@ -18,6 +18,7 @@ class BlogPostMediaContent extends StatefulWidget { required this.onOpenGallery, required this.onGalleryChanged, required this.videoUrlThumb, + this.heroTag, }); final List galleryImages; @@ -28,6 +29,9 @@ class BlogPostMediaContent extends StatefulWidget { final void Function(List urls, int index) onOpenGallery; final ValueChanged onGalleryChanged; + /// Unique per post. Required when several cards with the same [imageUrl] share a route. + final Object? heroTag; + @override State createState() => _BlogPostMediaContentState(); } @@ -44,12 +48,14 @@ class _BlogPostMediaContentState extends State { bool _isCounterVisible = false; Timer? _counterHideTimer; - bool get _hasVideo => widget.videoEmbedUrl != null && widget.videoEmbedUrl!.trim().isNotEmpty; + bool get _hasVideo => + widget.videoEmbedUrl != null && widget.videoEmbedUrl!.trim().isNotEmpty; List get _gallery { return [ for (final image in widget.galleryImages) - if (PostFeedMapper.resolveMediaUrl(image.url) case final url when url.isNotEmpty) + if (PostFeedMapper.resolveMediaUrl(image.url) case final url + when url.isNotEmpty) FeedGalleryImage( url: url, orientation: image.orientation, @@ -71,7 +77,8 @@ class _BlogPostMediaContentState extends State { return 0; } - bool get _isCurrentSlideVideo => _hasVideo && widget.currentGalleryIndex == _gallery.length; + bool get _isCurrentSlideVideo => + _hasVideo && widget.currentGalleryIndex == _gallery.length; bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible; @@ -84,7 +91,9 @@ class _BlogPostMediaContentState extends State { } double _heightForOrientation(String orientation, double width) { - final aspectRatio = orientation == 'vertical' ? _verticalAspectRatio : _horizontalAspectRatio; + final aspectRatio = orientation == 'vertical' + ? _verticalAspectRatio + : _horizontalAspectRatio; final maxHeight = MediaQuery.sizeOf(context).height * 0.7; return (width / aspectRatio).clamp(180.0, maxHeight); } @@ -115,12 +124,15 @@ class _BlogPostMediaContentState extends State { } bool _handleCarouselScroll(ScrollNotification notification) { - if (notification is ScrollStartNotification || notification is ScrollUpdateNotification) { + if (notification is ScrollStartNotification || + notification is ScrollUpdateNotification) { _counterHideTimer?.cancel(); if (!_isCounterVisible) { setState(() => _isCounterVisible = true); } - } else if (notification is ScrollEndNotification && _isCounterVisible && _isCurrentSlideVideo) { + } else if (notification is ScrollEndNotification && + _isCounterVisible && + _isCurrentSlideVideo) { _scheduleCounterHideOnVideo(); } return false; @@ -140,17 +152,25 @@ class _BlogPostMediaContentState extends State { } Widget _videoSlide() { - return FeedVideoPlayer(embedUrl: widget.videoEmbedUrl!, videoUrlThumb: widget.videoUrlThumb); + return FeedVideoPlayer( + embedUrl: widget.videoEmbedUrl!, + videoUrlThumb: widget.videoUrlThumb, + ); } - Widget _carousel({required int itemCount, required NullableIndexedWidgetBuilder itemBuilder, required double height}) { + Widget _carousel({ + required int itemCount, + required NullableIndexedWidgetBuilder itemBuilder, + required double height, + }) { return Stack( children: [ NotificationListener( onNotification: _handleCarouselScroll, child: CarouselSlider.builder( itemCount: itemCount, - itemBuilder: (context, itemIndex, _) => itemBuilder(context, itemIndex) ?? const SizedBox.shrink(), + itemBuilder: (context, itemIndex, _) => + itemBuilder(context, itemIndex) ?? const SizedBox.shrink(), options: CarouselOptions( viewportFraction: 1, height: height, @@ -167,7 +187,10 @@ class _BlogPostMediaContentState extends State { child: AnimatedOpacity( opacity: _shouldShowCounter ? 1 : 0, duration: const Duration(milliseconds: 150), - child: _GalleryPhotoCounter(currentIndex: widget.currentGalleryIndex, total: itemCount), + child: _GalleryPhotoCounter( + currentIndex: widget.currentGalleryIndex, + total: itemCount, + ), ), ), ), @@ -203,25 +226,21 @@ class _BlogPostMediaContentState extends State { } if (_hasVideo) { - return SizedBox( - height: mediaHeight, - width: width, - child: _videoSlide(), - ); + return SizedBox(height: mediaHeight, width: width, child: _videoSlide()); } if (widget.imageUrl.isNotEmpty) { + final image = CachedNetworkImage( + fit: BoxFit.fitWidth, + width: width, + imageUrl: widget.imageUrl, + errorWidget: (context, str, object) => const SizedBox.shrink(), + ); return GestureDetector( onTap: () => widget.onOpenGallery([widget.imageUrl], 0), - child: Hero( - tag: widget.imageUrl, - child: CachedNetworkImage( - fit: BoxFit.fitWidth, - width: width, - imageUrl: widget.imageUrl, - errorWidget: (context, str, object) => const SizedBox.shrink(), - ), - ), + child: widget.heroTag == null + ? image + : Hero(tag: widget.heroTag!, child: image), ); } @@ -239,15 +258,27 @@ class _GalleryPhotoCounter extends StatelessWidget { Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(999)), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(999), + ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.photo_library_outlined, size: 14, color: Colors.white), + const Icon( + Icons.photo_library_outlined, + size: 14, + color: Colors.white, + ), const SizedBox(width: 8), Text( '${currentIndex + 1} / $total', - style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500, height: 1.2), + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + height: 1.2, + ), ), ], ), diff --git a/lib/features/feed/presentation/widgets/blog_post_widget.dart b/lib/features/feed/presentation/widgets/blog_post_widget.dart index 3977d462..ac467501 100644 --- a/lib/features/feed/presentation/widgets/blog_post_widget.dart +++ b/lib/features/feed/presentation/widgets/blog_post_widget.dart @@ -51,14 +51,18 @@ class _BlogPostWidgetState extends State { totalLikes = post.totalLikes; return; } - if (oldWidget.post.wasLikedByMe != post.wasLikedByMe || oldWidget.post.totalLikes != post.totalLikes) { + if (oldWidget.post.wasLikedByMe != post.wasLikedByMe || + oldWidget.post.totalLikes != post.totalLikes) { isLiked = post.wasLikedByMe; totalLikes = post.totalLikes; } } - void _openGallery(List imageUrls, int index) { - context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index)); + void _openGallery(List imageUrls, int index, {Object? heroTag}) { + context.pushTransparentRoute( + rootNavigator: true, + PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag), + ); } Future _toggleLike() async { @@ -82,7 +86,8 @@ class _BlogPostWidgetState extends State { return InkWell( borderRadius: BorderRadius.circular(12), - onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id), + onTap: () => + GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id), child: Material( borderRadius: BorderRadius.circular(12), color: Colors.white, @@ -96,10 +101,19 @@ class _BlogPostWidgetState extends State { BlogPostMediaContent( galleryImages: post.images, imageUrl: imageUrl, - videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null, + videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty + ? embedUrl + : null, videoUrlThumb: post.videoUrlThumb, currentGalleryIndex: currentGalleryIndex, - onOpenGallery: _openGallery, + heroTag: 'feed-blog-post-${post.id}-cover', + onOpenGallery: (urls, index) => _openGallery( + urls, + index, + heroTag: urls.length == 1 + ? 'feed-blog-post-${post.id}-cover' + : null, + ), onGalleryChanged: (index) { setState(() { currentGalleryIndex = index; @@ -107,7 +121,12 @@ class _BlogPostWidgetState extends State { }, ), const SizedBox(height: 12), - _BlogPostActionsRow(post: post, isLiked: isLiked, totalLikes: totalLikes, onToggleLike: _toggleLike), + _BlogPostActionsRow( + post: post, + isLiked: isLiked, + totalLikes: totalLikes, + onToggleLike: _toggleLike, + ), const SizedBox(height: 8), ], ), @@ -129,11 +148,19 @@ class _BlogPostHeader extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ GestureDetector( - onTap: () => GoRouter.of(context).pushNamed('profileScreen', extra: post.author.authorSlug), + onTap: () => GoRouter.of( + context, + ).pushNamed('profileScreen', extra: post.author.authorSlug), child: CircleAvatar( radius: 20, backgroundColor: Colors.black12, - foregroundImage: post.author.authorProfileIcon.isNotEmpty ? NetworkImage(PostFeedMapper.resolveMediaUrl(post.author.authorProfileIcon)) : null, + foregroundImage: post.author.authorProfileIcon.isNotEmpty + ? NetworkImage( + PostFeedMapper.resolveMediaUrl( + post.author.authorProfileIcon, + ), + ) + : null, backgroundImage: const AssetImage('assets/images/no_avatar.png'), ), ), @@ -145,17 +172,27 @@ class _BlogPostHeader extends StatelessWidget { Text( post.author.authorName, overflow: TextOverflow.ellipsis, - style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16), + style: const TextStyle( + height: 1, + color: Colors.black87, + fontWeight: FontWeight.w500, + fontSize: 16, + ), ), const SizedBox(height: 4), Text( '${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}', - style: const TextStyle(height: 1, color: Colors.black45, fontSize: 14), + style: const TextStyle( + height: 1, + color: Colors.black45, + fontSize: 14, + ), ), ], ), ), - if (!(post.author.isEntitySubscribedByMe ?? false) && !post.author.authorIsMe) + if (!(post.author.isEntitySubscribedByMe ?? false) && + !post.author.authorIsMe) Padding( padding: const EdgeInsets.only(right: 8), child: SubscribeButtonWidget(slug: post.author.authorSlug), @@ -190,33 +227,51 @@ class _BlogPostTextContent extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 12), child: Text( post.title, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.black87), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), ), ), ], if (post.title.isEmpty) const SizedBox(height: 8), - if (post.title.isNotEmpty && post.description.isNotEmpty) const SizedBox(height: 8), + if (post.title.isNotEmpty && post.description.isNotEmpty) + const SizedBox(height: 8), if (post.description.isNotEmpty) Padding( padding: const EdgeInsets.symmetric(horizontal: 12), - child: MarkdownHtmlWidget(markdownText: _truncateDescription(post.description)), + child: MarkdownHtmlWidget( + markdownText: _truncateDescription(post.description), + ), ), if (post.description.length > 200) const Padding( padding: EdgeInsets.symmetric(horizontal: 12), child: Text( 'Показать ещё', - style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600), + style: TextStyle( + height: 1, + color: Color(0xFF8B8B8B), + fontSize: 14, + fontWeight: FontWeight.w600, + ), ), ), - if (post.title.isNotEmpty || post.description.isNotEmpty) const SizedBox(height: 8), + if (post.title.isNotEmpty || post.description.isNotEmpty) + const SizedBox(height: 8), ], ); } } class _BlogPostActionsRow extends StatelessWidget { - const _BlogPostActionsRow({required this.post, required this.isLiked, required this.totalLikes, required this.onToggleLike}); + const _BlogPostActionsRow({ + required this.post, + required this.isLiked, + required this.totalLikes, + required this.onToggleLike, + }); final FeedBlogPostModel post; final bool isLiked; @@ -230,7 +285,9 @@ class _BlogPostActionsRow extends StatelessWidget { child: Row( children: [ FeedIconButton( - icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded, + icon: isLiked + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, text: totalLikes.toString(), iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null, backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null, @@ -239,19 +296,34 @@ class _BlogPostActionsRow extends StatelessWidget { ), const SizedBox(width: 8), FeedIconButton( - svgIcon: SvgPicture.asset('assets/icons/comment.svg', height: 20, width: 20, colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn)), + svgIcon: SvgPicture.asset( + 'assets/icons/comment.svg', + height: 20, + width: 20, + colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn), + ), text: post.totalComments.toString(), - onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id), + onTap: () => GoRouter.of( + context, + ).pushNamed('blogPostScreen', extra: post.id), ), const SizedBox(width: 8), FeedIconButton( - svgIcon: SvgPicture.asset(height: 15, width: 12, 'assets/icons/repost.svg', colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn)), + svgIcon: SvgPicture.asset( + height: 15, + width: 12, + 'assets/icons/repost.svg', + colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn), + ), onTap: () { final box = context.findRenderObject() as RenderBox?; SharePlus.instance.share( ShareParams( - uri: Uri.parse('${sl().baseUrl}/ru/blog_post/${post.id}'), - sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, + uri: Uri.parse( + '${sl().baseUrl}/ru/blog_post/${post.id}', + ), + sharePositionOrigin: + box!.localToGlobal(Offset.zero) & box.size, ), ); }, @@ -261,7 +333,11 @@ class _BlogPostActionsRow extends StatelessWidget { const SizedBox(width: 4), Text( post.totalViews.toString(), - style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400), + style: TextStyle( + fontSize: 12, + color: appTheme.gray500, + fontWeight: FontWeight.w400, + ), ), ], ), diff --git a/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart b/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart index 2f19f12a..af70c9df 100644 --- a/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart +++ b/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart @@ -24,7 +24,11 @@ class ProfileBlogPostWidget extends StatefulWidget { final int index; final ProfileCubit profileCubit; - const ProfileBlogPostWidget({super.key, required this.index, required this.profileCubit}); + const ProfileBlogPostWidget({ + super.key, + required this.index, + required this.profileCubit, + }); @override State createState() => _ProfileBlogPostWidgetState(); @@ -35,8 +39,11 @@ class _ProfileBlogPostWidgetState extends State { late int totalLikes; int currentGalleryIndex = 0; - void _openGallery(List imageUrls, int index) { - context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index)); + void _openGallery(List imageUrls, int index, {Object? heroTag}) { + context.pushTransparentRoute( + rootNavigator: true, + PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag), + ); } @override @@ -56,9 +63,13 @@ class _ProfileBlogPostWidgetState extends State { return InkWell( borderRadius: BorderRadius.circular(12), - onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id), + onTap: () => + GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id), child: Ink( - decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: Colors.white), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: Colors.white, + ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -74,9 +85,12 @@ class _ProfileBlogPostWidgetState extends State { foregroundImage: NetworkImage( post.author.authorProfileIcon.contains('https') ? post.author.authorProfileIcon - : sl().baseUrl + post.author.authorProfileIcon, + : sl().baseUrl + + post.author.authorProfileIcon, + ), + backgroundImage: const AssetImage( + 'assets/images/no_avatar.png', ), - backgroundImage: const AssetImage('assets/images/no_avatar.png'), ), const SizedBox(width: 8), Column( @@ -84,12 +98,19 @@ class _ProfileBlogPostWidgetState extends State { children: [ Text( post.author.authorName, - style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500), + style: const TextStyle( + height: 1, + color: Colors.black87, + fontWeight: FontWeight.w500, + ), ), const SizedBox(height: 4), Text( '${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}', - style: const TextStyle(height: 1, color: Colors.black45), + style: const TextStyle( + height: 1, + color: Colors.black45, + ), ), ], ), @@ -112,12 +133,17 @@ class _ProfileBlogPostWidgetState extends State { padding: const EdgeInsets.symmetric(horizontal: 12), child: Text( post.title, - style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18, color: Colors.black87), + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 18, + color: Colors.black87, + ), ), ), ], if (post.title.isEmpty) const SizedBox(height: 8), - if (post.title.isNotEmpty && post.description.isNotEmpty) const SizedBox(height: 8), + if (post.title.isNotEmpty && post.description.isNotEmpty) + const SizedBox(height: 8), if (post.description.isNotEmpty) Padding( padding: const EdgeInsets.symmetric(horizontal: 12), @@ -132,17 +158,34 @@ class _ProfileBlogPostWidgetState extends State { padding: EdgeInsets.symmetric(horizontal: 12), child: Text( 'Показать ещё', - style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600), + style: TextStyle( + height: 1, + color: Color(0xFF8B8B8B), + fontSize: 14, + fontWeight: FontWeight.w600, + ), ), ), - if (post.title.isNotEmpty || post.description.isNotEmpty) const SizedBox(height: 8), + if (post.title.isNotEmpty || post.description.isNotEmpty) + const SizedBox(height: 8), BlogPostMediaContent( galleryImages: post.images, imageUrl: imageUrl, - videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null, - videoUrlThumb: post.videoUrlThumb != null ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) : null, + videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty + ? embedUrl + : null, + videoUrlThumb: post.videoUrlThumb != null + ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) + : null, currentGalleryIndex: currentGalleryIndex, - onOpenGallery: _openGallery, + heroTag: 'profile-blog-post-${post.id}-cover', + onOpenGallery: (urls, index) => _openGallery( + urls, + index, + heroTag: urls.length == 1 + ? 'profile-blog-post-${post.id}-cover' + : null, + ), onGalleryChanged: (index) { setState(() { currentGalleryIndex = index; @@ -155,18 +198,35 @@ class _ProfileBlogPostWidgetState extends State { child: Row( children: [ FeedIconButton( - icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded, + icon: isLiked + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, text: totalLikes.toString(), - iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null, - backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null, - textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null, + iconColor: isLiked + ? Colors.red.withValues(alpha: 0.8) + : null, + backgroundColor: isLiked + ? Colors.red.withValues(alpha: 0.1) + : null, + textColor: isLiked + ? Colors.red.withValues(alpha: 0.8) + : null, onTap: () async { setState(() { isLiked = !isLiked; isLiked ? totalLikes++ : totalLikes--; }); - isLiked ? await context.read().likePost(post.id) : await context.read().unlikedPost(post.id); - widget.profileCubit.updateFeedBlogPostList(post, isLiked ? false : true); + isLiked + ? await context.read().likePost( + post.id, + ) + : await context.read().unlikedPost( + post.id, + ); + widget.profileCubit.updateFeedBlogPostList( + post, + isLiked ? false : true, + ); }, ), const SizedBox(width: 8), @@ -175,10 +235,15 @@ class _ProfileBlogPostWidgetState extends State { 'assets/icons/comment.svg', height: 20, width: 20, - colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn), + colorFilter: ColorFilter.mode( + appTheme.gray600, + BlendMode.srcIn, + ), ), text: post.totalComments.toString(), - onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id), + onTap: () => GoRouter.of( + context, + ).pushNamed('blogPostScreen', extra: post.id), ), const SizedBox(width: 8), FeedIconButton( @@ -186,24 +251,38 @@ class _ProfileBlogPostWidgetState extends State { height: 20, width: 20, 'assets/icons/share.svg', - colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn), + colorFilter: ColorFilter.mode( + appTheme.gray600, + BlendMode.srcIn, + ), ), onTap: () { final box = context.findRenderObject() as RenderBox?; SharePlus.instance.share( ShareParams( - uri: Uri.parse('${sl().baseUrl}/ru/blog_post/${post.id}'), - sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, + uri: Uri.parse( + '${sl().baseUrl}/ru/blog_post/${post.id}', + ), + sharePositionOrigin: + box!.localToGlobal(Offset.zero) & box.size, ), ); }, ), const Spacer(), - Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500), + Icon( + Icons.visibility_rounded, + size: 16, + color: appTheme.gray500, + ), const SizedBox(width: 4), Text( post.totalViews.toString(), - style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400), + style: TextStyle( + fontSize: 12, + color: appTheme.gray500, + fontWeight: FontWeight.w400, + ), ), ], ), diff --git a/test/features/profile/presentation/widgets/profile_blog_post_widget_smoke_test.dart b/test/features/profile/presentation/widgets/profile_blog_post_widget_smoke_test.dart index 44ab062a..4102ca44 100644 --- a/test/features/profile/presentation/widgets/profile_blog_post_widget_smoke_test.dart +++ b/test/features/profile/presentation/widgets/profile_blog_post_widget_smoke_test.dart @@ -47,13 +47,77 @@ class _FakeHttpClientRequest implements HttpClientRequest { dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } -class _FakeHttpClientResponse extends Stream> implements HttpClientResponse { +class _FakeHttpClientResponse extends Stream> + implements HttpClientResponse { // 1x1 transparent PNG. static final List _png = [ - 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, - 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, - 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49, - 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, + 0x89, + 0x50, + 0x4E, + 0x47, + 0x0D, + 0x0A, + 0x1A, + 0x0A, + 0x00, + 0x00, + 0x00, + 0x0D, + 0x49, + 0x48, + 0x44, + 0x52, + 0x00, + 0x00, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x01, + 0x08, + 0x06, + 0x00, + 0x00, + 0x00, + 0x1F, + 0x15, + 0xC4, + 0x89, + 0x00, + 0x00, + 0x00, + 0x0A, + 0x49, + 0x44, + 0x41, + 0x54, + 0x78, + 0x9C, + 0x63, + 0x00, + 0x01, + 0x00, + 0x00, + 0x05, + 0x00, + 0x01, + 0x0D, + 0x0A, + 0x2D, + 0xB4, + 0x00, + 0x00, + 0x00, + 0x00, + 0x49, + 0x45, + 0x4E, + 0x44, + 0xAE, + 0x42, + 0x60, + 0x82, ]; @override @@ -63,11 +127,22 @@ class _FakeHttpClientResponse extends Stream> implements HttpClientRes int get contentLength => _png.length; @override - HttpClientResponseCompressionState get compressionState => HttpClientResponseCompressionState.notCompressed; + HttpClientResponseCompressionState get compressionState => + HttpClientResponseCompressionState.notCompressed; @override - StreamSubscription> listen(void Function(List)? onData, {Function? onError, void Function()? onDone, bool? cancelOnError}) { - return Stream>.fromIterable([_png]).listen(onData, onError: onError, onDone: onDone, cancelOnError: cancelOnError); + StreamSubscription> listen( + void Function(List)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) { + return Stream>.fromIterable([_png]).listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); } @override @@ -78,12 +153,18 @@ class _FakeHttpClientResponse extends Stream> implements HttpClientRes void main() { group('ProfileBlogPostWidget smoke', () { setUpAll(() async { - dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); + dotenv.loadFromString( + envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost', + ); }); setUp(() async { await sl.reset(); - sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); + sl.registerLazySingleton( + () => + AppConfig(buildType: AppBuildType.tests) + ..setBaseUrl('http://localhost'), + ); }); const profile = ProfileModel( @@ -160,7 +241,13 @@ void main() { testWidgets('renders gallery and video media content', (tester) async { final post = FeedFixtures.feedBlogPostGalleryAndVideo(); - final loadedState = ProfileState.loaded(profile: profile, profileDetails: details, blogPosts: [post], isMyProfile: true, certPhotos: []); + final loadedState = ProfileState.loaded( + profile: profile, + profileDetails: details, + blogPosts: [post], + isMyProfile: true, + certPhotos: [], + ); final cubit = MockProfileCubit(); when(cubit.state).thenReturn(loadedState); when(cubit.stream).thenAnswer((_) => const Stream.empty()); @@ -174,7 +261,13 @@ void main() { testWidgets('renders gallery-only media content', (tester) async { final post = FeedFixtures.feedBlogPostWithGallery(); - final loadedState = ProfileState.loaded(profile: profile, profileDetails: details, blogPosts: [post], isMyProfile: true, certPhotos: []); + final loadedState = ProfileState.loaded( + profile: profile, + profileDetails: details, + blogPosts: [post], + isMyProfile: true, + certPhotos: [], + ); final cubit = MockProfileCubit(); when(cubit.state).thenReturn(loadedState); when(cubit.stream).thenAnswer((_) => const Stream.empty()); @@ -188,7 +281,13 @@ void main() { testWidgets('renders video-only media content', (tester) async { final post = FeedFixtures.feedBlogPostVideoOnly(); - final loadedState = ProfileState.loaded(profile: profile, profileDetails: details, blogPosts: [post], isMyProfile: true, certPhotos: []); + final loadedState = ProfileState.loaded( + profile: profile, + profileDetails: details, + blogPosts: [post], + isMyProfile: true, + certPhotos: [], + ); final cubit = MockProfileCubit(); when(cubit.state).thenReturn(loadedState); when(cubit.stream).thenAnswer((_) => const Stream.empty()); @@ -199,5 +298,84 @@ void main() { expect(find.byType(BlogPostMediaContent), findsOneWidget); expect(find.text('Видео'), findsOneWidget); }); + + testWidgets('cover-only posts with the same image use unique hero tags', ( + tester, + ) async { + const sharedImage = 'https://example.com/same-cover.jpg'; + final posts = [ + FeedFixtures.feedBlogPostModel().copyWith( + id: 11, + title: 'Первый', + description: 'Текст', + image: sharedImage, + images: const [], + videoUrl: '', + videoEmbedUrl: null, + ), + FeedFixtures.feedBlogPostModel().copyWith( + id: 22, + title: 'Второй', + description: 'Текст', + image: sharedImage, + images: const [], + videoUrl: '', + videoEmbedUrl: null, + ), + ]; + final loadedState = ProfileState.loaded( + profile: profile, + profileDetails: details, + blogPosts: posts, + isMyProfile: true, + certPhotos: [], + ); + final cubit = MockProfileCubit(); + when(cubit.state).thenReturn(loadedState); + when(cubit.stream).thenAnswer((_) => const Stream.empty()); + when(cubit.close()).thenAnswer((_) async => Future.value()); + + await HttpOverrides.runZoned(() async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeHelper().themeData(), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('ru')], + home: Scaffold( + body: MediaQuery( + data: const MediaQueryData(size: Size(800, 1600)), + child: SingleChildScrollView( + child: Column( + children: [ + ProfileBlogPostWidget(index: 0, profileCubit: cubit), + ProfileBlogPostWidget(index: 1, profileCubit: cubit), + ], + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + }, createHttpClient: (_) => _FakeHttpClient()); + + final tags = tester + .widgetList(find.byType(Hero)) + .map((hero) => hero.tag) + .toList(); + expect( + tags, + containsAll([ + 'profile-blog-post-11-cover', + 'profile-blog-post-22-cover', + ]), + ); + expect(tags.toSet().length, tags.length); + }); }); } -- 2.49.1 From 6b5442eb872900ebdd24d5599782af708206925f Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 5 Aug 2026 15:33:27 +0300 Subject: [PATCH 81/88] fix: analyzer fix --- .../presentation/pages/blog_post_screen.dart | 284 ++++-------------- 1 file changed, 55 insertions(+), 229 deletions(-) diff --git a/lib/features/blog_post/presentation/pages/blog_post_screen.dart b/lib/features/blog_post/presentation/pages/blog_post_screen.dart index e8840cd2..915a61fb 100644 --- a/lib/features/blog_post/presentation/pages/blog_post_screen.dart +++ b/lib/features/blog_post/presentation/pages/blog_post_screen.dart @@ -26,11 +26,7 @@ class BlogPostScreen extends StatefulWidget { final int id; final int? highlightedCommentId; - const BlogPostScreen({ - super.key, - required this.id, - this.highlightedCommentId, - }); + const BlogPostScreen({super.key, required this.id, this.highlightedCommentId}); @override State createState() => _BlogPostScreenState(); @@ -49,10 +45,7 @@ class _BlogPostScreenState extends State { } void _openGallery(List imageUrls, int index, {Object? heroTag}) { - context.pushTransparentRoute( - rootNavigator: true, - PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag), - ); + context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag)); } @override @@ -60,8 +53,7 @@ class _BlogPostScreenState extends State { return BlocProvider( create: (context) => sl()..getBlogPost(widget.id), child: BlocProvider( - create: (BuildContext context) => commentsCubit - ..getComments(widget.id.toString(), CommentSourceType.blogPost, true), + create: (BuildContext context) => commentsCubit..getComments(widget.id.toString(), CommentSourceType.blogPost, true), child: BlocBuilder( builder: (context, blogPostState) { return BlocConsumer( @@ -89,23 +81,15 @@ class _BlogPostScreenState extends State { body: const Loader(), ); } - final bool isLiked = - blogPostState.blogPostModel?.wasLikedByMe ?? false; - final int totalLikes = - blogPostState.blogPostModel?.totalLikes ?? 0; + final bool isLiked = blogPostState.blogPostModel?.wasLikedByMe ?? false; + final int totalLikes = blogPostState.blogPostModel?.totalLikes ?? 0; return Scaffold( resizeToAvoidBottomInset: true, bottomSheet: Container( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).padding.bottom + 8, - ), + padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 8), decoration: BoxDecoration( color: Colors.white, - border: Border( - top: BorderSide( - color: Colors.black.withValues(alpha: 0.1), - ), - ), + border: Border(top: BorderSide(color: Colors.black.withValues(alpha: 0.1))), ), child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), @@ -123,17 +107,10 @@ class _BlogPostScreenState extends State { FocusManager.instance.primaryFocus?.unfocus(); }, child: Container( - decoration: BoxDecoration( - color: appTheme.blueA400, - shape: BoxShape.circle, - ), + decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle), height: 32, width: 32, - child: const Icon( - Icons.close_rounded, - size: 18, - color: Colors.white, - ), + child: const Icon(Icons.close_rounded, size: 18, color: Colors.white), ), ), if (replyId != null) const SizedBox(width: 8), @@ -141,8 +118,7 @@ class _BlogPostScreenState extends State { child: ConstrainedBox( constraints: const BoxConstraints(minHeight: 52), child: TextFormField( - textCapitalization: - TextCapitalization.sentences, + textCapitalization: TextCapitalization.sentences, onChanged: (_) { setState(() {}); }, @@ -150,48 +126,26 @@ class _BlogPostScreenState extends State { maxLines: 5, minLines: 1, keyboardType: TextInputType.multiline, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w400, - color: Colors.black, - height: 1.2, - ), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: Colors.black, height: 1.2), decoration: InputDecoration( isDense: false, // убираем true, чтобы padding учитывался корректно - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 16, - ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), // вертикальный паддинг увеличен hintText: S.of(context).comment_hint, - hintStyle: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w400, - height: 1.2, - color: Color(0xFFAEAEB2), - ), + hintStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, height: 1.2, color: Color(0xFFAEAEB2)), filled: true, border: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide( - color: Color(0xFFD1D1D6), - width: 1, - ), + borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide( - color: Color(0xFFD1D1D6), - width: 1, - ), + borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide( - color: Color(0xFFD1D1D6), - width: 1, - ), + borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), ), ), ), @@ -209,10 +163,7 @@ class _BlogPostScreenState extends State { ); if (!context.mounted) return; - if (isSent) - context - .read() - .incrementCommentsState(widget.id); + if (isSent) context.read().incrementCommentsState(widget.id); context.read().setReplyId(null); setState(() { @@ -223,19 +174,10 @@ class _BlogPostScreenState extends State { } }, child: Container( - decoration: BoxDecoration( - color: textEditingController.text.length > 2 - ? appTheme.blueA400 - : Colors.grey, - shape: BoxShape.circle, - ), + decoration: BoxDecoration(color: textEditingController.text.length > 2 ? appTheme.blueA400 : Colors.grey, shape: BoxShape.circle), height: 32, width: 32, - child: const Icon( - Icons.send_rounded, - size: 18, - color: Colors.white, - ), + child: const Icon(Icons.send_rounded, size: 18, color: Colors.white), ), ), ], @@ -255,33 +197,23 @@ class _BlogPostScreenState extends State { ), actions: [ CustomPopup( - isAuthor: - blogPostState.blogPostModel?.author.authorIsMe ?? - false, + isAuthor: blogPostState.blogPostModel?.author.authorIsMe ?? false, complainedContentType: ComplainedContent.posts, path: 'blog_post/${(widget.id)}', id: widget.id, feedBlogPostModel: blogPostState.blogPostModel, - userSlug: - blogPostState.blogPostModel?.author.authorSlug ?? - '', + userSlug: blogPostState.blogPostModel?.author.authorSlug ?? '', ), const SizedBox(width: 16), ], ), body: SingleChildScrollView( - padding: EdgeInsets.only( - bottom: 160 + MediaQuery.of(context).padding.bottom, - ), // поле комментария (52-200) + padding (12+12+56) + safe area + padding: EdgeInsets.only(bottom: 160 + MediaQuery.of(context).padding.bottom), // поле комментария (52-200) + padding (12+12+56) + safe area physics: const BouncingScrollPhysics(), child: Column( children: [ GestureDetector( - onTap: () => GoRouter.of(context).pushNamed( - 'profileScreen', - extra: - blogPostState.blogPostModel?.author.authorSlug, - ), + onTap: () => GoRouter.of(context).pushNamed('profileScreen', extra: blogPostState.blogPostModel?.author.authorSlug), child: Ink( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( @@ -290,46 +222,21 @@ class _BlogPostScreenState extends State { children: [ CircleAvatar( radius: 16, - foregroundImage: NetworkImage( - sl().baseUrl + - (blogPostState - .blogPostModel - ?.author - .authorProfileIcon ?? - ''), - ), - backgroundImage: const AssetImage( - 'assets/images/no_avatar.png', - ), + foregroundImage: NetworkImage(sl().baseUrl + (blogPostState.blogPostModel?.author.authorProfileIcon ?? '')), + backgroundImage: const AssetImage('assets/images/no_avatar.png'), ), const SizedBox(width: 8), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - blogPostState - .blogPostModel - ?.author - .authorName ?? - '', - style: const TextStyle( - color: Colors.black, - fontSize: 14, - fontWeight: FontWeight.w400, - height: 1, - ), + blogPostState.blogPostModel?.author.authorName ?? '', + style: const TextStyle(color: Colors.black, fontSize: 14, fontWeight: FontWeight.w400, height: 1), ), const SizedBox(height: 2), Text( - (blogPostState.blogPostModel?.createdAt ?? - DateTime.now()) - .format('dd MMMM', 'ru'), - style: TextStyle( - color: Colors.grey.shade600, - fontSize: 12, - fontWeight: FontWeight.w400, - height: 1, - ), + (blogPostState.blogPostModel?.createdAt ?? DateTime.now()).format('dd MMMM', 'ru'), + style: TextStyle(color: Colors.grey.shade600, fontSize: 12, fontWeight: FontWeight.w400, height: 1), ), ], ), @@ -337,20 +244,10 @@ class _BlogPostScreenState extends State { ), ), ), - if (blogPostState - .blogPostModel - ?.description - .isNotEmpty ?? - false) + if (blogPostState.blogPostModel?.description.isNotEmpty ?? false) Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - child: MarkdownHtmlWidget( - markdownText: - blogPostState.blogPostModel!.description, - ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: MarkdownHtmlWidget(markdownText: blogPostState.blogPostModel!.description), ), const SizedBox(height: 8), Builder( @@ -359,31 +256,16 @@ class _BlogPostScreenState extends State { if (post == null) { return const SizedBox.shrink(); } - final imageUrl = PostFeedMapper.resolveMediaUrl( - post.image, - ); + final imageUrl = PostFeedMapper.resolveMediaUrl(post.image); final embedUrl = post.videoEmbedUrl?.trim(); return BlogPostMediaContent( galleryImages: post.images, imageUrl: imageUrl, - videoEmbedUrl: - embedUrl != null && embedUrl.isNotEmpty - ? embedUrl - : null, - videoUrlThumb: post.videoUrlThumb != null - ? PostFeedMapper.resolveMediaUrl( - post.videoUrlThumb, - ) - : null, + videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null, + videoUrlThumb: post.videoUrlThumb != null ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) : null, currentGalleryIndex: currentGalleryIndex, heroTag: 'blog-post-screen-${post.id}-cover', - onOpenGallery: (urls, index) => _openGallery( - urls, - index, - heroTag: urls.length == 1 - ? 'blog-post-screen-${post.id}-cover' - : null, - ), + onOpenGallery: (urls, index) => _openGallery(urls, index, heroTag: urls.length == 1 ? 'blog-post-screen-${post.id}-cover' : null), onGalleryChanged: (index) { setState(() { currentGalleryIndex = index; @@ -393,43 +275,22 @@ class _BlogPostScreenState extends State { }, ), Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ _iconButton( - icon: isLiked - ? Icons.favorite_rounded - : Icons.favorite_border_rounded, + icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded, text: totalLikes.toString(), - iconColor: isLiked - ? Colors.red.withValues(alpha: 0.8) - : null, - backgroundColor: isLiked - ? Colors.red.withValues(alpha: 0.1) - : null, - textColor: isLiked - ? Colors.red.withValues(alpha: 0.8) - : null, + iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null, + backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null, + textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null, onTap: () async { - await context - .read() - .toggleLike(); + await context.read().toggleLike(); // ignore: use_build_context_synchronously - final updated = context - .read() - .state - .blogPostModel; + final updated = context.read().state.blogPostModel; if (updated != null) { // ignore: use_build_context_synchronously - context - .read() - .updateFeedBlogPostList( - updated, - updated.wasLikedByMe, - ); + context.read().updateFeedBlogPostList(updated, updated.wasLikedByMe); } }, ), @@ -437,36 +298,21 @@ class _BlogPostScreenState extends State { _iconButton( icon: Icons.reply_rounded, onTap: () { - final box = - context.findRenderObject() as RenderBox?; + final box = context.findRenderObject() as RenderBox?; SharePlus.instance.share( ShareParams( - uri: Uri.parse( - '${sl().baseUrl}/ru/blog_post/${widget.id}', - ), - sharePositionOrigin: - box!.localToGlobal(Offset.zero) & - box.size, + uri: Uri.parse('${sl().baseUrl}/ru/blog_post/${widget.id}'), + sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, ), ); }, ), const Spacer(), - Icon( - Icons.visibility_rounded, - size: 16, - color: appTheme.gray500, - ), + Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500), const SizedBox(width: 4), Text( - blogPostState.blogPostModel?.totalViews - .toString() ?? - '', - style: TextStyle( - fontSize: 12, - color: appTheme.gray500, - fontWeight: FontWeight.w400, - ), + blogPostState.blogPostModel?.totalViews.toString() ?? '', + style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400), ), ], ), @@ -474,10 +320,7 @@ class _BlogPostScreenState extends State { Container( width: MediaQuery.of(context).size.width - 32, height: 0.4, - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(16), - ), + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(16)), ), const SizedBox(height: 8), Container( @@ -485,11 +328,7 @@ class _BlogPostScreenState extends State { alignment: Alignment.centerLeft, child: Text( '${blogPostState.blogPostModel?.totalComments} КОММЕНТАРИЕВ', - style: TextStyle( - fontSize: 12, - color: appTheme.gray500, - fontWeight: FontWeight.w400, - ), + style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400), ), ), const SizedBox(height: 16), @@ -514,23 +353,13 @@ class _BlogPostScreenState extends State { ); } - InkWell _iconButton({ - required IconData icon, - String? text, - Function()? onTap, - Color? iconColor, - Color? backgroundColor, - Color? textColor, - }) { + InkWell _iconButton({required IconData icon, String? text, Function()? onTap, Color? iconColor, Color? backgroundColor, Color? textColor}) { return InkWell( onTap: onTap ?? () {}, borderRadius: BorderRadius.circular(16), child: Ink( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - decoration: BoxDecoration( - color: backgroundColor ?? appTheme.gray100, - borderRadius: BorderRadius.circular(16), - ), + decoration: BoxDecoration(color: backgroundColor ?? appTheme.gray100, borderRadius: BorderRadius.circular(16)), height: 32, child: Row( mainAxisSize: MainAxisSize.min, @@ -540,10 +369,7 @@ class _BlogPostScreenState extends State { if (text != null) Text( text, - style: TextStyle( - color: textColor ?? appTheme.gray600, - fontWeight: FontWeight.w500, - ), + style: TextStyle(color: textColor ?? appTheme.gray600, fontWeight: FontWeight.w500), ), ], ), -- 2.49.1 From 0d588bcd5747c3a086677b058b6e3ad61d190e11 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 5 Aug 2026 16:32:14 +0300 Subject: [PATCH 82/88] Version changed --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 21b16284..9e654300 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.37+126 +version: 1.3.38+127 publish_to: 'none' environment: -- 2.49.1 From 5c2458ecadfee3869cdf73af50337a53e96275df Mon Sep 17 00:00:00 2001 From: misha Date: Thu, 13 Aug 2026 12:28:34 +0300 Subject: [PATCH 83/88] feat(consultant_refistration): added explicit validation for scoring data --- .../models/consultant_profile_model.dart | 33 +- .../register_consultant_cubit.dart | 32 +- .../pages/passport_data_screen.dart | 8 + .../stepper_become_consultant_screen.dart | 586 +++++++++--------- .../utils/passport_data_validator.dart | 185 ++++++ .../models/consultant_profile_model_test.dart | 4 + .../bloc/register_consultant_cubit_test.dart | 43 +- .../pages/passport_data_screen_test.dart | 15 + .../utils/passport_data_validator_test.dart | 92 +++ 9 files changed, 656 insertions(+), 342 deletions(-) create mode 100644 lib/features/consultant/become_consultant/utils/passport_data_validator.dart create mode 100644 test/features/consultant/become_consultant/utils/passport_data_validator_test.dart diff --git a/lib/features/consultant/become_consultant/models/consultant_profile_model.dart b/lib/features/consultant/become_consultant/models/consultant_profile_model.dart index 5d454ff2..c80e46b2 100644 --- a/lib/features/consultant/become_consultant/models/consultant_profile_model.dart +++ b/lib/features/consultant/become_consultant/models/consultant_profile_model.dart @@ -1,3 +1,4 @@ +import 'package:eatvio_app/features/consultant/become_consultant/utils/passport_data_validator.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'consultant_profile_model.freezed.dart'; @@ -54,37 +55,7 @@ abstract class ConsultantProfileModel with _$ConsultantProfileModel { factory ConsultantProfileModel.fromJson(Map json) => _$ConsultantProfileModelFromJson(json); - bool get hasValidPassportData { - final last = lastName ?? ''; - final first = firstName ?? ''; - final innValue = inn ?? ''; - final snilsValue = snils ?? ''; - final series = passportSeries ?? ''; - final number = passportNumber ?? ''; - final issueDate = passportIssueDate ?? ''; - final issuer = passportIssuer ?? ''; - final issuerCode = passportIssuerCode ?? ''; - final birth = birthDate ?? ''; - final birthPlaceValue = birthPlace ?? ''; - final registration = registrationAddress ?? ''; - final residence = residenceAddress ?? ''; - final citizenshipValue = citizenship ?? ''; - - return last.trim().isNotEmpty && - first.trim().isNotEmpty && - RegExp(r'^\d{12}$').hasMatch(innValue) && - RegExp(r'^\d{3}-\d{3}-\d{3} \d{2}$').hasMatch(snilsValue) && - RegExp(r'^\d{4}$').hasMatch(series) && - RegExp(r'^\d{6}$').hasMatch(number) && - RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(issueDate) && - issuer.trim().isNotEmpty && - RegExp(r'^\d{3}-\d{3}$').hasMatch(issuerCode) && - RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(birth) && - birthPlaceValue.trim().isNotEmpty && - registration.trim().isNotEmpty && - residence.trim().isNotEmpty && - RegExp(r'^[A-Za-z]{2}$').hasMatch(citizenshipValue); - } + bool get hasValidPassportData => PassportDataValidator.validate(this).isEmpty; } @freezed diff --git a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart index 9f835e5c..c131bbcb 100644 --- a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart +++ b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart @@ -12,6 +12,7 @@ import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; import 'package:eatvio_app/features/consultant/become_consultant/utils/laravel_validation_errors.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/utils/passport_data_validator.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class RegisterConsultantCubit extends Cubit { @@ -240,6 +241,9 @@ class RegisterConsultantCubit extends Cubit { bool? acceptOffer, bool? acceptFrdoAgreement, ]) { + final nextFieldErrors = state.fieldErrors.isEmpty + ? const {} + : PassportDataValidator.validate(consultant); emit( state.copyWith( consultant: consultant, @@ -247,11 +251,17 @@ class RegisterConsultantCubit extends Cubit { acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement, loading: false, error: null, - fieldErrors: const {}, + fieldErrors: nextFieldErrors, ), ); } + Map validatePassportFields() { + final errors = PassportDataValidator.validate(state.consultant); + emit(state.copyWith(fieldErrors: errors, error: null)); + return errors; + } + void updateAgreements({bool? acceptOffer, bool? acceptFrdoAgreement}) { emit( state.copyWith( @@ -322,8 +332,8 @@ class RegisterConsultantCubit extends Cubit { } Future registerConsultant() async { - if (!state.consultant.hasValidPassportData) { - emit(state.copyWith(error: 'Заполните паспортные данные')); + final passportErrors = validatePassportFields(); + if (passportErrors.isNotEmpty) { return; } @@ -401,6 +411,10 @@ class RegisterConsultantCubit extends Cubit { if (!state.alfaScoringStatus.canRetry || state.scoringRetryInProgress) { return; } + final passportErrors = validatePassportFields(); + if (passportErrors.isNotEmpty) { + return; + } emit( state.copyWith( scoringRetryInProgress: true, @@ -413,18 +427,6 @@ class RegisterConsultantCubit extends Cubit { ); _update = true; try { - if (!state.consultant.hasValidPassportData) { - emit( - state.copyWith( - scoringRetryInProgress: false, - isScoringPolling: false, - awaitingScoringOutcome: false, - alfaScoringStatus: AlfaScoringStatus.scoringFailed, - error: 'Заполните паспортные данные', - ), - ); - return; - } await consultantRepository.registerConsultant( consultant: buildSubmitPayload(), update: true, diff --git a/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart index 466f4418..a14a9399 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart @@ -7,6 +7,7 @@ import 'package:eatvio_app/features/consultant/become_consultant/presentation/bl import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/passport_field_with_error.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/passport_section_title.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:mask_text_input_formatter/mask_text_input_formatter.dart'; @@ -332,6 +333,7 @@ class _PassportDataScreenState extends State { controller: innController, keyboardType: TextInputType.number, maxLength: 12, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], hasError: errors.containsKey('inn'), onChanged: (_) => _syncToCubit(), ), @@ -446,6 +448,9 @@ class _PassportDataScreenState extends State { controller: seriesController, keyboardType: TextInputType.number, maxLength: 4, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], hasError: errors.containsKey('passport_series'), onChanged: (_) => _syncToCubit(), ), @@ -460,6 +465,9 @@ class _PassportDataScreenState extends State { controller: numberController, keyboardType: TextInputType.number, maxLength: 6, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], hasError: errors.containsKey('passport_number'), onChanged: (_) => _syncToCubit(), ), diff --git a/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart index 12b016af..49e30959 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart @@ -146,212 +146,230 @@ class _StepperBecomeConsultantScreenState Widget build(BuildContext context) { return BlocProvider( create: (context) => registrationConsultantCubit, - child: BlocConsumer( + child: BlocListener( listenWhen: (previous, current) => - previous.awaitingScoringOutcome && - !current.awaitingScoringOutcome && - current.alfaScoringStatus.isComplete, + previous.fieldErrors.isEmpty && + current.fieldErrors.isNotEmpty && + current.error == null, listener: (context, state) { - _showSuccessSheet(context); + pageController.jumpToPage(5); }, - builder: (context, state) { - if (state.loading) { - return const Scaffold(body: Loader()); - } - if (state.error != null && - state.requestModel == null && - widget.slug != null && - state.alfaScoringStatus == AlfaScoringStatus.none) { - return Scaffold( - appBar: AppBar( - centerTitle: true, - leading: BackButton( - onPressed: () { - if (context.canPop()) { - GoRouter.of(context).pop(); - return; - } - GoRouter.of(context).go('/feed-screen'); - }, - ), - title: Text( - '', - maxLines: 2, - textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, - softWrap: true, - style: TextStyles.headingXLSemibold.copyWith( - color: appTheme.black, - ), - ), - ), - body: DataErrorWidget( - onReload: () => context - .read() - .reloadData(widget.slug), - ), - ); - } - - if (progressValue == 0) { - progressValue = 1 / totalSteps; - } - - final submitDisabled = - isLoading || - state.isScoringPolling || - state.scoringRetryInProgress; - - return Scaffold( - floatingActionButtonLocation: - FloatingActionButtonLocation.centerFloat, - floatingActionButton: Row( - children: [ - Container( - height: 48, - width: MediaQuery.of(context).size.width * 0.2, - padding: const EdgeInsets.only(left: 16, right: 4), - child: FloatingActionButton.extended( - heroTag: 'backButtonRegistration', - highlightElevation: 0, - focusElevation: 0, - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - backgroundColor: const Color(0xFFEAF2FD), - onPressed: () async { - setState(() { - pageController.previousPage( - duration: const Duration(milliseconds: 350), - curve: Curves.easeInOut, - ); - }); + child: BlocConsumer( + listenWhen: (previous, current) => + previous.awaitingScoringOutcome && + !current.awaitingScoringOutcome && + current.alfaScoringStatus.isComplete, + listener: (context, state) { + _showSuccessSheet(context); + }, + builder: (context, state) { + if (state.loading) { + return const Scaffold(body: Loader()); + } + if (state.error != null && + state.requestModel == null && + widget.slug != null && + state.alfaScoringStatus == AlfaScoringStatus.none) { + return Scaffold( + appBar: AppBar( + centerTitle: true, + leading: BackButton( + onPressed: () { + if (context.canPop()) { + GoRouter.of(context).pop(); + return; + } + GoRouter.of(context).go('/feed-screen'); }, - label: Icon( - Icons.arrow_back_ios_new_rounded, - color: appTheme.blueA400, + ), + title: Text( + '', + maxLines: 2, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + softWrap: true, + style: TextStyles.headingXLSemibold.copyWith( + color: appTheme.black, ), ), ), - if (widget.isCanceled == true && step != totalSteps) - InkWell( - onTap: () { - final canceledModeration = - state.requestModel?.canceledModeration; - if (canceledModeration == null) return; - showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(canceledModeration.title), - content: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - if (canceledModeration.additional != null) - for (final item - in canceledModeration.additional!) ...[ - Text('• $item'), - ], + body: DataErrorWidget( + onReload: () => context + .read() + .reloadData(widget.slug), + ), + ); + } + + if (progressValue == 0) { + progressValue = 1 / totalSteps; + } + + final submitDisabled = + isLoading || + state.isScoringPolling || + state.scoringRetryInProgress; + + return Scaffold( + floatingActionButtonLocation: + FloatingActionButtonLocation.centerFloat, + floatingActionButton: Row( + children: [ + Container( + height: 48, + width: MediaQuery.of(context).size.width * 0.2, + padding: const EdgeInsets.only(left: 16, right: 4), + child: FloatingActionButton.extended( + heroTag: 'backButtonRegistration', + highlightElevation: 0, + focusElevation: 0, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + backgroundColor: const Color(0xFFEAF2FD), + onPressed: () async { + setState(() { + pageController.previousPage( + duration: const Duration(milliseconds: 350), + curve: Curves.easeInOut, + ); + }); + }, + label: Icon( + Icons.arrow_back_ios_new_rounded, + color: appTheme.blueA400, + ), + ), + ), + if (widget.isCanceled == true && step != totalSteps) + InkWell( + onTap: () { + final canceledModeration = + state.requestModel?.canceledModeration; + if (canceledModeration == null) return; + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(canceledModeration.title), + content: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (canceledModeration.additional != null) + for (final item + in canceledModeration.additional!) ...[ + Text('• $item'), + ], + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('OK'), + ), ], ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('OK'), - ), - ], + ); + }, + borderRadius: BorderRadius.circular(20), + child: Ink( + height: 48, + width: MediaQuery.of(context).size.width * 0.6, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: const Color(0x33FFCC00), ), - ); - }, - borderRadius: BorderRadius.circular(20), - child: Ink( - height: 48, - width: MediaQuery.of(context).size.width * 0.6, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - color: const Color(0x33FFCC00), - ), - child: const Center( - child: Text( - 'Комментарий', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: Color(0xFF323F4B), - height: 1, + child: const Center( + child: Text( + 'Комментарий', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Color(0xFF323F4B), + height: 1, + ), ), ), ), ), - ), - Container( - height: 48, - width: widget.isCanceled == true && step != totalSteps - ? MediaQuery.of(context).size.width * 0.2 - : MediaQuery.of(context).size.width * 0.8, - padding: const EdgeInsets.only(left: 4, right: 16), - child: FloatingActionButton.extended( - heroTag: 'nextButtonRegistration', - highlightElevation: 0, - elevation: 0, - focusElevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - backgroundColor: submitDisabled - ? Colors.grey - : widget.isCanceled == true && step != totalSteps - ? const Color(0xFFEAF2FD) - : appTheme.blueA400, - onPressed: submitDisabled - ? null - : () async { - if (step == totalSteps) { - if (!state.consultant.hasValidPassportData) { - showDialog( - context: context, - builder: (context) => AlertDialog( - actionsPadding: const EdgeInsets.symmetric( - horizontal: 4, - ), - content: const Text( - 'Заполните паспортные данные', - ), - actions: [ - TextButton( - child: const Text('Ок'), - onPressed: () => - Navigator.of(context).pop(), - ), - ], - ), - ); - return; - } - if (registrationConsultantCubit - .hasAcceptedRequiredAgreements) { - setState(() { - isLoading = true; - }); - try { - await registrationConsultantCubit - .registerConsultant(); - if (!context.mounted) return; - if (registrationConsultantCubit.state.error != - null) { - showDialog( + Container( + height: 48, + width: widget.isCanceled == true && step != totalSteps + ? MediaQuery.of(context).size.width * 0.2 + : MediaQuery.of(context).size.width * 0.8, + padding: const EdgeInsets.only(left: 4, right: 16), + child: FloatingActionButton.extended( + heroTag: 'nextButtonRegistration', + highlightElevation: 0, + elevation: 0, + focusElevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + backgroundColor: submitDisabled + ? Colors.grey + : widget.isCanceled == true && step != totalSteps + ? const Color(0xFFEAF2FD) + : appTheme.blueA400, + onPressed: submitDisabled + ? null + : () async { + if (step == totalSteps) { + final passportErrors = + registrationConsultantCubit + .validatePassportFields(); + if (passportErrors.isNotEmpty) { + pageController.jumpToPage(5); + return; + } + if (registrationConsultantCubit + .hasAcceptedRequiredAgreements) { + setState(() { + isLoading = true; + }); + try { + await registrationConsultantCubit + .registerConsultant(); + if (!context.mounted) return; + if (registrationConsultantCubit + .state + .error != + null) { + showDialog( + context: context, + builder: (context) => AlertDialog( + actionsPadding: + const EdgeInsets.symmetric( + horizontal: 4, + ), + content: Text( + registrationConsultantCubit + .state + .error!, + ), + actions: [ + TextButton( + child: const Text('Ок'), + onPressed: () => + Navigator.of(context).pop(), + ), + ], + ), + ); + return; + } + // Success sheet is shown by BlocListener when scoring completes. + } catch (e) { + if (!context.mounted) return; + showDialog( context: context, builder: (context) => AlertDialog( actionsPadding: const EdgeInsets.symmetric( horizontal: 4, ), - content: Text( - registrationConsultantCubit - .state - .error!, - ), + content: Text(e.toString()), actions: [ TextButton( child: const Text('Ок'), @@ -361,140 +379,120 @@ class _StepperBecomeConsultantScreenState ], ), ); - return; + } finally { + setState(() { + isLoading = false; + }); } - // Success sheet is shown by BlocListener when scoring completes. - } catch (e) { - if (!context.mounted) return; - showDialog( + } else { + showDialog( context: context, builder: (context) => AlertDialog( actionsPadding: const EdgeInsets.symmetric( horizontal: 4, ), - content: Text(e.toString()), + content: Text( + state.isUpdating + ? 'Примите договор публичной оферты' + : 'Примите оферту и согласие ФРДО', + ), actions: [ TextButton( child: const Text('Ок'), - onPressed: () => - Navigator.of(context).pop(), + onPressed: () { + Navigator.of(context).pop(); + }, ), ], ), ); - } finally { - setState(() { - isLoading = false; - }); } } else { - showDialog( - context: context, - builder: (context) => AlertDialog( - actionsPadding: const EdgeInsets.symmetric( - horizontal: 4, - ), - content: Text( - state.isUpdating - ? 'Примите договор публичной оферты' - : 'Примите оферту и согласие ФРДО', - ), - actions: [ - TextButton( - child: const Text('Ок'), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - ], - ), - ); + FocusManager.instance.primaryFocus?.unfocus(); + setState(() { + pageController.nextPage( + duration: const Duration(milliseconds: 350), + curve: Curves.easeInOut, + ); + }); } - } else { - FocusManager.instance.primaryFocus?.unfocus(); - setState(() { - pageController.nextPage( - duration: const Duration(milliseconds: 350), - curve: Curves.easeInOut, - ); - }); - } - }, - label: submitDisabled && step == totalSteps - ? const CircularProgressIndicator() - : widget.isCanceled == true && step != totalSteps - ? Icon( - Icons.arrow_forward_ios_outlined, - color: appTheme.blueA400, - ) - : Text( - step == totalSteps ? 'Отправить' : 'Продолжить', - style: theme.textTheme.titleSmall!.copyWith( - color: Colors.white, + }, + label: submitDisabled && step == totalSteps + ? const CircularProgressIndicator() + : widget.isCanceled == true && step != totalSteps + ? Icon( + Icons.arrow_forward_ios_outlined, + color: appTheme.blueA400, + ) + : Text( + step == totalSteps ? 'Отправить' : 'Продолжить', + style: theme.textTheme.titleSmall!.copyWith( + color: Colors.white, + ), ), - ), + ), + ), + ], + ), + appBar: AppBar( + automaticallyImplyLeading: false, + leading: Builder( + builder: (context) => IconButton( + icon: const Icon(Icons.close_rounded), + onPressed: () => GoRouter.of(context).pop(), ), ), - ], - ), - appBar: AppBar( - automaticallyImplyLeading: false, - leading: Builder( - builder: (context) => IconButton( - icon: const Icon(Icons.close_rounded), - onPressed: () => GoRouter.of(context).pop(), + centerTitle: true, + title: SvgPicture.asset( + ImageConstant.imgLogoEatvio, + height: 48, + fit: BoxFit.contain, ), - ), - centerTitle: true, - title: SvgPicture.asset( - ImageConstant.imgLogoEatvio, - height: 48, - fit: BoxFit.contain, - ), - actions: [ - Padding( - padding: const EdgeInsets.only(right: 16), - child: Text('шаг $step из $totalSteps'), - ), - ], - bottom: PreferredSize( - preferredSize: Size(MediaQuery.of(context).size.width, 32), - child: Padding( - padding: const EdgeInsets.all(16), - child: TweenAnimationBuilder( - duration: const Duration(milliseconds: 350), - curve: Curves.easeInOut, - tween: Tween( - begin: 1 / totalSteps, - end: progressValue, - ), - builder: (context, value, _) => LinearProgressIndicator( - value: value, - borderRadius: BorderRadius.circular(16), - minHeight: 8, - backgroundColor: Colors.grey.shade100, - valueColor: AlwaysStoppedAnimation( - appTheme.blueA400, + actions: [ + Padding( + padding: const EdgeInsets.only(right: 16), + child: Text('шаг $step из $totalSteps'), + ), + ], + bottom: PreferredSize( + preferredSize: Size(MediaQuery.of(context).size.width, 32), + child: Padding( + padding: const EdgeInsets.all(16), + child: TweenAnimationBuilder( + duration: const Duration(milliseconds: 350), + curve: Curves.easeInOut, + tween: Tween( + begin: 1 / totalSteps, + end: progressValue, + ), + builder: (context, value, _) => LinearProgressIndicator( + value: value, + borderRadius: BorderRadius.circular(16), + minHeight: 8, + backgroundColor: Colors.grey.shade100, + valueColor: AlwaysStoppedAnimation( + appTheme.blueA400, + ), ), ), ), ), ), - ), - body: PageView( - physics: const NeverScrollableScrollPhysics(), - controller: pageController, - onPageChanged: (index) { - setState(() { - step = index + 1; - progressValue = step / totalSteps; - }); - }, - children: _pages(), - ), - ); - }, + body: PageView( + physics: const NeverScrollableScrollPhysics(), + controller: pageController, + onPageChanged: (index) { + setState(() { + step = index + 1; + progressValue = step / totalSteps; + }); + }, + children: _pages(), + ), + ); + }, + ), ), ); } diff --git a/lib/features/consultant/become_consultant/utils/passport_data_validator.dart b/lib/features/consultant/become_consultant/utils/passport_data_validator.dart new file mode 100644 index 00000000..73c0f1a6 --- /dev/null +++ b/lib/features/consultant/become_consultant/utils/passport_data_validator.dart @@ -0,0 +1,185 @@ +import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; + +/// Client-side validation for individual-person passport fields. +/// +/// Messages match [mapConsultantValidationMessage] so 422 and local errors +/// look the same in the UI. Dates also reject non-calendar values and +/// dates after today. +abstract final class PassportDataValidator { + static const futureDateMessage = 'Дата не может быть в будущем'; + + static Map validate( + ConsultantProfileModel consultant, { + DateTime? now, + }) { + final today = now ?? DateTime.now(); + final errors = {}; + + _requireText( + errors, + key: 'last_name', + value: consultant.lastName, + emptyMessage: 'Укажите фамилию', + ); + _requireText( + errors, + key: 'first_name', + value: consultant.firstName, + emptyMessage: 'Укажите имя', + ); + _requirePattern( + errors, + key: 'inn', + value: consultant.inn, + pattern: RegExp(r'^\d{12}$'), + emptyMessage: 'Укажите ИНН', + invalidMessage: 'Некорректный ИНН', + ); + _requirePattern( + errors, + key: 'snils', + value: consultant.snils, + pattern: RegExp(r'^\d{3}-\d{3}-\d{3} \d{2}$'), + emptyMessage: 'Укажите СНИЛС', + invalidMessage: 'Некорректный СНИЛС', + ); + _requirePattern( + errors, + key: 'passport_series', + value: consultant.passportSeries, + pattern: RegExp(r'^\d{4}$'), + emptyMessage: 'Укажите серию паспорта', + invalidMessage: 'Некорректная серия паспорта', + ); + _requirePattern( + errors, + key: 'passport_number', + value: consultant.passportNumber, + pattern: RegExp(r'^\d{6}$'), + emptyMessage: 'Укажите номер паспорта', + invalidMessage: 'Некорректный номер паспорта', + ); + _requireText( + errors, + key: 'passport_issuer', + value: consultant.passportIssuer, + emptyMessage: 'Укажите, кем выдан паспорт', + ); + _requirePattern( + errors, + key: 'passport_issuer_code', + value: consultant.passportIssuerCode, + pattern: RegExp(r'^\d{3}-\d{3}$'), + emptyMessage: 'Укажите код подразделения', + invalidMessage: 'Некорректный код подразделения', + ); + _requireDate( + errors, + key: 'passport_issue_date', + value: consultant.passportIssueDate, + emptyMessage: 'Укажите дату выдачи паспорта', + invalidMessage: 'Некорректная дата выдачи', + today: today, + ); + _requireDate( + errors, + key: 'birth_date', + value: consultant.birthDate, + emptyMessage: 'Укажите дату рождения', + invalidMessage: 'Некорректная дата рождения', + today: today, + ); + _requireText( + errors, + key: 'birth_place', + value: consultant.birthPlace, + emptyMessage: 'Укажите место рождения', + ); + _requireText( + errors, + key: 'registration_address', + value: consultant.registrationAddress, + emptyMessage: 'Укажите адрес регистрации', + ); + _requireText( + errors, + key: 'residence_address', + value: consultant.residenceAddress, + emptyMessage: 'Укажите адрес проживания', + ); + _requirePattern( + errors, + key: 'citizenship', + value: consultant.citizenship, + pattern: RegExp(r'^[A-Za-z]{2}$'), + emptyMessage: 'Укажите гражданство', + invalidMessage: 'Некорректный код гражданства', + ); + + return errors; + } + + static void _requireText( + Map errors, { + required String key, + required String? value, + required String emptyMessage, + }) { + if (value == null || value.trim().isEmpty) { + errors[key] = emptyMessage; + } + } + + static void _requirePattern( + Map errors, { + required String key, + required String? value, + required RegExp pattern, + required String emptyMessage, + required String invalidMessage, + }) { + final raw = value?.trim() ?? ''; + if (raw.isEmpty) { + errors[key] = emptyMessage; + return; + } + if (!pattern.hasMatch(raw)) { + errors[key] = invalidMessage; + } + } + + static void _requireDate( + Map errors, { + required String key, + required String? value, + required String emptyMessage, + required String invalidMessage, + required DateTime today, + }) { + final raw = value?.trim() ?? ''; + if (raw.isEmpty) { + errors[key] = emptyMessage; + return; + } + + final match = RegExp(r'^(\d{4})-(\d{2})-(\d{2})$').firstMatch(raw); + if (match == null) { + errors[key] = invalidMessage; + return; + } + + final year = int.parse(match.group(1)!); + final month = int.parse(match.group(2)!); + final day = int.parse(match.group(3)!); + final parsed = DateTime(year, month, day); + if (parsed.year != year || parsed.month != month || parsed.day != day) { + errors[key] = invalidMessage; + return; + } + + final todayDate = DateTime(today.year, today.month, today.day); + if (parsed.isAfter(todayDate)) { + errors[key] = futureDateMessage; + } + } +} diff --git a/test/features/consultant/become_consultant/models/consultant_profile_model_test.dart b/test/features/consultant/become_consultant/models/consultant_profile_model_test.dart index 12676d48..82a2ab44 100644 --- a/test/features/consultant/become_consultant/models/consultant_profile_model_test.dart +++ b/test/features/consultant/become_consultant/models/consultant_profile_model_test.dart @@ -158,6 +158,10 @@ void main() { expect(valid.hasValidPassportData, isTrue); expect(valid.copyWith(inn: '123').hasValidPassportData, isFalse); expect(valid.copyWith(lastName: '').hasValidPassportData, isFalse); + expect( + valid.copyWith(birthDate: '2020-02-31').hasValidPassportData, + isFalse, + ); }); }); } diff --git a/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart b/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart index 6f6a0090..f9118d8f 100644 --- a/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart +++ b/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart @@ -463,7 +463,30 @@ void main() { expect(cubit.state.isScoringPolling, isFalse); }); - test('registerConsultant blocks submit without passport data', () async { + test( + 'retryScoring writes fieldErrors when passport data is invalid', + () async { + cubit.emit( + cubit.state.copyWith( + alfaScoringStatus: AlfaScoringStatus.scoringFailed, + loading: false, + ), + ); + + await cubit.retryScoring(); + + expect(cubit.state.fieldErrors['inn'], 'Укажите ИНН'); + expect(cubit.state.alfaScoringStatus, AlfaScoringStatus.scoringFailed); + verifyNever( + () => repository.registerConsultant( + consultant: any(named: 'consultant'), + update: any(named: 'update'), + ), + ); + }, + ); + + test('registerConsultant writes fieldErrors without calling API', () async { when( () => repository.registerConsultant( consultant: any(named: 'consultant'), @@ -473,7 +496,10 @@ void main() { await cubit.registerConsultant(); - expect(cubit.state.error, 'Заполните паспортные данные'); + expect(cubit.state.fieldErrors['last_name'], 'Укажите фамилию'); + expect(cubit.state.fieldErrors['inn'], 'Укажите ИНН'); + expect(cubit.state.fieldErrors['birth_date'], 'Укажите дату рождения'); + expect(cubit.state.error, isNull); verifyNever( () => repository.registerConsultant( consultant: any(named: 'consultant'), @@ -482,6 +508,19 @@ void main() { ); }); + test('editing a field clears only that field error', () async { + await cubit.registerConsultant(); + expect(cubit.state.fieldErrors.containsKey('last_name'), isTrue); + expect(cubit.state.fieldErrors.containsKey('inn'), isTrue); + + cubit.updateConsultantProfileModel( + cubit.state.consultant.copyWith(lastName: 'Иванов'), + ); + + expect(cubit.state.fieldErrors.containsKey('last_name'), isFalse); + expect(cubit.state.fieldErrors['inn'], 'Укажите ИНН'); + }); + test('registerConsultant maps Laravel 422 into fieldErrors', () async { when( () => repository.registerConsultant( diff --git a/test/features/consultant/become_consultant/presentation/pages/passport_data_screen_test.dart b/test/features/consultant/become_consultant/presentation/pages/passport_data_screen_test.dart index 1b508e45..1385a5a7 100644 --- a/test/features/consultant/become_consultant/presentation/pages/passport_data_screen_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/passport_data_screen_test.dart @@ -4,6 +4,7 @@ import 'package:eatvio_app/features/consultant/become_consultant/presentation/bl import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; +import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -60,5 +61,19 @@ void main() { expect(find.text('Банк получатель (поиск)'), findsOneWidget); expect(find.text('Я являюсь налоговым резидентом США'), findsOneWidget); }); + + testWidgets('shows ValidationErrorWidget for invalid field', ( + tester, + ) async { + cubit.emit( + cubit.state.copyWith(fieldErrors: const {'inn': 'Некорректный ИНН'}), + ); + + await tester.pumpWidget(wrap(const PassportDataScreen())); + await tester.pump(); + + expect(find.byType(ValidationErrorWidget), findsOneWidget); + expect(find.text('Некорректный ИНН'), findsOneWidget); + }); }); } diff --git a/test/features/consultant/become_consultant/utils/passport_data_validator_test.dart b/test/features/consultant/become_consultant/utils/passport_data_validator_test.dart new file mode 100644 index 00000000..3e32f758 --- /dev/null +++ b/test/features/consultant/become_consultant/utils/passport_data_validator_test.dart @@ -0,0 +1,92 @@ +import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/utils/passport_data_validator.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const valid = ConsultantProfileModel( + lastName: 'Иванов', + firstName: 'Иван', + inn: '123456789012', + snils: '123-456-789 00', + passportSeries: '1234', + passportNumber: '567890', + passportIssueDate: '2020-01-15', + passportIssuer: 'ОВД', + passportIssuerCode: '772-053', + birthDate: '1990-05-20', + birthPlace: 'Москва', + registrationAddress: 'Адрес 1', + residenceAddress: 'Адрес 2', + citizenship: 'RU', + ); + + group('PassportDataValidator', () { + test('returns empty map for a valid individual person profile', () { + expect(PassportDataValidator.validate(valid), isEmpty); + }); + + test('requires empty text fields', () { + final errors = PassportDataValidator.validate( + valid.copyWith(lastName: '', firstName: null), + ); + + expect(errors['last_name'], 'Укажите фамилию'); + expect(errors['first_name'], 'Укажите имя'); + }); + + test('rejects short INN and malformed SNILS', () { + final errors = PassportDataValidator.validate( + valid.copyWith(inn: '123', snils: '12345678900'), + ); + + expect(errors['inn'], 'Некорректный ИНН'); + expect(errors['snils'], 'Некорректный СНИЛС'); + }); + + test('rejects empty INN and SNILS', () { + final errors = PassportDataValidator.validate( + valid.copyWith(inn: '', snils: ''), + ); + + expect(errors['inn'], 'Укажите ИНН'); + expect(errors['snils'], 'Укажите СНИЛС'); + }); + + test('rejects non-calendar dates', () { + final errors = PassportDataValidator.validate( + valid.copyWith( + birthDate: '2020-02-31', + passportIssueDate: '1999-13-32', + ), + ); + + expect(errors['birth_date'], 'Некорректная дата рождения'); + expect(errors['passport_issue_date'], 'Некорректная дата выдачи'); + }); + + test('rejects dates in the future', () { + final errors = PassportDataValidator.validate( + valid.copyWith( + birthDate: '2030-01-01', + passportIssueDate: '2030-01-01', + ), + now: DateTime(2026, 8, 13), + ); + + expect(errors['birth_date'], PassportDataValidator.futureDateMessage); + expect( + errors['passport_issue_date'], + PassportDataValidator.futureDateMessage, + ); + }); + + test('accepts today as a valid date', () { + final errors = PassportDataValidator.validate( + valid.copyWith(passportIssueDate: '2026-08-13'), + now: DateTime(2026, 8, 13, 18), + ); + + expect(errors.containsKey('passport_issue_date'), isFalse); + }); + }); +} -- 2.49.1 From 8494e61eecddf496cbd6988e3ca860575a154de4 Mon Sep 17 00:00:00 2001 From: misha Date: Thu, 13 Aug 2026 12:29:18 +0300 Subject: [PATCH 84/88] version changed --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 9e654300..3be79617 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.38+127 +version: 1.3.39+128 publish_to: 'none' environment: -- 2.49.1 From 52a01bee12f5449c483389e308855fac81f2a969 Mon Sep 17 00:00:00 2001 From: misha Date: Fri, 14 Aug 2026 11:05:03 +0300 Subject: [PATCH 85/88] feat(chat_messages): fixed data race for chat mesages state --- lib/core/websockets/ws_manager.dart | 16 +- .../bloc/chat_room_cubit/chat_room_cubit.dart | 276 ++++++++++-------- .../bloc/chats_cubit/chats_cubit.dart | 4 +- .../chat/presentation/pages/chat_screen.dart | 72 +++-- .../bloc/chat_room_cubit_test.dart | 215 +++++++++++++- 5 files changed, 436 insertions(+), 147 deletions(-) diff --git a/lib/core/websockets/ws_manager.dart b/lib/core/websockets/ws_manager.dart index 3631e0ac..9ab8fe19 100644 --- a/lib/core/websockets/ws_manager.dart +++ b/lib/core/websockets/ws_manager.dart @@ -38,13 +38,16 @@ class WsManager { StreamSubscription? _eventSubscription; StreamSubscription? _connectionEstablishedSubscription; + Stream? _directChatStream; + Stream? _clientTypingStream; + Stream? get newMessagesStream => _privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated'); Stream? get newNotificationsStream => _privateNotificationChannel?.bind('Illuminate\\Notifications\\Events\\BroadcastNotificationCreated'); - Stream? get directChatStream => _privateDirectChatChannel?.bind('App\\Events\\ChatMessageUpdated'); + Stream? get directChatStream => _directChatStream; - Stream? get clientTypingStream => _privateDirectChatChannel?.bind('client-typing'); + Stream? get clientTypingStream => _clientTypingStream; Future initialize() async { if (client == null) { @@ -107,6 +110,8 @@ class WsManager { if (data == PusherChannelsClientLifeCycleState.establishedConnection) { _unsubscribe(); _subscribe(); + _privateDirectChatChannel?.subscribe(); + _presenceDirectChatChannel?.subscribe(); } }); _eventSubscription = client?.eventStream.listen((event) { @@ -170,6 +175,7 @@ class WsManager { } Future initPrivateDirectChatChannel(String chatId) async { + _privateDirectChatChannel?.unsubscribe(); final token = await TokenStorage.getTokenFromStorage(); _privateDirectChatChannel = client?.privateChannel( '$directChatChannelName.$chatId', @@ -180,6 +186,8 @@ class WsManager { }, ), ); + _directChatStream = _privateDirectChatChannel?.bind('App\\Events\\ChatMessageUpdated'); + _clientTypingStream = _privateDirectChatChannel?.bind('client-typing'); } Future initPresenceDirectChatChannel(String chatId) async { @@ -299,6 +307,8 @@ class WsManager { void unsubscribeDirectChatChannel() { _privateDirectChatChannel?.unsubscribe(); + _directChatStream = null; + _clientTypingStream = null; } void unsubscribePresenceDirectChatChannel() { @@ -331,6 +341,8 @@ class WsManager { _privateDirectChatChannel = null; _privateMessagesChannel = null; _privateNotificationChannel = null; + _directChatStream = null; + _clientTypingStream = null; } } diff --git a/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart b/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart index 452adb18..27b81f50 100644 --- a/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart +++ b/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart @@ -54,16 +54,16 @@ class ChatRoomCubit extends Cubit { if (currentPage < lastPage && !isLoading) { isLoading = true; - emit((state as ChatRoomStateLoaded).copyWith(isLoading: isLoading)); + _emitLoaded((current) => current.copyWith(isLoading: isLoading)); try { final chatMessages = await chatRepository.getChatMessages(uuid, currentPage + 1); isLoading = false; - emit( - (state as ChatRoomStateLoaded).copyWith( + _emitLoaded( + (current) => current.copyWith( isLoading: isLoading, - chatMessages: [...(state as ChatRoomStateLoaded).chatMessages, ...chatMessages.messagesList], + chatMessages: [...current.chatMessages, ...chatMessages.messagesList], ), ); @@ -86,11 +86,8 @@ class ChatRoomCubit extends Cubit { } void _setCard({required List cards}) { - final currentState = state; - if (currentState is ChatRoomStateLoaded) { - if (cards.where((card) => card.isDefault).isNotEmpty) { - emit(currentState.copyWith(hasCard: true)); - } + if (cards.where((card) => card.isDefault).isNotEmpty) { + _emitLoaded((current) => current.copyWith(hasCard: true)); } } @@ -117,6 +114,9 @@ class ChatRoomCubit extends Cubit { throw ArgumentError('initChat requires uuid or orderId'); } + currentPage = 1; + isLoading = false; + final chatRoom = await chatRepository.getChatRoom(resolvedUuid); ChosenPlanModel? chosenPlan; final planId = resolvedChosenPlanId ?? chatRoom.chosenPlanId; @@ -135,6 +135,7 @@ class ChatRoomCubit extends Cubit { hasCard: false, ), ); + await _subscribeToPresenceChannel(chatId: chatRoom.id); if (chosenPlan?.consultantId == chatRoom.users.sender.id) { await checkCards(); @@ -154,13 +155,15 @@ class ChatRoomCubit extends Cubit { Future updateChosenPlanState() async { try { final currentState = state; - if (currentState is ChatRoomStateLoaded) { - final chosenPlanId = currentState.chatRoom.chosenPlanId; - if (chosenPlanId != null) { - final chosenPlan = await chatRepository.getChosenPlan(id: chosenPlanId); - emit(currentState.copyWith.chatRoom(chosenPlan: chosenPlan)); - } + if (currentState is! ChatRoomStateLoaded) { + return; } + final chosenPlanId = currentState.chatRoom.chosenPlanId; + if (chosenPlanId == null) { + return; + } + final chosenPlan = await chatRepository.getChosenPlan(id: chosenPlanId); + _emitLoaded((latest) => latest.copyWith.chatRoom(chosenPlan: chosenPlan)); } catch (e, st) { await reportFrom(e, st, this); } @@ -193,38 +196,92 @@ class ChatRoomCubit extends Cubit { } } + bool _emitLoaded(ChatRoomStateLoaded Function(ChatRoomStateLoaded current) update) { + if (isClosed) { + return false; + } + final currentState = state; + if (currentState is! ChatRoomStateLoaded) { + return false; + } + emit(update(currentState)); + return true; + } + Future _subscribeToChatChannel({required String uuid, required String chatId}) async { final client = wsManager.client; if (client == null) return; _activeChatUuid = uuid; + await messagesSubscription?.cancel(); + await typingSubscription?.cancel(); + messagesSubscription = null; + typingSubscription = null; await wsManager.initPrivateDirectChatChannel(chatId); wsManager.subscribeDirectChatChannel(); - await _subscribeToPresenceChannel(chatId: chatId); - messagesSubscription = wsManager.directChatStream?.listen((event) { - AudioPlayer().play((AssetSource('audio/recive_message.wav'))); - final message = ChatMessageModel.fromJson(jsonDecode(event.data)); - final currentState = state as ChatRoomStateLoaded; - - // Сообщения от текущего пользователя не приходят через WebSocket, - // они обрабатываются через ответ API в sendMessage - // Здесь обрабатываем только входящие сообщения от других пользователей - List messagesList = [message, ...currentState.chatMessages]; - - chatRepository.emitNewMessageToChats(message); - emit(currentState.copyWith(chatMessages: messagesList, isLoading: isLoading)); - EasyDebounce.debounce('searchChatRoom', const Duration(milliseconds: 300), () => sl().getChatRooms()); - }); + messagesSubscription = wsManager.directChatStream?.listen( + _onDirectChatEvent, + onError: (Object e, StackTrace st) { + unawaited(reportFrom(e, st, this)); + }, + ); typingSubscription = wsManager.clientTypingStream?.listen((event) { - emit((state as ChatRoomStateLoaded).copyWith(isTyping: true, isLoading: isLoading)); + _emitLoaded((current) => current.copyWith(isTyping: true, isLoading: isLoading)); EasyDebounce.debounce( 'client-typing', const Duration(seconds: 3), - () => emit((state as ChatRoomStateLoaded).copyWith(isTyping: false, isLoading: isLoading)), + () => _emitLoaded((current) => current.copyWith(isTyping: false, isLoading: isLoading)), ); }); } + void _onDirectChatEvent(ChannelReadEvent event) { + try { + if (addIncomingMessage(ChatMessageModel.fromJson(jsonDecode(event.data) as Map))) { + unawaited(_playIncomingSound()); + } + } catch (e, st) { + unawaited(reportFrom(e, st, this)); + } + } + + Future _playIncomingSound() async { + try { + await AudioPlayer().play(AssetSource('audio/recive_message.wav')); + } catch (_) {} + } + + /// Добавляет входящее сообщение в текущий список, не затирая более новый state. + bool addIncomingMessage(ChatMessageModel message) { + final currentUserId = wsManager.userId; + if (currentUserId != null && message.authorId == currentUserId) { + return false; + } + + final currentState = state; + if (currentState is! ChatRoomStateLoaded) { + return false; + } + if (currentState.chatMessages.any((existing) => existing.id == message.id && message.id != 0)) { + return false; + } + + final added = _emitLoaded((current) { + if (current.chatMessages.any((existing) => existing.id == message.id && message.id != 0)) { + return current; + } + return current.copyWith( + chatMessages: [message, ...current.chatMessages], + isLoading: isLoading, + ); + }); + if (!added) { + return false; + } + chatRepository.emitNewMessageToChats(message); + return true; + } + Future _subscribeToPresenceChannel({required String chatId}) async { final currentState = state; if (currentState is! ChatRoomStateLoaded) return; @@ -341,59 +398,38 @@ class ChatRoomCubit extends Cubit { try { final messageUuid = uuid.v4(); final compressedFiles = files != null ? await ImageCompressUtility.compressFilesList(List.from(files)) : null; - AudioPlayer().play((AssetSource('audio/send_message.wav'))); + try { + unawaited(AudioPlayer().play(AssetSource('audio/send_message.wav'))); + } catch (_) {} - // Создаем временное сообщение с состоянием loading - emit( - (state as ChatRoomStateLoaded).copyWith( + _emitLoaded( + (current) => current.copyWith( chatMessages: [ ChatMessageModel( - id: 1, + id: -messageUuid.hashCode.abs(), data: data ?? '', mediaData: compressedFiles, - authorId: 1, + authorId: wsManager.userId ?? 0, updatedAt: DateTime.now(), isRead: true, isCurrentUserAuthor: true, - roomId: '', + roomId: current.chatRoom.id, messageState: MessageState.loading, uuid: messageUuid, ), - ...(state as ChatRoomStateLoaded).chatMessages, + ...current.chatMessages, ], ), ); try { - // Отправляем сообщение и получаем ответ от сервера с правильными данными final serverMessage = await chatRepository.sendMessage(data: data, files: compressedFiles, slug: userSlug); - - // Заменяем локальное сообщение данными с сервера (с правильными URL файлов) - emit( - (state as ChatRoomStateLoaded).copyWith( - chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) { - if (elem.uuid == messageUuid) { - // Сохраняем UUID, состояние и обновляем данные из ответа сервера - // Используем значения из локального сообщения для полей, которые могут отсутствовать в ответе - return serverMessage.copyWith( - uuid: messageUuid, - messageState: MessageState.sent, - isCurrentUserAuthor: true, - isRead: elem.isRead, - roomId: elem.roomId ?? serverMessage.roomId, - authorId: serverMessage.authorId, - updatedAt: serverMessage.updatedAt, - ); - } - return elem; - }).toList(), - ), - ); + _replaceOptimisticMessage(messageUuid: messageUuid, serverMessage: serverMessage); } catch (e, st) { await reportFrom(e, st, this); - emit( - (state as ChatRoomStateLoaded).copyWith( - chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) { + _emitLoaded( + (current) => current.copyWith( + chatMessages: current.chatMessages.map((elem) { if (elem.uuid == messageUuid) { return elem.copyWith(messageState: MessageState.error); } @@ -411,21 +447,21 @@ class ChatRoomCubit extends Cubit { } } - Future markMessagesAsRead(String chatId, List<({int id, int index})> messagesIds) async { + Future markMessagesAsRead(String chatId, List messageIds) async { try { - if (state is ChatRoomStateLoaded) { - final ids = messagesIds.map((e) => e.id).toList(); - await chatRepository.markMessagesAsRead(chatId, ids); - final currentMessages = [...(state as ChatRoomStateLoaded).chatMessages]; - for (var elem in messagesIds) { - currentMessages[elem.index] = currentMessages[elem.index].copyWith(isRead: true); - } - if (!isClosed) { - emit((state as ChatRoomStateLoaded).copyWith(chatMessages: currentMessages)); - } - return true; + if (state is! ChatRoomStateLoaded) { + return false; } - return false; + await chatRepository.markMessagesAsRead(chatId, messageIds); + final idSet = messageIds.toSet(); + _emitLoaded( + (current) => current.copyWith( + chatMessages: current.chatMessages + .map((message) => idSet.contains(message.id) ? message.copyWith(isRead: true) : message) + .toList(), + ), + ); + return true; } catch (e, st) { await reportFrom(e, st, this); log(e.toString(), stackTrace: st); @@ -433,6 +469,35 @@ class ChatRoomCubit extends Cubit { } } + void _replaceOptimisticMessage({ + required String messageUuid, + required ChatMessageModel serverMessage, + }) { + _emitLoaded((current) { + final sentMessage = serverMessage.copyWith( + uuid: messageUuid, + messageState: MessageState.sent, + isCurrentUserAuthor: true, + ); + final withoutDuplicate = current.chatMessages.where((message) => message.id != serverMessage.id || message.uuid == messageUuid); + final hasOptimistic = withoutDuplicate.any((message) => message.uuid == messageUuid); + if (!hasOptimistic) { + return current.copyWith(chatMessages: [sentMessage, ...withoutDuplicate]); + } + return current.copyWith( + chatMessages: withoutDuplicate.map((elem) { + if (elem.uuid == messageUuid) { + return sentMessage.copyWith( + isRead: elem.isRead, + roomId: elem.roomId?.isNotEmpty == true ? elem.roomId : serverMessage.roomId, + ); + } + return elem; + }).toList(), + ); + }); + } + Future getPaymentLink() async { try { final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id; @@ -477,14 +542,16 @@ class ChatRoomCubit extends Cubit { @override Future close() async { + EasyDebounce.cancel('client-typing'); + EasyDebounce.cancel('searchChatRoom'); if (_presenceOnlineListener != null) { wsManager.removePresenceDirectChatListener(_presenceOnlineListener!); _presenceOnlineListener = null; } wsManager.unsubscribeDirectChatChannel(); _timer?.cancel(); - messagesSubscription?.cancel(); - typingSubscription?.cancel(); + await messagesSubscription?.cancel(); + await typingSubscription?.cancel(); super.close(); } @@ -501,67 +568,46 @@ class ChatRoomCubit extends Cubit { final message = currentState.chatMessages.firstWhereOrNull((message) => message.uuid == uuid); if (message != null) { try { - AudioPlayer().play((AssetSource('audio/send_message.wav'))); + try { + unawaited(AudioPlayer().play(AssetSource('audio/send_message.wav'))); + } catch (_) {} - // Определяем, есть ли файлы для повторной отправки List? filesToResend; if (message.mediaData != null && message.mediaData!.isNotEmpty) { - // Если mediaData содержит File объекты, используем их - // Если это строки (URL), то файлы уже на сервере и повторная отправка не нужна final firstItem = message.mediaData!.first; if (firstItem is File) { filesToResend = message.mediaData!.cast(); } } - emit( - (state as ChatRoomStateLoaded).copyWith( + _emitLoaded( + (current) => current.copyWith( chatMessages: [ ChatMessageModel( - id: 1, + id: -uuid.hashCode.abs(), data: message.data, mediaData: message.mediaData, - authorId: 1, + authorId: wsManager.userId ?? 0, updatedAt: DateTime.now(), isRead: true, isCurrentUserAuthor: true, - roomId: '', + roomId: current.chatRoom.id, messageState: MessageState.loading, uuid: uuid, ), - ...(state as ChatRoomStateLoaded).chatMessages.where((message) => message.uuid != uuid), + ...current.chatMessages.where((item) => item.uuid != uuid), ], ), ); try { - // Отправляем сообщение и получаем ответ от сервера final serverMessage = await chatRepository.sendMessage(data: message.data, files: filesToResend, slug: userSlug); - - // Заменяем локальное сообщение данными с сервера - emit( - (state as ChatRoomStateLoaded).copyWith( - chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) { - if (elem.uuid == uuid) { - return serverMessage.copyWith( - uuid: uuid, - messageState: MessageState.sent, - isCurrentUserAuthor: true, - isRead: elem.isRead, - roomId: elem.roomId ?? serverMessage.roomId, - authorId: serverMessage.authorId, - updatedAt: serverMessage.updatedAt, - ); - } - return elem; - }).toList(), - ), - ); + _replaceOptimisticMessage(messageUuid: uuid, serverMessage: serverMessage); } catch (e, st) { await reportFrom(e, st, this); - emit( - (state as ChatRoomStateLoaded).copyWith( - chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) { + _emitLoaded( + (current) => current.copyWith( + chatMessages: current.chatMessages.map((elem) { if (elem.uuid == uuid) { return elem.copyWith(messageState: MessageState.error); } 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 52e1631e..10f91095 100644 --- a/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart +++ b/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart @@ -141,8 +141,8 @@ class ChatsCubit extends Cubit { Future _handleNewMessageViaSocket(dynamic data) async { try { - final json = jsonDecode(data); - final message = ChatMessageModel.fromJson(json['message']); + final json = jsonDecode(data) as Map; + final message = ChatMessageModel.fromJson(json); final currentChatRooms = [...state.chatRooms]; diff --git a/lib/features/chat/presentation/pages/chat_screen.dart b/lib/features/chat/presentation/pages/chat_screen.dart index 432bebdb..4ef7ba4e 100644 --- a/lib/features/chat/presentation/pages/chat_screen.dart +++ b/lib/features/chat/presentation/pages/chat_screen.dart @@ -56,8 +56,7 @@ class _ChatScreenState extends State { final scrollController = ScrollController(); final TextEditingController textEditingController = TextEditingController(); final ChatRoomCubit chatRoomCubit = sl(); - final List<({int id, int index})> unreadMessagesIds = []; - final List<({int id, int index})> unreadMessagesIdsCopy = []; + final List unreadMessageIds = []; final showableChatBadgeStates = ChosenPlanStateEnum.getShowableChatBadgeStates(); late AppLifecycleListener _listener; List pickedFiles = []; @@ -116,6 +115,7 @@ class _ChatScreenState extends State { @override void dispose() { + EasyDebounce.cancel('mark_as_read'); scrollController.dispose(); _listener.dispose(); chatRoomCubit.close(); @@ -337,21 +337,15 @@ class _ChatScreenState extends State { isCurrentUser: isCurrentUserAuthor, ), VisibilityDetector( - key: Key(message.id.toString()), + key: ValueKey('msg-${message.uuid ?? message.id}'), onVisibilityChanged: (VisibilityInfo info) { - if (!message.isRead && !isCurrentUserAuthor && info.visibleFraction > 0) { - unreadMessagesIdsCopy.add((id: message.id, index: index)); - EasyDebounce.debounce('mark_as_read', const Duration(milliseconds: 500), () async { - final result = await context.read().markMessagesAsRead(state.chatRoom.id, unreadMessagesIdsCopy); - if (!result) return; - if (!context.mounted) return; - context.read().reduceUnreadMessagesCount( - chatId: state.chatRoom.id, - count: unreadMessagesIdsCopy.length, - ); - unreadMessagesIdsCopy.clear(); - }); - } + _queueMarkAsRead( + context: context, + chatId: state.chatRoom.id, + message: message, + isCurrentUserAuthor: isCurrentUserAuthor, + visibleFraction: info.visibleFraction, + ); }, child: _buildMessageBubble( context: context, @@ -391,18 +385,15 @@ class _ChatScreenState extends State { isCurrentUser: isCurrentUserAuthor, ), VisibilityDetector( - key: Key(message.id.toString()), + key: ValueKey('msg-${message.uuid ?? message.id}'), onVisibilityChanged: (VisibilityInfo info) { - if (!message.isRead && !isCurrentUserAuthor && info.visibleFraction > 0) { - unreadMessagesIds.add((id: message.id, index: index)); - EasyDebounce.debounce('mark_as_read', const Duration(milliseconds: 500), () async { - final result = await context.read().markMessagesAsRead(state.chatRoom.id, unreadMessagesIds); - if (!result) return; - if (!context.mounted) return; - context.read().reduceUnreadMessagesCount(chatId: state.chatRoom.id, count: unreadMessagesIds.length); - unreadMessagesIds.clear(); - }); - } + _queueMarkAsRead( + context: context, + chatId: state.chatRoom.id, + message: message, + isCurrentUserAuthor: isCurrentUserAuthor, + visibleFraction: info.visibleFraction, + ); }, child: _buildMessageBubble( context: context, @@ -793,6 +784,33 @@ class _ChatScreenState extends State { ); } + void _queueMarkAsRead({ + required BuildContext context, + required String chatId, + required ChatMessageModel message, + required bool isCurrentUserAuthor, + required double visibleFraction, + }) { + if (message.isRead || isCurrentUserAuthor || visibleFraction <= 0) { + return; + } + if (!unreadMessageIds.contains(message.id)) { + unreadMessageIds.add(message.id); + } + EasyDebounce.debounce('mark_as_read', const Duration(milliseconds: 500), () async { + final ids = List.from(unreadMessageIds); + if (ids.isEmpty) { + return; + } + unreadMessageIds.clear(); + final result = await context.read().markMessagesAsRead(chatId, ids); + if (!result || !context.mounted) { + return; + } + context.read().reduceUnreadMessagesCount(chatId: chatId, count: ids.length); + }); + } + Widget _buildMessageBubble({ required BuildContext context, required ChatRoomStateLoaded state, diff --git a/test/features/chat/presentation/bloc/chat_room_cubit_test.dart b/test/features/chat/presentation/bloc/chat_room_cubit_test.dart index d1f14bba..38793443 100644 --- a/test/features/chat/presentation/bloc/chat_room_cubit_test.dart +++ b/test/features/chat/presentation/bloc/chat_room_cubit_test.dart @@ -200,7 +200,7 @@ void main() { test('markMessagesAsRead - отмечает сообщения как прочитанные', () async { const chatId = 'chat-id'; - final messagesIds = [(id: 1, index: 0)]; + final messagesIds = [1]; // Сначала инициализируем чат when(mockChatRepository.getChatRoom(any)).thenAnswer((_) async => testChatRoom); @@ -1443,5 +1443,218 @@ void main() { verifyNever(mockChatRepository.getChatMessages(any, any)); }); + + test('addIncomingMessage - добавляет входящее сообщение в начало списка', () async { + const uuid = 'room-uuid'; + when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => testChatRoom); + when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages); + when(mockWsManager.client).thenReturn(null); + when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value()); + when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null); + when(mockWsManager.directChatStream).thenReturn(null); + when(mockWsManager.clientTypingStream).thenReturn(null); + when(mockChatRepository.getChosenPlan(id: anyNamed('id'))).thenAnswer((_) async => throw Exception('No plan')); + + await chatRoomCubit.initChat(uuid); + await Future.delayed(const Duration(milliseconds: 200)); + + final incoming = ChatMessageModel( + id: 99, + data: 'Incoming socket message', + mediaData: null, + authorId: 2, + updatedAt: DateTime.now(), + isRead: false, + isCurrentUserAuthor: false, + roomId: 'chat-id', + ); + + chatRoomCubit.addIncomingMessage(incoming); + + final state = chatRoomCubit.state as ChatRoomStateLoaded; + expect(state.chatMessages.first.id, 99); + expect(state.chatMessages.length, 2); + }); + + test('addIncomingMessage - не добавляет дубликат по id', () async { + const uuid = 'room-uuid'; + when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => testChatRoom); + when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages); + when(mockWsManager.client).thenReturn(null); + when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value()); + when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null); + when(mockWsManager.directChatStream).thenReturn(null); + when(mockWsManager.clientTypingStream).thenReturn(null); + when(mockChatRepository.getChosenPlan(id: anyNamed('id'))).thenAnswer((_) async => throw Exception('No plan')); + + await chatRoomCubit.initChat(uuid); + await Future.delayed(const Duration(milliseconds: 200)); + + final incoming = ChatMessageModel( + id: 1, + data: 'Duplicate of existing', + mediaData: null, + authorId: 2, + updatedAt: DateTime.now(), + isRead: false, + isCurrentUserAuthor: false, + roomId: 'chat-id', + ); + + chatRoomCubit.addIncomingMessage(incoming); + + final state = chatRoomCubit.state as ChatRoomStateLoaded; + expect(state.chatMessages.length, 1); + expect(state.chatMessages.first.data, 'Test message'); + }); + + test('addIncomingMessage - игнорирует собственные сообщения', () async { + const uuid = 'room-uuid'; + when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => testChatRoom); + when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages); + when(mockWsManager.client).thenReturn(null); + when(mockWsManager.userId).thenReturn(1); + when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value()); + when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null); + when(mockWsManager.directChatStream).thenReturn(null); + when(mockWsManager.clientTypingStream).thenReturn(null); + when(mockChatRepository.getChosenPlan(id: anyNamed('id'))).thenAnswer((_) async => throw Exception('No plan')); + + await chatRoomCubit.initChat(uuid); + await Future.delayed(const Duration(milliseconds: 200)); + + chatRoomCubit.addIncomingMessage( + ChatMessageModel( + id: 77, + data: 'Own echo', + mediaData: null, + authorId: 1, + updatedAt: DateTime.now(), + isRead: true, + isCurrentUserAuthor: true, + roomId: 'chat-id', + ), + ); + + final state = chatRoomCubit.state as ChatRoomStateLoaded; + expect(state.chatMessages.length, 1); + expect(state.chatMessages.any((message) => message.id == 77), isFalse); + }); + + test('updateChosenPlanState - не затирает сообщения, пришедшие во время запроса', () async { + const uuid = 'room-uuid'; + const planId = 123; + final chatRoomWithPlan = testChatRoom.copyWith(chosenPlanId: planId); + const testPlan = PlanModel( + id: 1, + slug: 'test-plan', + name: 'Test Plan', + image: 'https://example.com/image.jpg', + duration: 7, + state: 'active', + type: PlanType(fit: true, slim: false, behavior: false, wellness: false), + details: 'Test details', + packetsCount: 3, + author: Author(authorSlug: 'author', authorName: 'Author Name', authorProfileIcon: 'https://example.com/icon.jpg', authorIsMe: false), + totalLikes: 10, + totalViews: 100, + totalComments: 5, + isFavorite: false, + minPriceOfOptions: 1000, + maxPriceOfOptions: 2000, + wasLikedByMe: false, + ); + final chosenPlan = ChosenPlanModel( + id: planId, + optionType: 'options1', + startDate: DateTime.now(), + chosenPlanState: ChosenPlanStateEnum.stateStarted, + chatId: 'chat-1', + consultantId: 1, + plan: testPlan, + ); + final updatedPlan = chosenPlan.copyWith(chosenPlanState: ChosenPlanStateEnum.statePaid); + final planCompleter = Completer(); + var getChosenPlanCalls = 0; + + when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => chatRoomWithPlan); + when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages); + when(mockChatRepository.getChosenPlan(id: planId)).thenAnswer((_) { + getChosenPlanCalls++; + if (getChosenPlanCalls == 1) { + return Future.value(chosenPlan); + } + return planCompleter.future; + }); + when(mockWsManager.client).thenReturn(null); + when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value()); + when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null); + when(mockWsManager.directChatStream).thenReturn(null); + when(mockWsManager.clientTypingStream).thenReturn(null); + + await chatRoomCubit.initChat(uuid); + await Future.delayed(const Duration(milliseconds: 200)); + + final updateFuture = chatRoomCubit.updateChosenPlanState(); + chatRoomCubit.addIncomingMessage( + ChatMessageModel( + id: 55, + data: 'Arrived during plan poll', + mediaData: null, + authorId: 2, + updatedAt: DateTime.now(), + isRead: false, + isCurrentUserAuthor: false, + roomId: 'chat-id', + ), + ); + + planCompleter.complete(updatedPlan); + await updateFuture; + + final state = chatRoomCubit.state as ChatRoomStateLoaded; + expect(state.chatMessages.any((message) => message.id == 55), isTrue); + expect(state.chatRoom.chosenPlan?.chosenPlanState, ChosenPlanStateEnum.statePaid); + }); + + test('markMessagesAsRead - не затирает сообщения, пришедшие во время запроса', () async { + const uuid = 'room-uuid'; + const chatId = 'chat-id'; + final markCompleter = Completer(); + + when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => testChatRoom); + when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages); + when(mockWsManager.client).thenReturn(null); + when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value()); + when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null); + when(mockWsManager.directChatStream).thenReturn(null); + when(mockWsManager.clientTypingStream).thenReturn(null); + when(mockChatRepository.getChosenPlan(id: anyNamed('id'))).thenAnswer((_) async => throw Exception('No plan')); + when(mockChatRepository.markMessagesAsRead(chatId, [1])).thenAnswer((_) => markCompleter.future); + + await chatRoomCubit.initChat(uuid); + await Future.delayed(const Duration(milliseconds: 200)); + + final markFuture = chatRoomCubit.markMessagesAsRead(chatId, [1]); + chatRoomCubit.addIncomingMessage( + ChatMessageModel( + id: 88, + data: 'Arrived during mark as read', + mediaData: null, + authorId: 2, + updatedAt: DateTime.now(), + isRead: false, + isCurrentUserAuthor: false, + roomId: chatId, + ), + ); + + markCompleter.complete(); + await markFuture; + + final state = chatRoomCubit.state as ChatRoomStateLoaded; + expect(state.chatMessages.any((message) => message.id == 88), isTrue); + expect(state.chatMessages.firstWhere((message) => message.id == 1).isRead, isTrue); + }); }); } -- 2.49.1 From 85482173adae5cd86fd8c0be5f637121e274383c Mon Sep 17 00:00:00 2001 From: misha Date: Fri, 14 Aug 2026 12:16:03 +0300 Subject: [PATCH 86/88] feat(consultant_scoring): added validation for last first and middle name for scoring --- .../pages/passport_data_screen.dart | 9 +++++ .../utils/laravel_validation_errors.dart | 9 +++-- .../utils/passport_data_validator.dart | 34 +++++++++++++++++-- .../utils/laravel_validation_errors_test.dart | 21 +++++++++++- .../utils/passport_data_validator_test.dart | 28 +++++++++++++++ 5 files changed, 95 insertions(+), 6 deletions(-) diff --git a/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart index a14a9399..4cde5a95 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart @@ -69,6 +69,9 @@ class _PassportDataScreenState extends State { filter: {'#': RegExp(r'[0-9]')}, type: MaskAutoCompletionType.lazy, ); + final _alfaPersonNameFormatter = FilteringTextInputFormatter.allow( + RegExp(r'[a-zA-Zа-яА-ЯёЁ ,.-]'), + ); @override void initState() { @@ -301,6 +304,8 @@ class _PassportDataScreenState extends State { child: CustomTextFormField( hintText: 'Фамилия', controller: lastNameController, + maxLength: 50, + inputFormatters: [_alfaPersonNameFormatter], hasError: errors.containsKey('last_name'), onChanged: (_) => _syncToCubit(), ), @@ -311,6 +316,8 @@ class _PassportDataScreenState extends State { child: CustomTextFormField( hintText: 'Имя', controller: firstNameController, + maxLength: 50, + inputFormatters: [_alfaPersonNameFormatter], hasError: errors.containsKey('first_name'), onChanged: (_) => _syncToCubit(), ), @@ -321,6 +328,8 @@ class _PassportDataScreenState extends State { child: CustomTextFormField( hintText: 'Отчество', controller: middleNameController, + maxLength: 50, + inputFormatters: [_alfaPersonNameFormatter], hasError: errors.containsKey('middle_name'), onChanged: (_) => _syncToCubit(), ), diff --git a/lib/features/consultant/become_consultant/utils/laravel_validation_errors.dart b/lib/features/consultant/become_consultant/utils/laravel_validation_errors.dart index d4b0d947..97be0dc1 100644 --- a/lib/features/consultant/become_consultant/utils/laravel_validation_errors.dart +++ b/lib/features/consultant/become_consultant/utils/laravel_validation_errors.dart @@ -18,7 +18,8 @@ class LaravelValidationErrors { } const _genericValidationSummary = 'Проверьте введённые данные'; -const _genericNetworkSummary = 'Не удалось отправить данные. Попробуйте ещё раз'; +const _genericNetworkSummary = + 'Не удалось отправить данные. Попробуйте ещё раз'; /// Parses Laravel `errors` map and maps them to user-facing Russian texts. /// Raw backend messages are never shown in the UI. @@ -85,8 +86,7 @@ String mapConsultantValidationMessage({ raw.contains('required') || raw.contains('обязательн') || raw.contains('required_if'); - final isAccepted = - raw.contains('accepted') || field.contains('agreement'); + final isAccepted = raw.contains('accepted') || field.contains('agreement'); final isUrl = raw.contains('url') || field.contains('link'); final isFormat = raw.contains('format') || @@ -154,6 +154,9 @@ String mapConsultantValidationMessage({ 'passport_number' => 'Некорректный номер паспорта', 'passport_issue_date' => 'Некорректная дата выдачи', 'passport_issuer_code' => 'Некорректный код подразделения', + 'last_name' => 'Некорректная фамилия', + 'first_name' => 'Некорректное имя', + 'middle_name' => 'Некорректное отчество', 'birth_date' => 'Некорректная дата рождения', 'citizenship' => 'Некорректный код гражданства', 'sbp_phone' => 'Некорректный телефон СБП', diff --git a/lib/features/consultant/become_consultant/utils/passport_data_validator.dart b/lib/features/consultant/become_consultant/utils/passport_data_validator.dart index 73c0f1a6..6b71299f 100644 --- a/lib/features/consultant/become_consultant/utils/passport_data_validator.dart +++ b/lib/features/consultant/become_consultant/utils/passport_data_validator.dart @@ -8,6 +8,9 @@ import 'package:eatvio_app/features/consultant/become_consultant/models/consulta abstract final class PassportDataValidator { static const futureDateMessage = 'Дата не может быть в будущем'; + /// Alfa Bank `personalInformation.{last,first,middle}Name` pattern. + static final alfaPersonNamePattern = RegExp(r'^[a-zA-Zа-яА-ЯёЁ ,.-]{1,50}$'); + static Map validate( ConsultantProfileModel consultant, { DateTime? now, @@ -15,17 +18,28 @@ abstract final class PassportDataValidator { final today = now ?? DateTime.now(); final errors = {}; - _requireText( + _requirePattern( errors, key: 'last_name', value: consultant.lastName, + pattern: alfaPersonNamePattern, emptyMessage: 'Укажите фамилию', + invalidMessage: 'Некорректная фамилия', ); - _requireText( + _requirePattern( errors, key: 'first_name', value: consultant.firstName, + pattern: alfaPersonNamePattern, emptyMessage: 'Укажите имя', + invalidMessage: 'Некорректное имя', + ); + _optionalPattern( + errors, + key: 'middle_name', + value: consultant.middleName, + pattern: alfaPersonNamePattern, + invalidMessage: 'Некорректное отчество', ); _requirePattern( errors, @@ -148,6 +162,22 @@ abstract final class PassportDataValidator { } } + static void _optionalPattern( + Map errors, { + required String key, + required String? value, + required RegExp pattern, + required String invalidMessage, + }) { + final raw = value?.trim() ?? ''; + if (raw.isEmpty) { + return; + } + if (!pattern.hasMatch(raw)) { + errors[key] = invalidMessage; + } + } + static void _requireDate( Map errors, { required String key, diff --git a/test/features/consultant/become_consultant/utils/laravel_validation_errors_test.dart b/test/features/consultant/become_consultant/utils/laravel_validation_errors_test.dart index 4470e4e3..88a0d328 100644 --- a/test/features/consultant/become_consultant/utils/laravel_validation_errors_test.dart +++ b/test/features/consultant/become_consultant/utils/laravel_validation_errors_test.dart @@ -49,7 +49,26 @@ void main() { ); expect(parsed.fieldErrors['last_name'], 'Укажите фамилию'); - expect(parsed.fieldErrors['agreement_accepted'], 'Примите условия оферты'); + expect( + parsed.fieldErrors['agreement_accepted'], + 'Примите условия оферты', + ); + }); + + test('maps name format errors', () { + final parsed = parseLaravelValidationErrors( + dio422({ + 'errors': { + 'last_name': ['The last name format is invalid.'], + 'first_name': ['The first name format is invalid.'], + 'middle_name': ['The middle name format is invalid.'], + }, + }), + ); + + expect(parsed.fieldErrors['last_name'], 'Некорректная фамилия'); + expect(parsed.fieldErrors['first_name'], 'Некорректное имя'); + expect(parsed.fieldErrors['middle_name'], 'Некорректное отчество'); }); test('falls back to generic message for non-422 DioException', () { diff --git a/test/features/consultant/become_consultant/utils/passport_data_validator_test.dart b/test/features/consultant/become_consultant/utils/passport_data_validator_test.dart index 3e32f758..fd568e36 100644 --- a/test/features/consultant/become_consultant/utils/passport_data_validator_test.dart +++ b/test/features/consultant/become_consultant/utils/passport_data_validator_test.dart @@ -25,6 +25,34 @@ void main() { expect(PassportDataValidator.validate(valid), isEmpty); }); + test('rejects names that do not match Alfa Bank pattern', () { + final errors = PassportDataValidator.validate( + valid.copyWith( + lastName: 'Regress14LastName', + firstName: 'Regress14Name', + middleName: 'Regress14middleName', + ), + ); + + expect(errors['last_name'], 'Некорректная фамилия'); + expect(errors['first_name'], 'Некорректное имя'); + expect(errors['middle_name'], 'Некорректное отчество'); + }); + + test('allows hyphenated names and empty middle name', () { + final errors = PassportDataValidator.validate( + valid.copyWith( + lastName: 'Салтыков-Щедрин', + firstName: 'Mary Jane', + middleName: '', + ), + ); + + expect(errors.containsKey('last_name'), isFalse); + expect(errors.containsKey('first_name'), isFalse); + expect(errors.containsKey('middle_name'), isFalse); + }); + test('requires empty text fields', () { final errors = PassportDataValidator.validate( valid.copyWith(lastName: '', firstName: null), -- 2.49.1 From 6d7419c28394c43cdadc47c9499f338aa7e5e727 Mon Sep 17 00:00:00 2001 From: misha Date: Fri, 14 Aug 2026 12:59:04 +0300 Subject: [PATCH 87/88] version changed --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 3be79617..a0d96551 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.39+128 +version: 1.3.40+129 publish_to: 'none' environment: -- 2.49.1 From 90ac4e750d3221c0dbd4546f453f9e04ddb5bcfe Mon Sep 17 00:00:00 2001 From: misha Date: Fri, 14 Aug 2026 13:46:00 +0300 Subject: [PATCH 88/88] feat(portion_input): fixed portion input sizes --- .../presentation/widgets/add_meal_widget.dart | 92 +++++---- .../widgets/add_product_modal.dart | 186 +++++++++--------- 2 files changed, 145 insertions(+), 133 deletions(-) diff --git a/lib/features/create_diet/presentation/widgets/add_meal_widget.dart b/lib/features/create_diet/presentation/widgets/add_meal_widget.dart index a8eb36d5..bfe5d823 100644 --- a/lib/features/create_diet/presentation/widgets/add_meal_widget.dart +++ b/lib/features/create_diet/presentation/widgets/add_meal_widget.dart @@ -59,43 +59,47 @@ class _AddMealWidgetState extends State { Row( mainAxisSize: MainAxisSize.min, children: [ - SizedBox( - width: MediaQuery.of(context).size.width * 0.8 - 32, - child: CustomTextFormField( - controller: productNameController, - autofocus: true, - hintText: 'Введите название продукта/блюда', - onChanged: (query) { - EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async { - selectedGlossary = null; - if (query.isEmpty) { - glossariesList = []; + Expanded( + flex: 2, + child: SizedBox( + child: CustomTextFormField( + controller: productNameController, + autofocus: true, + hintText: 'Введите название продукта/блюда', + onChanged: (query) { + EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async { selectedGlossary = null; + if (query.isEmpty) { + glossariesList = []; + selectedGlossary = null; + setState(() {}); + return; + } + glossariesList = await widget.createDietCubit.getGlossaryListByQuery(query); setState(() {}); - return; - } - glossariesList = await widget.createDietCubit.getGlossaryListByQuery(query); - setState(() {}); - }); - }, + }); + }, + ), ), ), const SizedBox(width: 4), - SizedBox( - width: MediaQuery.of(context).size.width * 0.2 - 4, - child: CustomTextFormField( - controller: quantityController, - keyboardType: TextInputType.number, - hintText: selectedGlossary == null - ? '100' - : selectedGlossary!.grInPortion == 0 - ? '100' - : '1', - suffixText: selectedGlossary == null - ? 'гр' - : selectedGlossary?.grInPortion == 0 - ? 'гр' - : 'шт', + Expanded( + flex: 1, + child: SizedBox( + child: CustomTextFormField( + controller: quantityController, + keyboardType: TextInputType.number, + hintText: selectedGlossary == null + ? '100' + : selectedGlossary!.grInPortion == 0 + ? '100' + : '1', + suffixText: selectedGlossary == null + ? 'гр' + : selectedGlossary?.grInPortion == 0 + ? 'гр' + : 'шт', + ), ), ), ], @@ -149,17 +153,19 @@ class _AddMealWidgetState extends State { ), ), if (widget.portionIndex != null) const SizedBox(width: 8), - InkWell( - onTap: addPortion, - borderRadius: BorderRadius.circular(20), - child: Ink( - width: MediaQuery.of(context).size.width - (widget.portionIndex == null ? 32 : 92), - height: 52, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400), - child: Center( - child: Text( - widget.portionIndex == null ? 'Добавить' : 'Сохранить', - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white), + Expanded( + child: InkWell( + onTap: addPortion, + borderRadius: BorderRadius.circular(20), + child: Ink( + width: MediaQuery.of(context).size.width - (widget.portionIndex == null ? 32 : 92), + height: 52, + decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400), + child: Center( + child: Text( + widget.portionIndex == null ? 'Добавить' : 'Сохранить', + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white), + ), ), ), ), diff --git a/lib/features/diary/presentation/widgets/add_product_modal.dart b/lib/features/diary/presentation/widgets/add_product_modal.dart index a0895bf9..611652cf 100644 --- a/lib/features/diary/presentation/widgets/add_product_modal.dart +++ b/lib/features/diary/presentation/widgets/add_product_modal.dart @@ -70,42 +70,46 @@ class _AddProductModalState extends State { Row( mainAxisSize: MainAxisSize.min, children: [ - SizedBox( - width: MediaQuery.of(context).size.width * 0.76 - 32, - child: CustomTextFormField( - controller: productNameController, - autofocus: true, - hintText: 'Введите название продукта/блюда', - onChanged: (query) { - EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async { - selectedGlossary = null; - if (query.isEmpty) { - glossariesList = []; + Expanded( + flex: 2, + child: SizedBox( + child: CustomTextFormField( + controller: productNameController, + autofocus: true, + hintText: 'Введите название продукта/блюда', + onChanged: (query) { + EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async { + selectedGlossary = null; + if (query.isEmpty) { + glossariesList = []; + setState(() {}); + return; + } + glossariesList = await widget.diaryCubit.getGlossaryListByQuery(query); setState(() {}); - return; - } - glossariesList = await widget.diaryCubit.getGlossaryListByQuery(query); - setState(() {}); - }); - }, + }); + }, + ), ), ), const SizedBox(width: 4), - SizedBox( - width: MediaQuery.of(context).size.width * 0.24 - 4, - child: CustomTextFormField( - controller: quantityController, - keyboardType: TextInputType.number, - hintText: selectedGlossary == null - ? '100' - : selectedGlossary!.grInPortion == 0 - ? '100' - : '1', - suffixText: selectedGlossary == null - ? 'гр' - : selectedGlossary?.grInPortion == 0 - ? 'гр' - : 'шт', + Expanded( + flex: 1, + child: SizedBox( + child: CustomTextFormField( + controller: quantityController, + keyboardType: TextInputType.number, + hintText: selectedGlossary == null + ? '100' + : selectedGlossary!.grInPortion == 0 + ? '100' + : '1', + suffixText: selectedGlossary == null + ? 'гр' + : selectedGlossary?.grInPortion == 0 + ? 'гр' + : 'шт', + ), ), ), ], @@ -168,66 +172,68 @@ class _AddProductModalState extends State { ), ), if (widget.portion != null) const SizedBox(width: 8), - InkWell( - onTap: () { - // Автоматически выбираем первый элемент, если текст точно совпадает - if (selectedGlossary == null && - glossariesList.isNotEmpty && - productNameController.text.trim().toLowerCase() == glossariesList[0].name.toLowerCase()) { - selectedGlossary = glossariesList[0]; - } + Expanded( + child: InkWell( + onTap: () { + // Автоматически выбираем первый элемент, если текст точно совпадает + if (selectedGlossary == null && + glossariesList.isNotEmpty && + productNameController.text.trim().toLowerCase() == glossariesList[0].name.toLowerCase()) { + selectedGlossary = glossariesList[0]; + } - if (widget.portion == null) { - widget.diaryCubit.addProductToMeal( - diaryId: widget.state.diary.id, - mealId: widget.state.selectedMeal, - productName: productNameController.text, - portion: selectedGlossary == null - ? 'gr' - : selectedGlossary!.grInPortion == 0 - ? 'gr' - : 'pcs', - quantity: int.parse(quantityController.text), - productNutritionalValueId: selectedGlossary?.id, - ); - } else { - widget.diaryCubit.updateProductInMeal( - diaryId: widget.state.diary.id, - mealId: widget.state.selectedMeal, - portionId: widget.portion!.id, - productName: productNameController.text, - portion: selectedGlossary == null - ? 'gr' - : selectedGlossary!.grInPortion == 0 - ? 'gr' - : 'pcs', - quantity: int.parse(quantityController.text), - productNutritionalValueId: selectedGlossary?.id, - portionState: widget.portion!.portionState, - ); - } - Navigator.of(context).pop(); - if (selectedGlossary == null && _previousProductName != productNameController.text) { - showModalBottomSheet( - isScrollControlled: true, - useRootNavigator: true, - context: context, - builder: (context) => Padding( - padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), - child: GlossaryModerationRequestModalWidget(productName: productNameController.text, diaryCubit: widget.diaryCubit), + if (widget.portion == null) { + widget.diaryCubit.addProductToMeal( + diaryId: widget.state.diary.id, + mealId: widget.state.selectedMeal, + productName: productNameController.text, + portion: selectedGlossary == null + ? 'gr' + : selectedGlossary!.grInPortion == 0 + ? 'gr' + : 'pcs', + quantity: int.parse(quantityController.text), + productNutritionalValueId: selectedGlossary?.id, + ); + } else { + widget.diaryCubit.updateProductInMeal( + diaryId: widget.state.diary.id, + mealId: widget.state.selectedMeal, + portionId: widget.portion!.id, + productName: productNameController.text, + portion: selectedGlossary == null + ? 'gr' + : selectedGlossary!.grInPortion == 0 + ? 'gr' + : 'pcs', + quantity: int.parse(quantityController.text), + productNutritionalValueId: selectedGlossary?.id, + portionState: widget.portion!.portionState, + ); + } + Navigator.of(context).pop(); + if (selectedGlossary == null && _previousProductName != productNameController.text) { + showModalBottomSheet( + isScrollControlled: true, + useRootNavigator: true, + context: context, + builder: (context) => Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: GlossaryModerationRequestModalWidget(productName: productNameController.text, diaryCubit: widget.diaryCubit), + ), + ); + } + }, + borderRadius: BorderRadius.circular(20), + child: Ink( + width: MediaQuery.of(context).size.width - (widget.portion == null ? 32 : 92), + height: 52, + decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400), + child: Center( + child: Text( + widget.portion == null ? 'Добавить' : 'Сохранить', + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white), ), - ); - } - }, - borderRadius: BorderRadius.circular(20), - child: Ink( - width: MediaQuery.of(context).size.width - (widget.portion == null ? 32 : 92), - height: 52, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400), - child: Center( - child: Text( - widget.portion == null ? 'Добавить' : 'Сохранить', - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white), ), ), ), -- 2.49.1