Compare commits

...

4 Commits

Author SHA1 Message Date
misha
508c321913 version changed 2026-10-29 13:56:06 +03:00
misha
4a5c67632e feat(video_calls): app opening from terminated state fix 2026-10-29 13:55:38 +03:00
misha
5e6e114d5d version changed 2026-10-27 14:08:05 +03:00
misha
e9cfcb9637 feat(video_calls): video calls tokens registration fix 2026-10-27 14:06:55 +03:00
10 changed files with 625 additions and 284 deletions

View File

@@ -437,73 +437,21 @@ import flutter_foreground_task
return payload return payload
} }
private func bringAppToForeground() { /// The answer callback runs before the scene is visible. A custom-scheme
DispatchQueue.main.async { /// open from here is an untrusted user action and SpringBoard denies it.
for scene in UIApplication.shared.connectedScenes { /// CallKit foregrounds a video call itself after `fulfill`. Flutter reads
guard let windowScene = scene as? UIWindowScene else { continue } /// the stored payload once that scene is actually active.
for window in windowScene.windows { func deliverStoredAcceptedCall() {
window.makeKeyAndVisible() guard voipDiagnosticsChannel != nil else { return }
} guard let payload = UserDefaults.standard.dictionary(forKey: pendingAcceptedVoipCallKey) else {
}
self.window?.makeKeyAndVisible()
}
}
private func openAppForAcceptedCall(_ call: Call) {
guard let conversationUuid = conversationUuid(fromCall: call), !conversationUuid.isEmpty else {
recordVoipDiagnostic(
"accepted_call_missing_conversation_uuid",
data: ["callKitId": call.data.uuid]
)
return return
} }
let acceptedAt = (payload[pendingAcceptedAtKey] as? NSNumber)?.doubleValue
var components = URLComponents() if let acceptedAt, Date().timeIntervalSince1970 - acceptedAt > pendingAcceptedCallTtl {
components.scheme = appUrlScheme UserDefaults.standard.removeObject(forKey: pendingAcceptedVoipCallKey)
components.host = "video-call"
var queryItems = [
URLQueryItem(name: "callKitId", value: call.data.uuid),
URLQueryItem(name: "conversationUuid", value: conversationUuid)
]
let serverCallId = extraServerCallId(call.data.extra)
if !serverCallId.isEmpty {
queryItems.append(URLQueryItem(name: "call_id", value: serverCallId))
}
components.queryItems = queryItems
guard let url = components.url else {
recordVoipDiagnostic(
"accepted_call_url_build_failed",
data: [
"callKitId": call.data.uuid,
"conversationUuid": conversationUuid,
]
)
return return
} }
voipDiagnosticsChannel?.invokeMethod("acceptedVoipCall", arguments: payload)
recordVoipDiagnostic(
"accepted_call_open_app_requested",
data: [
"callKitId": call.data.uuid,
"conversationUuid": conversationUuid,
"url": url.absoluteString,
"appState": UIApplication.shared.applicationState.rawValue,
]
)
bringAppToForeground()
UIApplication.shared.open(url, options: [:]) { [weak self] success in
self?.recordVoipDiagnostic(
success ? "accepted_call_open_app_succeeded" : "accepted_call_open_app_failed",
data: [
"callKitId": call.data.uuid,
"conversationUuid": conversationUuid,
"url": url.absoluteString,
]
)
}
} }
private func sanitizedDiagnosticValue(_ value: Any?) -> Any { private func sanitizedDiagnosticValue(_ value: Any?) -> Any {
@@ -1177,6 +1125,8 @@ import flutter_foreground_task
// activation and can start I/O before didActivate. // activation and can start I/O before didActivate.
prepareAvAudioSessionCategory() prepareAvAudioSessionCategory()
storePendingAcceptedCall(call) storePendingAcceptedCall(call)
let extra = call.data.extra as? [String: Any] ?? [:]
VideoCallDeclineClient.sendAccept(extra: extra, handle: call.data.handle)
recordVoipDiagnostic( recordVoipDiagnostic(
"callkit_accept_native", "callkit_accept_native",
data: [ data: [
@@ -1185,11 +1135,7 @@ import flutter_foreground_task
"data": call.data.toJSON(), "data": call.data.toJSON(),
] ]
) )
voipDiagnosticsChannel?.invokeMethod("acceptedVoipCall", arguments: call.data.toJSON()) deliverStoredAcceptedCall()
DispatchQueue.main.async { [weak self] in
self?.bringAppToForeground()
self?.openAppForAcceptedCall(call)
}
action.fulfill() action.fulfill()
} }

View File

@@ -19,6 +19,10 @@ class SceneDelegate: FlutterSceneDelegate {
override func sceneDidBecomeActive(_ scene: UIScene) { override func sceneDidBecomeActive(_ scene: UIScene) {
super.sceneDidBecomeActive(scene) super.sceneDidBecomeActive(scene)
registerCallChannels() registerCallChannels()
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {
return
}
appDelegate.deliverStoredAcceptedCall()
} }
override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) { override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {

View File

@@ -8,25 +8,37 @@ enum VideoCallDeclineClient {
/// `POST /video-call/decline` from the CallKit callback. The process is /// `POST /video-call/decline` from the CallKit callback. The process is
/// awake here even when the user never opens the app afterwards. /// awake here even when the user never opens the app afterwards.
static func send(extra: [String: Any], handle: String) { static func send(extra: [String: Any], handle: String) {
post(action: "decline", extra: extra, handle: handle)
}
/// `POST /video-call/accept` from the CallKit answer callback. Flutter may
/// still be frozen on the splash snapshot, so the other device must be
/// told from here. A later Dart retry with the same hw_id is idempotent.
static func sendAccept(extra: [String: Any], handle: String) {
post(action: "accept", extra: extra, handle: handle)
}
private static func post(action: String, extra: [String: Any], handle: String) {
let fields = declineFields(extra: extra, handle: handle) let fields = declineFields(extra: extra, handle: handle)
guard let request = makeRequest( guard let request = makeRequest(
baseUrl: prefsString("apiBaseUrl") ?? prefsString("customBaseUrl"), baseUrl: prefsString("apiBaseUrl") ?? prefsString("customBaseUrl"),
token: prefsString("token"), token: prefsString("token"),
hwId: UIDevice.current.identifierForVendor?.uuidString, hwId: UIDevice.current.identifierForVendor?.uuidString,
fields: fields fields: fields,
action: action
) else { ) else {
NSLog( NSLog(
"[EatvioVoIP] decline POST skipped conversation=\(fields.conversationUuid) ended=\(fields.endedPush)" "[EatvioVoIP] \(action) POST skipped conversation=\(fields.conversationUuid) ended=\(fields.endedPush)"
) )
return return
} }
let task = EatvioBackgroundTask() let task = EatvioBackgroundTask()
task.begin(name: "video-call-decline") task.begin(name: "video-call-\(action)")
let urlTask = URLSession.shared.dataTask(with: request) { _, response, error in let urlTask = URLSession.shared.dataTask(with: request) { _, response, error in
let code = (response as? HTTPURLResponse)?.statusCode ?? -1 let code = (response as? HTTPURLResponse)?.statusCode ?? -1
NSLog( NSLog(
"[EatvioVoIP] decline POST status=\(code) conversation=\(fields.conversationUuid) error=\(error?.localizedDescription ?? "-")" "[EatvioVoIP] \(action) POST status=\(code) conversation=\(fields.conversationUuid) error=\(error?.localizedDescription ?? "-")"
) )
task.end() task.end()
} }
@@ -37,7 +49,8 @@ enum VideoCallDeclineClient {
baseUrl: String?, baseUrl: String?,
token: String?, token: String?,
hwId: String?, hwId: String?,
fields: VideoCallDeclineFields fields: VideoCallDeclineFields,
action: String = "decline"
) -> URLRequest? { ) -> URLRequest? {
if fields.endedPush { return nil } if fields.endedPush { return nil }
let conversation = fields.conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines) let conversation = fields.conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -50,8 +63,9 @@ enum VideoCallDeclineClient {
guard !conversation.isEmpty, !authToken.isEmpty else { return nil } guard !conversation.isEmpty, !authToken.isEmpty else { return nil }
guard deviceId.count >= 8, deviceId.count <= 64 else { return nil } guard deviceId.count >= 8, deviceId.count <= 64 else { return nil }
guard root.hasPrefix("http://") || root.hasPrefix("https://") else { return nil } guard root.hasPrefix("http://") || root.hasPrefix("https://") else { return nil }
guard action == "accept" || action == "decline" else { return nil }
guard let encoded = conversation.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed), guard let encoded = conversation.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed),
let url = URL(string: "\(root)/api/v2/chat/\(encoded)/video-call/decline") let url = URL(string: "\(root)/api/v2/chat/\(encoded)/video-call/\(action)")
else { return nil } else { return nil }
var body: [String: String] = ["hw_id": deviceId] var body: [String: String] = ["hw_id": deviceId]

View File

@@ -42,7 +42,8 @@ import 'package:eatvio_app/features/order_info/cubit/order_info_cubit.dart';
import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart'; import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart';
import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit.dart'; import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit.dart';
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart'; import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart';
import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_cubit.dart' as profile_plans; import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_cubit.dart'
as profile_plans;
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart'; import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
import 'package:eatvio_app/features/profile/presentation/bloc/sub_cubit/sub_cubit.dart'; import 'package:eatvio_app/features/profile/presentation/bloc/sub_cubit/sub_cubit.dart';
import 'package:eatvio_app/features/recipe/presentation/cubit/about_recipe_cubit.dart'; import 'package:eatvio_app/features/recipe/presentation/cubit/about_recipe_cubit.dart';
@@ -70,16 +71,26 @@ class CubitRegistrations {
/// Регистрация Cubit'ов как синглтонов /// Регистрация Cubit'ов как синглтонов
static void _registerSingletonCubits() { static void _registerSingletonCubits() {
sl.registerLazySingleton<NewMessagesCubit>(() => NewMessagesCubit()); sl.registerLazySingleton<NewMessagesCubit>(() => NewMessagesCubit());
sl.registerLazySingleton<NotificationsCubit>(() => NotificationsCubit(wsManager: sl(), notificationsRepository: sl())); sl.registerLazySingleton<NotificationsCubit>(
sl.registerLazySingleton<ProfileAvatarCubit>(() => ProfileAvatarCubit(profileRepository: sl())); () => NotificationsCubit(wsManager: sl(), notificationsRepository: sl()),
sl.registerLazySingleton<ChatsCubit>(() => ChatsCubit(chatRepository: sl(), wsManager: sl(), profileRepository: sl())); );
sl.registerLazySingleton<InternetConnectionCubit>(() => InternetConnectionCubit()); sl.registerLazySingleton<ProfileAvatarCubit>(
sl.registerLazySingleton<VideoCallManager>( () => ProfileAvatarCubit(profileRepository: sl()),
() => VideoCallManager( );
videoCallRepository: sl(), sl.registerLazySingleton<ChatsCubit>(
() => ChatsCubit(
chatRepository: sl(),
wsManager: sl(),
profileRepository: sl(), profileRepository: sl(),
), ),
); );
sl.registerLazySingleton<InternetConnectionCubit>(
() => InternetConnectionCubit(),
);
sl.registerLazySingleton<VideoCallManager>(
() =>
VideoCallManager(videoCallRepository: sl(), profileRepository: sl()),
);
sl.registerLazySingleton<VideoCallSessionCubit>( sl.registerLazySingleton<VideoCallSessionCubit>(
() => VideoCallSessionCubit( () => VideoCallSessionCubit(
videoCallManager: sl(), videoCallManager: sl(),
@@ -91,48 +102,130 @@ class CubitRegistrations {
/// Регистрация Cubit'ов как фабрик /// Регистрация Cubit'ов как фабрик
static void _registerFactoryCubits() { static void _registerFactoryCubits() {
// Auth related cubits // Auth related cubits
sl.registerFactory<AuthCubit>(() => AuthCubit(authRepository: sl(), firebaseApi: sl(), prefs: sl(), profileRepository: sl())); sl.registerFactory<AuthCubit>(
sl.registerFactory<RegistrationCubit>(() => RegistrationCubit(registrationRepository: sl(), prefs: sl())); () => AuthCubit(
sl.registerFactory<ResetPasswordCubit>(() => ResetPasswordCubit(resetPasswordRepository: sl())); authRepository: sl(),
sl.registerFactory<VerifyEmailCubit>(() => VerifyEmailCubit(profileRepository: sl(), prefs: sl())); firebaseApi: sl(),
sl.registerFactory<OAuthCubit>(() => OAuthCubit(authRepository: sl(), prefs: sl())); prefs: sl(),
sl.registerFactory<RegistrationCubitV2>(() => RegistrationCubitV2(registrationRepository: sl())); profileRepository: sl(),
),
);
sl.registerFactory<RegistrationCubit>(
() => RegistrationCubit(registrationRepository: sl(), prefs: sl()),
);
sl.registerFactory<ResetPasswordCubit>(
() => ResetPasswordCubit(resetPasswordRepository: sl()),
);
sl.registerFactory<VerifyEmailCubit>(
() => VerifyEmailCubit(profileRepository: sl(), prefs: sl()),
);
sl.registerFactory<OAuthCubit>(
() => OAuthCubit(authRepository: sl(), firebaseApi: sl(), prefs: sl()),
);
sl.registerFactory<RegistrationCubitV2>(
() =>
RegistrationCubitV2(registrationRepository: sl(), firebaseApi: sl()),
);
// Feed related cubits // Feed related cubits
sl.registerFactory<FeedCubit>(() => FeedCubit(feedRepository: sl())); sl.registerFactory<FeedCubit>(() => FeedCubit(feedRepository: sl()));
sl.registerFactory<RecipesCubit>(() => RecipesCubit(feedRepository: sl(), recipeRepository: sl(), aboutRecipeRepository: sl())); sl.registerFactory<RecipesCubit>(
sl.registerFactory<DietsCubit>(() => DietsCubit(feedRepository: sl(), dietsRepository: sl())); () => RecipesCubit(
sl.registerFactory<PlansCubit>(() => PlansCubit(feedRepository: sl(), planRepository: sl())); feedRepository: sl(),
sl.registerFactory<ConsultantsCubit>(() => ConsultantsCubit(feedRepository: sl())); recipeRepository: sl(),
sl.registerFactory<CommentsCubit>(() => CommentsCubit(feedRepository: sl(), commentsRepository: sl())); aboutRecipeRepository: sl(),
sl.registerFactory<FeedProfileCubit>(() => FeedProfileCubit(profileRepository: sl())); ),
);
sl.registerFactory<DietsCubit>(
() => DietsCubit(feedRepository: sl(), dietsRepository: sl()),
);
sl.registerFactory<PlansCubit>(
() => PlansCubit(feedRepository: sl(), planRepository: sl()),
);
sl.registerFactory<ConsultantsCubit>(
() => ConsultantsCubit(feedRepository: sl()),
);
sl.registerFactory<CommentsCubit>(
() => CommentsCubit(feedRepository: sl(), commentsRepository: sl()),
);
sl.registerFactory<FeedProfileCubit>(
() => FeedProfileCubit(profileRepository: sl()),
);
// Chat related cubits // Chat related cubits
sl.registerFactory<ChatRoomCubit>(() => ChatRoomCubit(cardsRepository: sl(), chatRepository: sl(), wsManager: sl())); sl.registerFactory<ChatRoomCubit>(
() => ChatRoomCubit(
cardsRepository: sl(),
chatRepository: sl(),
wsManager: sl(),
),
);
// Profile related cubits // Profile related cubits
sl.registerFactory<ProfileCubit>(() => ProfileCubit(profileRepository: sl(), chatRepository: sl(), consultantRepository: sl())); sl.registerFactory<ProfileCubit>(
sl.registerFactory<profile_plans.PlansCubit>(() => profile_plans.PlansCubit(plansRepository: sl())); () => ProfileCubit(
profileRepository: sl(),
chatRepository: sl(),
consultantRepository: sl(),
),
);
sl.registerFactory<profile_plans.PlansCubit>(
() => profile_plans.PlansCubit(plansRepository: sl()),
);
sl.registerFactory<SubCubit>(() => SubCubit(profileRepository: sl())); sl.registerFactory<SubCubit>(() => SubCubit(profileRepository: sl()));
// Content creation cubits // Content creation cubits
sl.registerFactory<CreateDietCubit>(() => CreateDietCubit(profileRepository: sl(), createDietRepository: sl(), glossaryRepository: sl())); sl.registerFactory<CreateDietCubit>(
sl.registerFactory<CreatePlanCubit>(() => CreatePlanCubit(createPlanRepository: sl())); () => CreateDietCubit(
sl.registerFactory<CreateRecipeCubit>(() => CreateRecipeCubit(recipeRepository: sl(), glossaryRepository: sl())); profileRepository: sl(),
sl.registerFactory<BlogPostCreateCubit>(() => BlogPostCreateCubit(blogPostRepository: sl())); createDietRepository: sl(),
glossaryRepository: sl(),
),
);
sl.registerFactory<CreatePlanCubit>(
() => CreatePlanCubit(createPlanRepository: sl()),
);
sl.registerFactory<CreateRecipeCubit>(
() => CreateRecipeCubit(recipeRepository: sl(), glossaryRepository: sl()),
);
sl.registerFactory<BlogPostCreateCubit>(
() => BlogPostCreateCubit(blogPostRepository: sl()),
);
// Content viewing cubits // Content viewing cubits
sl.registerFactory<AboutDietCubit>(() => AboutDietCubit(dietsRepository: sl(), profileRepository: sl())); sl.registerFactory<AboutDietCubit>(
sl.registerFactory<AboutPlanCubit>(() => AboutPlanCubit(planRepository: sl(), profileRepository: sl(), dietRepository: sl())); () => AboutDietCubit(dietsRepository: sl(), profileRepository: sl()),
sl.registerFactory<AboutRecipeCubit>(() => AboutRecipeCubit(recipeRepository: sl(), profileRepository: sl())); );
sl.registerFactory<AboutPlanCubit>(
() => AboutPlanCubit(
planRepository: sl(),
profileRepository: sl(),
dietRepository: sl(),
),
);
sl.registerFactory<AboutRecipeCubit>(
() => AboutRecipeCubit(recipeRepository: sl(), profileRepository: sl()),
);
sl.registerFactory<BlogPostCubit>(() => BlogPostCubit(sl())); sl.registerFactory<BlogPostCubit>(() => BlogPostCubit(sl()));
// User content cubits // User content cubits
sl.registerFactory<MyDietsCubit>(() => MyDietsCubit(myDietsRepository: sl(), createDietRepository: sl())); sl.registerFactory<MyDietsCubit>(
sl.registerFactory<MyPlansCubit>(() => MyPlansCubit(myPlansRepository: sl())); () => MyDietsCubit(myDietsRepository: sl(), createDietRepository: sl()),
sl.registerFactory<MyRecipesCubit>(() => MyRecipesCubit(myRecipesRepository: sl(), aboutRecipeRepository: sl(), recipeRepository: sl())); );
sl.registerFactory<MyPlansCubit>(
() => MyPlansCubit(myPlansRepository: sl()),
);
sl.registerFactory<MyRecipesCubit>(
() => MyRecipesCubit(
myRecipesRepository: sl(),
aboutRecipeRepository: sl(),
recipeRepository: sl(),
),
);
// Other feature cubits // Other feature cubits
sl.registerFactory<CalendarCubit>(() => CalendarCubit(calendarRepository: sl())); sl.registerFactory<CalendarCubit>(
() => CalendarCubit(calendarRepository: sl()),
);
sl.registerFactory<DiaryCubit>( sl.registerFactory<DiaryCubit>(
() => DiaryCubit( () => DiaryCubit(
chatRepository: sl(), chatRepository: sl(),
@@ -144,28 +237,73 @@ class CubitRegistrations {
), ),
); );
sl.registerFactory<SettingsCubit>( sl.registerFactory<SettingsCubit>(
() => SettingsCubit(profileRepository: sl(), settingsRepository: sl(), consultantRepository: sl(), consultationsRepository: sl()), () => SettingsCubit(
profileRepository: sl(),
settingsRepository: sl(),
consultantRepository: sl(),
consultationsRepository: sl(),
),
);
sl.registerFactory<RegisterConsultantCubit>(
() => RegisterConsultantCubit(consultantRepository: sl()),
);
sl.registerFactory<ComplainingCubit>(
() => ComplainingCubit(repository: sl()),
);
sl.registerFactory<UserCardsCubit>(
() => UserCardsCubit(cardsRepository: sl()),
);
sl.registerFactory<OrderInfoCubit>(
() => OrderInfoCubit(chatRepository: sl(), profileRepository: sl()),
); );
sl.registerFactory<RegisterConsultantCubit>(() => RegisterConsultantCubit(consultantRepository: sl()));
sl.registerFactory<ComplainingCubit>(() => ComplainingCubit(repository: sl()));
sl.registerFactory<UserCardsCubit>(() => UserCardsCubit(cardsRepository: sl()));
sl.registerFactory<OrderInfoCubit>(() => OrderInfoCubit(chatRepository: sl(), profileRepository: sl()));
sl.registerFactory<ConsultationPaymentCubit>( sl.registerFactory<ConsultationPaymentCubit>(
() => ConsultationPaymentCubit(paymentRepository: sl(), sbpRepository: sl()), () => ConsultationPaymentCubit(
paymentRepository: sl(),
sbpRepository: sl(),
),
);
sl.registerFactory<ConsultationsCubit>(
() => ConsultationsCubit(consultationsRepository: sl()),
);
sl.registerFactory<ConsultantCommentsCubit>(
() => ConsultantCommentsCubit(diaryRepository: sl()),
);
sl.registerFactory<CancelPlanCubit>(
() => CancelPlanCubit(cancelPlanRepository: sl()),
);
sl.registerFactory<ConsultationsArchiveCubit>(
() => ConsultationsArchiveCubit(consultationsRepository: sl()),
);
sl.registerFactory<ConsultantFinancesCubit>(
() => ConsultantFinancesCubit(consultationsRepository: sl()),
);
sl.registerFactory<UserPurchasesCubit>(
() => UserPurchasesCubit(consultationsRepository: sl()),
);
sl.registerFactory<BlockedUsersCubit>(
() => BlockedUsersCubit(blockedUsersRepository: sl()),
);
sl.registerFactory<NotificationSettingsCubit>(
() => NotificationSettingsCubit(notificationSettingsRepository: sl()),
);
sl.registerFactory<ConsultantRatingCubit>(
() => ConsultantRatingCubit(
feedRepository: sl(),
repository: sl(),
profileRepository: sl(),
),
); );
sl.registerFactory<ConsultationsCubit>(() => ConsultationsCubit(consultationsRepository: sl()));
sl.registerFactory<ConsultantCommentsCubit>(() => ConsultantCommentsCubit(diaryRepository: sl()));
sl.registerFactory<CancelPlanCubit>(() => CancelPlanCubit(cancelPlanRepository: sl()));
sl.registerFactory<ConsultationsArchiveCubit>(() => ConsultationsArchiveCubit(consultationsRepository: sl()));
sl.registerFactory<ConsultantFinancesCubit>(() => ConsultantFinancesCubit(consultationsRepository: sl()));
sl.registerFactory<UserPurchasesCubit>(() => UserPurchasesCubit(consultationsRepository: sl()));
sl.registerFactory<BlockedUsersCubit>(() => BlockedUsersCubit(blockedUsersRepository: sl()));
sl.registerFactory<NotificationSettingsCubit>(() => NotificationSettingsCubit(notificationSettingsRepository: sl()));
sl.registerFactory<ConsultantRatingCubit>(() => ConsultantRatingCubit(feedRepository: sl(), repository: sl(), profileRepository: sl()));
sl.registerFactory<ViewsCubit>(() => ViewsCubit(viewsRepository: sl())); sl.registerFactory<ViewsCubit>(() => ViewsCubit(viewsRepository: sl()));
sl.registerFactory<UserDetailsCubit>(() => UserDetailsCubit(profileRepository: sl())); sl.registerFactory<UserDetailsCubit>(
() => UserDetailsCubit(profileRepository: sl()),
);
sl.registerFactory<MenuCubit>(() => MenuCubit(sl())); sl.registerFactory<MenuCubit>(() => MenuCubit(sl()));
sl.registerFactory<MarketingCubit>(() => MarketingCubit(sl())); sl.registerFactory<MarketingCubit>(() => MarketingCubit(sl()));
sl.registerFactory<PersonalDietSuggestionCubit>(() => PersonalDietSuggestionCubit(dietRepository: sl(), personalDietSuggestionService: sl())); sl.registerFactory<PersonalDietSuggestionCubit>(
() => PersonalDietSuggestionCubit(
dietRepository: sl(),
personalDietSuggestionService: sl(),
),
);
} }
} }

View File

@@ -1,5 +1,6 @@
import 'dart:developer'; import 'dart:developer';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:eatvio_app/core/firebase_api.dart';
import 'package:eatvio_app/core/mixins/registration_date_cache_mixin.dart'; import 'package:eatvio_app/core/mixins/registration_date_cache_mixin.dart';
import 'package:eatvio_app/core/prefs.dart'; import 'package:eatvio_app/core/prefs.dart';
import 'package:eatvio_app/core/token_storage.dart'; import 'package:eatvio_app/core/token_storage.dart';
@@ -11,12 +12,22 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart';
class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin { class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
final AuthRepository authRepository; final AuthRepository authRepository;
final FirebaseApi firebaseApi;
OAuthCubit({required this.authRepository, required Prefs prefs}) : super(const OAuthState()) { OAuthCubit({
required this.authRepository,
required this.firebaseApi,
required Prefs prefs,
}) : super(const OAuthState()) {
this.prefs = prefs; this.prefs = prefs;
} }
Future<void> signInViaSocialMedia({required String provider, required String accessToken, required String refreshToken, required String clientId}) async { Future<void> signInViaSocialMedia({
required String provider,
required String accessToken,
required String refreshToken,
required String clientId,
}) async {
try { try {
emit(const OAuthState.loading()); emit(const OAuthState.loading());
@@ -28,7 +39,7 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
); );
log('OAuthCubit: Got token model: ${tokenModel.token}'); log('OAuthCubit: Got token model: ${tokenModel.token}');
TokenStorage.saveTokenInStorage(tokenModel.token); await TokenStorage.saveTokenInStorage(tokenModel.token);
emit(OAuthState.loaded(token: tokenModel.token)); emit(OAuthState.loaded(token: tokenModel.token));
} on DioException catch (e, st) { } on DioException catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
@@ -45,7 +56,8 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
if (errorStr.contains('Некорректный формат email')) { if (errorStr.contains('Некорректный формат email')) {
errorsList.add(RegistrationValidationErrors.incorrectEmailFormat); errorsList.add(RegistrationValidationErrors.incorrectEmailFormat);
} }
if (errorStr.contains('почта уже существует') || errorStr.contains('Эл. почта уже существует')) { if (errorStr.contains('почта уже существует') ||
errorStr.contains('Эл. почта уже существует')) {
errorsList.add(RegistrationValidationErrors.emailExist); errorsList.add(RegistrationValidationErrors.emailExist);
} }
} }
@@ -53,14 +65,20 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
if (phoneErrors != null && phoneErrors.isNotEmpty) { if (phoneErrors != null && phoneErrors.isNotEmpty) {
for (final error in phoneErrors) { for (final error in phoneErrors) {
final errorStr = error.toString(); final errorStr = error.toString();
if (errorStr.contains('phone уже существует') || errorStr.contains('телефон уже существует')) { if (errorStr.contains('phone уже существует') ||
errorStr.contains('телефон уже существует')) {
errorsList.add(RegistrationValidationErrors.phoneExist); errorsList.add(RegistrationValidationErrors.phoneExist);
} }
} }
} }
if (errorsList.isNotEmpty) { if (errorsList.isNotEmpty) {
emit(OAuthState.error(errorMessage: 'validation_error', registrationValidationErrors: errorsList)); emit(
OAuthState.error(
errorMessage: 'validation_error',
registrationValidationErrors: errorsList,
),
);
} else { } else {
emit(OAuthState.error(errorMessage: e.toString())); emit(OAuthState.error(errorMessage: e.toString()));
} }
@@ -103,7 +121,8 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
height: height, height: height,
clientId: clientId, clientId: clientId,
); );
TokenStorage.saveTokenInStorage(token); await TokenStorage.saveTokenInStorage(token);
await firebaseApi.registerPushToken();
cacheRegistrationDate(DateTime.now()); cacheRegistrationDate(DateTime.now());
emit(OAuthState.loaded(token: token)); emit(OAuthState.loaded(token: token));
} on DioException catch (e, st) { } on DioException catch (e, st) {
@@ -120,7 +139,8 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
if (errorStr.contains('Некорректный формат email')) { if (errorStr.contains('Некорректный формат email')) {
errorsList.add(RegistrationValidationErrors.incorrectEmailFormat); errorsList.add(RegistrationValidationErrors.incorrectEmailFormat);
} }
if (errorStr.contains('почта уже существует') || errorStr.contains('Эл. почта уже существует')) { if (errorStr.contains('почта уже существует') ||
errorStr.contains('Эл. почта уже существует')) {
errorsList.add(RegistrationValidationErrors.emailExist); errorsList.add(RegistrationValidationErrors.emailExist);
} }
} }
@@ -128,14 +148,17 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
if (phoneErrors != null && phoneErrors.isNotEmpty) { if (phoneErrors != null && phoneErrors.isNotEmpty) {
for (final error in phoneErrors) { for (final error in phoneErrors) {
final errorStr = error.toString(); final errorStr = error.toString();
if (errorStr.contains('phone уже существует') || errorStr.contains('телефон уже существует')) { if (errorStr.contains('phone уже существует') ||
errorStr.contains('телефон уже существует')) {
errorsList.add(RegistrationValidationErrors.phoneExist); errorsList.add(RegistrationValidationErrors.phoneExist);
} }
} }
} }
if (dateOfBirth != null && dateOfBirth.isNotEmpty) { if (dateOfBirth != null && dateOfBirth.isNotEmpty) {
if (dateOfBirth.contains('Некорректный формат даты рождения')) { if (dateOfBirth.contains('Некорректный формат даты рождения')) {
errorsList.add(RegistrationValidationErrors.incorrectDateOfBirthFormat); errorsList.add(
RegistrationValidationErrors.incorrectDateOfBirthFormat,
);
} }
} }
if (errorsList.isNotEmpty) { if (errorsList.isNotEmpty) {
@@ -146,13 +169,23 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
return null; return null;
} }
Future<OAuthVerificationRequestResponse?> requestOAuthVerification({String? email, String? phone}) async { Future<OAuthVerificationRequestResponse?> requestOAuthVerification({
String? email,
String? phone,
}) async {
try { try {
emit(const OAuthState.loading()); emit(const OAuthState.loading());
log('OAuthCubit: Requesting verification code for email: $email, phone: $phone'); log(
final response = await authRepository.requestOAuthVerification(email: email, phone: phone); 'OAuthCubit: Requesting verification code for email: $email, phone: $phone',
);
final response = await authRepository.requestOAuthVerification(
email: email,
phone: phone,
);
if (response != null) { if (response != null) {
log('OAuthCubit: Verification code requested successfully, masked email: ${response.email}'); log(
'OAuthCubit: Verification code requested successfully, masked email: ${response.email}',
);
} }
// Сбрасываем состояние после успешного запроса кода // Сбрасываем состояние после успешного запроса кода
emit(const OAuthState()); emit(const OAuthState());
@@ -184,7 +217,9 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
}) async { }) async {
try { try {
emit(const OAuthState.loading()); emit(const OAuthState.loading());
log('OAuthCubit: Confirming OAuth verification - provider: $provider, verificationType: $verificationType, code: $code'); log(
'OAuthCubit: Confirming OAuth verification - provider: $provider, verificationType: $verificationType, code: $code',
);
final response = await authRepository.confirmOAuthVerification( final response = await authRepository.confirmOAuthVerification(
provider: provider, provider: provider,
accessToken: accessToken, accessToken: accessToken,
@@ -202,8 +237,11 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
weight: weight, weight: weight,
height: height, height: height,
); );
log('OAuthCubit: OAuth verification confirmed successfully, token received'); log(
TokenStorage.saveTokenInStorage(response.token); 'OAuthCubit: OAuth verification confirmed successfully, token received',
);
await TokenStorage.saveTokenInStorage(response.token);
await firebaseApi.registerPushToken();
emit(OAuthState.loaded(token: response.token)); emit(OAuthState.loaded(token: response.token));
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
@@ -212,4 +250,3 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
} }
} }
} }

View File

@@ -1,6 +1,7 @@
import 'dart:developer'; import 'dart:developer';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:eatvio_app/core/firebase_api.dart';
import 'package:eatvio_app/core/token_storage.dart'; import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/core/utils/date_time_utils.dart'; import 'package:eatvio_app/core/utils/date_time_utils.dart';
import 'package:eatvio_app/core/utils/validation_utils.dart'; import 'package:eatvio_app/core/utils/validation_utils.dart';
@@ -13,7 +14,11 @@ import 'package:gpassword/gpassword.dart';
class RegistrationCubitV2 extends Cubit<RegistrationStateV2> { class RegistrationCubitV2 extends Cubit<RegistrationStateV2> {
final RegistrationRepositoryV2 registrationRepository; final RegistrationRepositoryV2 registrationRepository;
RegistrationCubitV2({required this.registrationRepository}) : super(const RegistrationStateV2()); final FirebaseApi firebaseApi;
RegistrationCubitV2({
required this.registrationRepository,
required this.firebaseApi,
}) : super(const RegistrationStateV2());
void setFirstName(String firstName) { void setFirstName(String firstName) {
emit(state.copyWith(firstName: firstName)); emit(state.copyWith(firstName: firstName));
@@ -194,9 +199,12 @@ class RegistrationCubitV2 extends Cubit<RegistrationStateV2> {
bool didApiComplainOnValidation = false; bool didApiComplainOnValidation = false;
String errorMessage = ''; String errorMessage = '';
try { try {
final token = await registrationRepository.register(registrationModel: registrationModel); final token = await registrationRepository.register(
registrationModel: registrationModel,
);
if (token != null) { if (token != null) {
TokenStorage.saveTokenInStorage(token); await TokenStorage.saveTokenInStorage(token);
await firebaseApi.registerPushToken();
emit(state.copyWith(registrationError: null)); emit(state.copyWith(registrationError: null));
return (true, false); return (true, false);
} }
@@ -214,11 +222,21 @@ class RegistrationCubitV2 extends Cubit<RegistrationStateV2> {
for (var error in emailErrors) { for (var error in emailErrors) {
allErrors.add(error.toString()); allErrors.add(error.toString());
if (error.toString() == 'Некорректный формат email') { if (error.toString() == 'Некорректный формат email') {
emit(state.copyWith(isEmailValid: false, emailDisplayError: 'Некорректный формат email')); emit(
state.copyWith(
isEmailValid: false,
emailDisplayError: 'Некорректный формат email',
),
);
didApiComplainOnValidation = true; didApiComplainOnValidation = true;
} }
if (error.toString() == 'Такой Эл. почта уже существует.') { if (error.toString() == 'Такой Эл. почта уже существует.') {
emit(state.copyWith(isEmailValid: false, emailDisplayError: 'Такая Эл. почта уже существует.')); emit(
state.copyWith(
isEmailValid: false,
emailDisplayError: 'Такая Эл. почта уже существует.',
),
);
didApiComplainOnValidation = true; didApiComplainOnValidation = true;
} }
} }
@@ -227,7 +245,12 @@ class RegistrationCubitV2 extends Cubit<RegistrationStateV2> {
for (var error in phoneErrors) { for (var error in phoneErrors) {
allErrors.add(error.toString()); allErrors.add(error.toString());
if (error.toString() == 'Такой phone уже существует.') { if (error.toString() == 'Такой phone уже существует.') {
emit(state.copyWith(isPhoneValid: false, phoneDisplayError: 'Такой номер телефона уже существует.')); emit(
state.copyWith(
isPhoneValid: false,
phoneDisplayError: 'Такой номер телефона уже существует.',
),
);
didApiComplainOnValidation = true; didApiComplainOnValidation = true;
} }
} }
@@ -236,7 +259,12 @@ class RegistrationCubitV2 extends Cubit<RegistrationStateV2> {
for (var error in dateOfBirth) { for (var error in dateOfBirth) {
allErrors.add(error.toString()); allErrors.add(error.toString());
if (error.toString() == 'Некорректный формат даты рождения') { if (error.toString() == 'Некорректный формат даты рождения') {
emit(state.copyWith(isBirthdayValid: false, dateOfBirthDisplayError: 'Некорректный формат даты рождения')); emit(
state.copyWith(
isBirthdayValid: false,
dateOfBirthDisplayError: 'Некорректный формат даты рождения',
),
);
didApiComplainOnValidation = true; didApiComplainOnValidation = true;
} }
} }

View File

@@ -52,6 +52,7 @@ class VideoCallCallKitCoordinator {
_PendingIncomingAccept? _pendingIncomingAccept; _PendingIncomingAccept? _pendingIncomingAccept;
_AndroidAcceptResumeObserver? _androidResumeObserver; _AndroidAcceptResumeObserver? _androidResumeObserver;
_AppResumeObserver? _iosResumeObserver;
bool _pendingAcceptDrainScheduled = false; bool _pendingAcceptDrainScheduled = false;
Future<void> initialize() async { Future<void> initialize() async {
@@ -86,6 +87,12 @@ class VideoCallCallKitCoordinator {
} }
}); });
await _firebaseApi.registerPushToken(); await _firebaseApi.registerPushToken();
_iosResumeObserver = _AppResumeObserver(
onResumed: () {
unawaited(_drainPendingNativeAcceptedVoipCall());
},
);
WidgetsBinding.instance.addObserver(_iosResumeObserver!);
await _drainPendingNativeAcceptedVoipCall(); await _drainPendingNativeAcceptedVoipCall();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_scheduleNativeVoipDiagnosticsDrain(); _scheduleNativeVoipDiagnosticsDrain();
@@ -257,6 +264,11 @@ class VideoCallCallKitCoordinator {
WidgetsBinding.instance.removeObserver(observer); WidgetsBinding.instance.removeObserver(observer);
_androidResumeObserver = null; _androidResumeObserver = null;
} }
final iosObserver = _iosResumeObserver;
if (iosObserver != null) {
WidgetsBinding.instance.removeObserver(iosObserver);
_iosResumeObserver = null;
}
unawaited(_eventSubscription?.cancel()); unawaited(_eventSubscription?.cancel());
_eventSubscription = null; _eventSubscription = null;
_callKitParamsById.clear(); _callKitParamsById.clear();
@@ -1228,6 +1240,19 @@ class _PendingIncomingAccept {
final bool? counterpartIsConsultant; final bool? counterpartIsConsultant;
} }
class _AppResumeObserver with WidgetsBindingObserver {
_AppResumeObserver({required this.onResumed});
final VoidCallback onResumed;
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
onResumed();
}
}
}
class _AndroidAcceptResumeObserver with WidgetsBindingObserver { class _AndroidAcceptResumeObserver with WidgetsBindingObserver {
_AndroidAcceptResumeObserver({required this.onResumed}); _AndroidAcceptResumeObserver({required this.onResumed});

View File

@@ -1,6 +1,6 @@
name: eatvio_app name: eatvio_app
description: A new Flutter project. description: A new Flutter project.
version: 1.3.67+156 version: 1.3.69+158
publish_to: 'none' publish_to: 'none'
environment: environment:

View File

@@ -1,4 +1,5 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:eatvio_app/core/firebase_api.dart';
import 'package:eatvio_app/core/prefs.dart'; import 'package:eatvio_app/core/prefs.dart';
import 'package:eatvio_app/core/token_storage.dart'; import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/features/auth/models/oauth_verification_confirm_response.dart'; import 'package:eatvio_app/features/auth/models/oauth_verification_confirm_response.dart';
@@ -16,6 +17,17 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../../../../../fixtures/fixture_reader.dart'; import '../../../../../fixtures/fixture_reader.dart';
import 'oauth_cubit_test.mocks.dart'; import 'oauth_cubit_test.mocks.dart';
class _RecordingFirebaseApi extends Fake implements FirebaseApi {
int registerPushTokenCalls = 0;
bool hadAuthToken = false;
@override
Future<void> registerPushToken() async {
registerPushTokenCalls++;
hadAuthToken = await TokenStorage.haveTokenInStorage();
}
}
@GenerateNiceMocks([MockSpec<AuthRepository>(), MockSpec<Prefs>()]) @GenerateNiceMocks([MockSpec<AuthRepository>(), MockSpec<Prefs>()])
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
@@ -24,26 +36,36 @@ void main() {
OAuthCubit? oauthCubit; OAuthCubit? oauthCubit;
late MockAuthRepository mockAuthRepository; late MockAuthRepository mockAuthRepository;
late MockPrefs mockPrefs; late MockPrefs mockPrefs;
late _RecordingFirebaseApi firebaseApi;
late TokenModel tokenFixture; late TokenModel tokenFixture;
late Map<String, dynamic> signInRequestFixture; late Map<String, dynamic> signInRequestFixture;
late Map<String, dynamic> signUpRequestFixture; late Map<String, dynamic> signUpRequestFixture;
setUp(() async { setUp(() async {
tokenFixture = TokenModel.fromJson(FixtureReader.readAsMap('auth/token_response.json')); tokenFixture = TokenModel.fromJson(
signInRequestFixture = FixtureReader.readAsMap('auth/oauth_signin_request.json'); FixtureReader.readAsMap('auth/token_response.json'),
signUpRequestFixture = FixtureReader.readAsMap('auth/oauth_signup_request.json'); );
signInRequestFixture = FixtureReader.readAsMap(
'auth/oauth_signin_request.json',
);
signUpRequestFixture = FixtureReader.readAsMap(
'auth/oauth_signup_request.json',
);
mockAuthRepository = MockAuthRepository(); mockAuthRepository = MockAuthRepository();
mockPrefs = MockPrefs(); mockPrefs = MockPrefs();
firebaseApi = _RecordingFirebaseApi();
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
// Mock Fluttertoast plugin // Mock Fluttertoast plugin
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(const MethodChannel('PonnamKarthik/fluttertoast'), ( TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
MethodCall methodCall, .setMockMethodCallHandler(
) async { const MethodChannel('PonnamKarthik/fluttertoast'),
return null; (MethodCall methodCall) async {
}); return null;
},
);
}); });
tearDown(() async { tearDown(() async {
@@ -52,18 +74,31 @@ void main() {
oauthCubit!.close(); oauthCubit!.close();
oauthCubit = null; oauthCubit = null;
} }
TokenStorage.clearCache();
// Clear mock handlers // Clear mock handlers
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(const MethodChannel('PonnamKarthik/fluttertoast'), null); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('PonnamKarthik/fluttertoast'),
null,
);
}); });
test('начальное состояние - OAuthState', () { test('начальное состояние - OAuthState', () {
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs); oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi,
prefs: mockPrefs,
);
expect(oauthCubit!.state, const OAuthState()); expect(oauthCubit!.state, const OAuthState());
}); });
test('signInViaSocialMedia - успешный вход через соцсеть', () async { test('signInViaSocialMedia - успешный вход через соцсеть', () async {
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs); oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi,
prefs: mockPrefs,
);
when( when(
mockAuthRepository.signInViaSocialMedia( mockAuthRepository.signInViaSocialMedia(
@@ -92,7 +127,11 @@ void main() {
}); });
test('signInViaSocialMedia - переход в состояние loading', () async { test('signInViaSocialMedia - переход в состояние loading', () async {
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs); oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi,
prefs: mockPrefs,
);
when( when(
mockAuthRepository.signInViaSocialMedia( mockAuthRepository.signInViaSocialMedia(
@@ -119,7 +158,11 @@ void main() {
}); });
test('signInViaSocialMedia - обработка ошибки', () async { test('signInViaSocialMedia - обработка ошибки', () async {
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs); oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi,
prefs: mockPrefs,
);
when( when(
mockAuthRepository.signInViaSocialMedia( mockAuthRepository.signInViaSocialMedia(
@@ -144,7 +187,11 @@ void main() {
}); });
test('signUpViaSocialMedia - успешная регистрация через соцсеть', () async { test('signUpViaSocialMedia - успешная регистрация через соцсеть', () async {
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs); oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi,
prefs: mockPrefs,
);
when( when(
mockAuthRepository.signUpViaSocialMedia( mockAuthRepository.signUpViaSocialMedia(
@@ -164,7 +211,9 @@ void main() {
), ),
).thenAnswer((_) async => tokenFixture.token); ).thenAnswer((_) async => tokenFixture.token);
when(mockPrefs.setString(any, any)).thenAnswer((_) async => Future.value()); when(
mockPrefs.setString(any, any),
).thenAnswer((_) async => Future.value());
final result = await oauthCubit!.signUpViaSocialMedia( final result = await oauthCubit!.signUpViaSocialMedia(
provider: signUpRequestFixture['provider'] as String, provider: signUpRequestFixture['provider'] as String,
@@ -187,48 +236,62 @@ void main() {
expect(oauthCubit!.state, isA<OAuthStateLoaded>()); expect(oauthCubit!.state, isA<OAuthStateLoaded>());
final loadedState = oauthCubit!.state as OAuthStateLoaded; final loadedState = oauthCubit!.state as OAuthStateLoaded;
expect(loadedState.token, tokenFixture.token); expect(loadedState.token, tokenFixture.token);
expect(await TokenStorage.getTokenFromStorage(), tokenFixture.token);
expect(firebaseApi.registerPushTokenCalls, 1);
expect(firebaseApi.hadAuthToken, isTrue);
}); });
test('signInViaSocialMedia - обработка ошибки валидации email/phone', () async { test(
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs); 'signInViaSocialMedia - обработка ошибки валидации email/phone',
() async {
oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi,
prefs: mockPrefs,
);
final dioException = DioException( final dioException = DioException(
requestOptions: RequestOptions(path: '/api/auth/oauth/signin'),
response: Response(
statusCode: 422,
data: {
'errors': {
'email': ['Некорректный формат email'],
},
},
requestOptions: RequestOptions(path: '/api/auth/oauth/signin'), requestOptions: RequestOptions(path: '/api/auth/oauth/signin'),
), response: Response(
); statusCode: 422,
data: {
'errors': {
'email': ['Некорректный формат email'],
},
},
requestOptions: RequestOptions(path: '/api/auth/oauth/signin'),
),
);
when( when(
mockAuthRepository.signInViaSocialMedia( mockAuthRepository.signInViaSocialMedia(
provider: anyNamed('provider'), provider: anyNamed('provider'),
accessToken: anyNamed('accessToken'), accessToken: anyNamed('accessToken'),
refreshToken: anyNamed('refreshToken'), refreshToken: anyNamed('refreshToken'),
clientId: anyNamed('clientId'), clientId: anyNamed('clientId'),
), ),
).thenThrow(dioException); ).thenThrow(dioException);
await oauthCubit!.signInViaSocialMedia( await oauthCubit!.signInViaSocialMedia(
provider: signInRequestFixture['provider'] as String, provider: signInRequestFixture['provider'] as String,
accessToken: signInRequestFixture['access_token'] as String, accessToken: signInRequestFixture['access_token'] as String,
refreshToken: signInRequestFixture['refresh_token'] as String, refreshToken: signInRequestFixture['refresh_token'] as String,
clientId: signInRequestFixture['client_id'] as String, clientId: signInRequestFixture['client_id'] as String,
); );
await Future.delayed(const Duration(milliseconds: 100)); await Future.delayed(const Duration(milliseconds: 100));
expect(oauthCubit!.state, isA<OAuthStateError>()); expect(oauthCubit!.state, isA<OAuthStateError>());
final errorState = oauthCubit!.state as OAuthStateError; final errorState = oauthCubit!.state as OAuthStateError;
expect(errorState.errorMessage, 'validation_error'); expect(errorState.errorMessage, 'validation_error');
}); },
);
test('signUpViaSocialMedia - обработка ошибки валидации email', () async { test('signUpViaSocialMedia - обработка ошибки валидации email', () async {
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs); oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi,
prefs: mockPrefs,
);
final dioException = DioException( final dioException = DioException(
requestOptions: RequestOptions(path: '/api/auth/oauth/signup'), requestOptions: RequestOptions(path: '/api/auth/oauth/signup'),
@@ -282,82 +345,130 @@ void main() {
expect(result[0], RegistrationValidationErrors.incorrectEmailFormat); expect(result[0], RegistrationValidationErrors.incorrectEmailFormat);
}); });
test('requestOAuthVerification - успешный запрос кода верификации', () async { test(
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs); 'requestOAuthVerification - успешный запрос кода верификации',
() async {
oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi,
prefs: mockPrefs,
);
const verificationResponse = OAuthVerificationRequestResponse(email: 't***@example.com', status: 'sent'); const verificationResponse = OAuthVerificationRequestResponse(
email: 't***@example.com',
status: 'sent',
);
when(mockAuthRepository.requestOAuthVerification(email: anyNamed('email'), phone: anyNamed('phone'))).thenAnswer((_) async => verificationResponse); when(
mockAuthRepository.requestOAuthVerification(
email: anyNamed('email'),
phone: anyNamed('phone'),
),
).thenAnswer((_) async => verificationResponse);
final result = await oauthCubit!.requestOAuthVerification(email: 'test@example.com'); final result = await oauthCubit!.requestOAuthVerification(
email: 'test@example.com',
);
expect(result, isNotNull); expect(result, isNotNull);
expect(result!.email, 't***@example.com'); expect(result!.email, 't***@example.com');
expect(oauthCubit!.state, isA<OAuthState>()); expect(oauthCubit!.state, isA<OAuthState>());
}); },
);
test('requestOAuthVerification - обработка ошибки', () async { test('requestOAuthVerification - обработка ошибки', () async {
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs); oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi,
prefs: mockPrefs,
);
when(mockAuthRepository.requestOAuthVerification(email: anyNamed('email'), phone: anyNamed('phone'))).thenThrow(Exception('Ошибка запроса')); when(
mockAuthRepository.requestOAuthVerification(
email: anyNamed('email'),
phone: anyNamed('phone'),
),
).thenThrow(Exception('Ошибка запроса'));
final result = await oauthCubit!.requestOAuthVerification(email: 'test@example.com'); final result = await oauthCubit!.requestOAuthVerification(
email: 'test@example.com',
);
expect(result, isNull); expect(result, isNull);
expect(oauthCubit!.state, isA<OAuthStateError>()); expect(oauthCubit!.state, isA<OAuthStateError>());
}); });
test('confirmOAuthVerification - успешное подтверждение верификации', () async { test(
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs); 'confirmOAuthVerification - успешное подтверждение верификации',
() async {
oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi,
prefs: mockPrefs,
);
final verificationResponse = OAuthVerificationConfirmResponse(success: true, message: 'Success', token: tokenFixture.token, user: {}, isNewUser: false); final verificationResponse = OAuthVerificationConfirmResponse(
success: true,
message: 'Success',
token: tokenFixture.token,
user: {},
isNewUser: false,
);
when( when(
mockAuthRepository.confirmOAuthVerification( mockAuthRepository.confirmOAuthVerification(
provider: anyNamed('provider'), provider: anyNamed('provider'),
accessToken: anyNamed('accessToken'), accessToken: anyNamed('accessToken'),
refreshToken: anyNamed('refreshToken'), refreshToken: anyNamed('refreshToken'),
clientId: anyNamed('clientId'), clientId: anyNamed('clientId'),
id: anyNamed('id'), id: anyNamed('id'),
email: anyNamed('email'), email: anyNamed('email'),
phone: anyNamed('phone'), phone: anyNamed('phone'),
code: anyNamed('code'), code: anyNamed('code'),
verificationType: anyNamed('verificationType'), verificationType: anyNamed('verificationType'),
firstName: anyNamed('firstName'), firstName: anyNamed('firstName'),
lastName: anyNamed('lastName'), lastName: anyNamed('lastName'),
dateOfBirth: anyNamed('dateOfBirth'), dateOfBirth: anyNamed('dateOfBirth'),
gender: anyNamed('gender'), gender: anyNamed('gender'),
weight: anyNamed('weight'), weight: anyNamed('weight'),
height: anyNamed('height'), height: anyNamed('height'),
), ),
).thenAnswer((_) async => verificationResponse); ).thenAnswer((_) async => verificationResponse);
await oauthCubit!.confirmOAuthVerification( await oauthCubit!.confirmOAuthVerification(
provider: 'google', provider: 'google',
accessToken: 'access_token', accessToken: 'access_token',
refreshToken: 'refresh_token', refreshToken: 'refresh_token',
clientId: 'client_id', clientId: 'client_id',
id: 'id', id: 'id',
email: 'test@example.com', email: 'test@example.com',
phone: null, phone: null,
code: '123456', code: '123456',
verificationType: 'email', verificationType: 'email',
firstName: 'Test', firstName: 'Test',
lastName: 'User', lastName: 'User',
dateOfBirth: '2000-01-01', dateOfBirth: '2000-01-01',
gender: 'male', gender: 'male',
weight: 70, weight: 70,
height: 175, height: 175,
); );
await Future.delayed(const Duration(milliseconds: 100)); await Future.delayed(const Duration(milliseconds: 100));
expect(oauthCubit!.state, isA<OAuthStateLoaded>()); expect(oauthCubit!.state, isA<OAuthStateLoaded>());
final loadedState = oauthCubit!.state as OAuthStateLoaded; final loadedState = oauthCubit!.state as OAuthStateLoaded;
expect(loadedState.token, tokenFixture.token); expect(loadedState.token, tokenFixture.token);
}); expect(await TokenStorage.getTokenFromStorage(), tokenFixture.token);
expect(firebaseApi.registerPushTokenCalls, 1);
expect(firebaseApi.hadAuthToken, isTrue);
},
);
test('confirmOAuthVerification - обработка ошибки', () async { test('confirmOAuthVerification - обработка ошибки', () async {
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs); oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi,
prefs: mockPrefs,
);
when( when(
mockAuthRepository.confirmOAuthVerification( mockAuthRepository.confirmOAuthVerification(

View File

@@ -1,4 +1,6 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:eatvio_app/core/firebase_api.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/features/registration_v2/cubit/registration_cubit.dart'; import 'package:eatvio_app/features/registration_v2/cubit/registration_cubit.dart';
import 'package:eatvio_app/features/registration_v2/repositories/registration_repository_v2.dart'; import 'package:eatvio_app/features/registration_v2/repositories/registration_repository_v2.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -9,43 +11,60 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'registration_cubit_v2_test.mocks.dart'; import 'registration_cubit_v2_test.mocks.dart';
class _RecordingFirebaseApi extends Fake implements FirebaseApi {
int registerPushTokenCalls = 0;
bool hadAuthToken = false;
@override
Future<void> registerPushToken() async {
registerPushTokenCalls++;
hadAuthToken = await TokenStorage.haveTokenInStorage();
}
}
@GenerateMocks([RegistrationRepositoryV2]) @GenerateMocks([RegistrationRepositoryV2])
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
group('RegistrationCubitV2', () { group('RegistrationCubitV2', () {
late RegistrationCubitV2 cubit; late RegistrationCubitV2 cubit;
late MockRegistrationRepositoryV2 mockRepository; late MockRegistrationRepositoryV2 mockRepository;
const MethodChannel passwordGeneratorChannel = MethodChannel('flutter_password_generator'); late _RecordingFirebaseApi firebaseApi;
const MethodChannel passwordGeneratorChannel = MethodChannel(
'flutter_password_generator',
);
setUp(() { setUp(() {
mockRepository = MockRegistrationRepositoryV2(); mockRepository = MockRegistrationRepositoryV2();
cubit = RegistrationCubitV2(registrationRepository: mockRepository); firebaseApi = _RecordingFirebaseApi();
cubit = RegistrationCubitV2(
registrationRepository: mockRepository,
firebaseApi: firebaseApi,
);
// Инициализируем SharedPreferences для тестов // Инициализируем SharedPreferences для тестов
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
// Мокируем MethodChannel для flutter_password_generator // Мокируем MethodChannel для flutter_password_generator
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
passwordGeneratorChannel, .setMockMethodCallHandler(passwordGeneratorChannel, (
(MethodCall methodCall) async { MethodCall methodCall,
if (methodCall.method == 'generateSecureRandomData') { ) async {
// Возвращаем Uint8List для генерации пароля if (methodCall.method == 'generateSecureRandomData') {
// MethodChannel автоматически преобразует Uint8List в List<int> // Возвращаем Uint8List для генерации пароля
return Uint8List.fromList(List<int>.generate(32, (i) => i % 256)); // MethodChannel автоматически преобразует Uint8List в List<int>
} return Uint8List.fromList(List<int>.generate(32, (i) => i % 256));
return null; }
}, return null;
); });
}); });
tearDown(() { tearDown(() {
TokenStorage.clearCache();
cubit.close(); cubit.close();
// Очищаем моки // Очищаем моки
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
passwordGeneratorChannel, .setMockMethodCallHandler(passwordGeneratorChannel, null);
null,
);
}); });
test('начальное состояние', () { test('начальное состояние', () {
@@ -182,14 +201,24 @@ void main() {
cubit.setBirthday(DateTime(1990, 1, 1)); cubit.setBirthday(DateTime(1990, 1, 1));
const token = 'test-token'; const token = 'test-token';
when(mockRepository.register(registrationModel: anyNamed('registrationModel'))) when(
.thenAnswer((_) async => token); mockRepository.register(
registrationModel: anyNamed('registrationModel'),
),
).thenAnswer((_) async => token);
final result = await cubit.register(); final result = await cubit.register();
expect(result.$1, isTrue); expect(result.$1, isTrue);
expect(result.$2, isFalse); expect(result.$2, isFalse);
verify(mockRepository.register(registrationModel: anyNamed('registrationModel'))).called(1); expect(await TokenStorage.getTokenFromStorage(), token);
expect(firebaseApi.registerPushTokenCalls, 1);
expect(firebaseApi.hadAuthToken, isTrue);
verify(
mockRepository.register(
registrationModel: anyNamed('registrationModel'),
),
).called(1);
}); });
test('register - возвращает false при невалидных полях', () async { test('register - возвращает false при невалидных полях', () async {
@@ -199,7 +228,11 @@ void main() {
expect(result.$1, isFalse); expect(result.$1, isFalse);
expect(result.$2, isTrue); expect(result.$2, isTrue);
verifyNever(mockRepository.register(registrationModel: anyNamed('registrationModel'))); verifyNever(
mockRepository.register(
registrationModel: anyNamed('registrationModel'),
),
);
}); });
test('register - обработка ошибки валидации email', () async { test('register - обработка ошибки валидации email', () async {
@@ -226,16 +259,21 @@ void main() {
), ),
); );
when(mockRepository.register(registrationModel: anyNamed('registrationModel'))) when(
.thenThrow(dioException); mockRepository.register(
registrationModel: anyNamed('registrationModel'),
),
).thenThrow(dioException);
final result = await cubit.register(); final result = await cubit.register();
expect(result.$1, isFalse); expect(result.$1, isFalse);
expect(result.$2, isTrue); expect(result.$2, isTrue);
expect(cubit.state.isEmailValid, isFalse); expect(cubit.state.isEmailValid, isFalse);
expect(cubit.state.emailDisplayError, equals('Некорректный формат email')); expect(
cubit.state.emailDisplayError,
equals('Некорректный формат email'),
);
}); });
}); });
} }