Compare commits

...

21 Commits

Author SHA1 Message Date
misha
55e1633d1c fix: android ci runner changed 2026-11-02 11:42:14 +03:00
5b2ac4f228 Merge pull request 'Исправлен чужой аватар после смены аккаунта' (#1117) from fix/1113-stale-avatar-account-switch into main
Reviewed-on: eatvio/eatvio_app#1117
2026-11-02 08:14:04 +00:00
51aff6c175 test(session): cover avatar refresh and session teardown
Add regression tests for avatar reset and token-change refresh, stale 401 handling, idempotent session teardown and account-scoped preference cleanup. Update existing cubit tests for the new session dependency.

Refs: #1113
2026-10-30 15:15:36 +03:00
cba2689406 feat(session): centralize session teardown and refresh
Add SessionLifecycleService to reset user-scoped cubits, analytics, socket id and account-scoped preferences on logout, 401 or account deletion. Run the same refresh after every successful auth path, including registration v1/v2, and ignore 401 responses from stale tokens so an old request cannot terminate the new user's session.

Refs: #1113
2026-10-30 15:15:28 +03:00
5f0b7e026b fix(profile): refresh bottom-nav avatar on account switch
Reset ProfileAvatarCubit and user-scoped feed, chat and notification state so stale data from the previous account cannot be rendered. Refresh the avatar when the stored token changes and rebuild nav tabs when the profile identity or consultant role changes.

Refs: #1113
2026-10-30 15:15:21 +03:00
3a41a85f9d Merge branch 'main' of ssh://10.55.5.123:222/eatvio/eatvio_app 2026-10-29 14:22:06 +03:00
194b8d5b16 Merge pull request 'feat(video_calls): app opening from terminated state fix' (#1109) from misha/eatvio_app:issue-1089 into main
Reviewed-on: eatvio/eatvio_app#1109
2026-10-29 10:59:30 +00:00
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
e2d287eb9e Merge branch 'main' of ssh://10.55.5.123:222/eatvio/eatvio_app 2026-10-27 16:22:59 +03:00
cf5c42ff4b Merge pull request 'feat(video_calls): tokens registration' (#1104) from misha/eatvio_app:issue-1089 into main
Reviewed-on: eatvio/eatvio_app#1104
Reviewed-by: Анатолий Полоз <t0ly.od@ya.ru>
2026-10-27 13:09:45 +00: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
28c62bf775 Merge branch 'main' of ssh://10.55.5.123:222/eatvio/eatvio_app 2026-10-26 12:26:48 +03:00
dedb55ecca Merge branch 'main' of ssh://10.55.5.123:222/eatvio/eatvio_app 2026-10-23 15:17:38 +03:00
04dab02a6d Merge branch 'main' of ssh://10.55.5.123:222/eatvio/eatvio_app 2026-10-23 12:37:10 +03:00
db293bf13f Merge branch 'main' of ssh://10.55.5.123:222/eatvio/eatvio_app 2026-10-22 16:23:38 +03:00
fe54037cef Merge branch 'main' of ssh://10.55.5.123:222/eatvio/eatvio_app 2026-10-22 13:11:31 +03:00
c70c9beb20 Merge branch 'main' of ssh://10.55.5.123:222/eatvio/eatvio_app 2026-10-21 17:55:55 +03:00
37a787c8d0 Merge branch 'main' of ssh://10.55.5.123:222/eatvio/eatvio_app 2026-10-21 17:03:44 +03:00
3b8d1bf651 Merge branch 'issue-1089' of ssh://10.55.5.123:222/misha/eatvio_app 2026-10-21 14:31:39 +03:00
50 changed files with 1707 additions and 503 deletions

View File

@@ -14,7 +14,7 @@ env:
jobs: jobs:
build: build:
runs-on: [ubuntu-latest] runs-on: [debian-12]
steps: steps:
- uses: https://github.com/actions/checkout@v3 - uses: https://github.com/actions/checkout@v3
@@ -155,10 +155,10 @@ jobs:
- name: Get dependencies - name: Get dependencies
run: | run: |
flutter clean flutter clean
flutter pub cache repair flutter pub cache repair
flutter pub get flutter pub get
dart run build_runner build --delete-conflicting-outputs dart run build_runner build --delete-conflicting-outputs
- name: Native Analyze - name: Native Analyze
run: flutter analyze run: flutter analyze

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,69 +71,174 @@ 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(),
systemPipCoordinator: sl(), systemPipCoordinator: sl(),
), ),
); );
sl.registerLazySingleton<FeedCubit>(() => FeedCubit(feedRepository: sl()));
} }
/// Регистрация 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(),
sessionLifecycleService: sl(),
),
);
sl.registerFactory<RegistrationCubit>(
() => RegistrationCubit(
registrationRepository: sl(),
prefs: sl(),
sessionLifecycleService: 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(),
sessionLifecycleService: sl(),
),
);
sl.registerFactory<RegistrationCubitV2>(
() => RegistrationCubitV2(
registrationRepository: sl(),
firebaseApi: sl(),
sessionLifecycleService: sl(),
),
);
// Feed related cubits // Feed related cubits
sl.registerFactory<FeedCubit>(() => FeedCubit(feedRepository: sl())); sl.registerFactory<RecipesCubit>(
sl.registerFactory<RecipesCubit>(() => RecipesCubit(feedRepository: sl(), recipeRepository: sl(), aboutRecipeRepository: sl())); () => RecipesCubit(
sl.registerFactory<DietsCubit>(() => DietsCubit(feedRepository: sl(), dietsRepository: sl())); feedRepository: sl(),
sl.registerFactory<PlansCubit>(() => PlansCubit(feedRepository: sl(), planRepository: sl())); recipeRepository: sl(),
sl.registerFactory<ConsultantsCubit>(() => ConsultantsCubit(feedRepository: sl())); aboutRecipeRepository: sl(),
sl.registerFactory<CommentsCubit>(() => CommentsCubit(feedRepository: sl(), commentsRepository: 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 +250,74 @@ 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(),
sessionLifecycleService: 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>(
sl.registerFactory<MenuCubit>(() => MenuCubit(sl())); () => UserDetailsCubit(profileRepository: sl()),
);
sl.registerFactory<MenuCubit>(() => MenuCubit(sl(), 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

@@ -0,0 +1,15 @@
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
class SessionRegistrations {
static void registerAll() {
sl.registerLazySingleton<SessionLifecycleService>(
() => SessionLifecycleService(
wsManager: sl<WsManager>(),
errorReporter: sl<ErrorReporter>(),
),
);
}
}

View File

@@ -1,9 +1,7 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
import 'package:eatvio_app/core/token_storage.dart'; import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/locator_service.dart';
import 'package:go_router/go_router.dart';
class TokenInterceptor extends Interceptor { class TokenInterceptor extends Interceptor {
@override @override
@@ -14,6 +12,7 @@ class TokenInterceptor extends Interceptor {
final token = await TokenStorage.getTokenFromStorage(); final token = await TokenStorage.getTokenFromStorage();
if (token != null && token.isNotEmpty) { if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token'; options.headers['Authorization'] = 'Bearer $token';
options.extra['authToken'] = token;
} }
super.onRequest(options, handler); super.onRequest(options, handler);
@@ -25,9 +24,18 @@ class TokenInterceptor extends Interceptor {
ErrorInterceptorHandler handler, ErrorInterceptorHandler handler,
) async { ) async {
if (err.response?.statusCode == 401) { if (err.response?.statusCode == 401) {
await sl<WsManager>().clear(); final requestCarriedAuth = err.requestOptions.headers.containsKey(
await TokenStorage.removeTokenInStorage(); 'Authorization',
AppRouter.navigatorKey.currentContext?.go('/auth-screen'); );
final requestToken = err.requestOptions.extra['authToken'];
final currentToken = await TokenStorage.getTokenFromStorage();
if (requestCarriedAuth &&
requestToken != null &&
requestToken == currentToken) {
await sl<SessionLifecycleService>().endSession(
reason: SessionEndReason.unauthorized,
);
}
} }
super.onError(err, handler); super.onError(err, handler);
} }

View File

@@ -5,6 +5,7 @@ import 'package:eatvio_app/core/di/cubit_registrations.dart';
import 'package:eatvio_app/core/di/external_registrations.dart'; import 'package:eatvio_app/core/di/external_registrations.dart';
import 'package:eatvio_app/core/di/repository_registrations.dart'; import 'package:eatvio_app/core/di/repository_registrations.dart';
import 'package:eatvio_app/core/di/service_registrations.dart'; import 'package:eatvio_app/core/di/service_registrations.dart';
import 'package:eatvio_app/core/di/session_registrations.dart';
import 'package:eatvio_app/core/prefs.dart'; import 'package:eatvio_app/core/prefs.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart'; import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
@@ -38,4 +39,6 @@ Future<void> init({required AppBuildType buildType}) async {
sl.registerLazySingleton<WsManager>( sl.registerLazySingleton<WsManager>(
() => WsManager(wsService: sl(), errorReporter: sl()), () => WsManager(wsService: sl(), errorReporter: sl()),
); );
SessionRegistrations.registerAll();
} }

View File

@@ -1,6 +1,5 @@
import 'package:eatvio_app/core/routing/route_constants.dart'; import 'package:eatvio_app/core/routing/route_constants.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/widgets/bottom_nav_scope.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/widgets/bottom_nav_scope.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart';
import 'package:eatvio_app/features/chat/presentation/pages/chats_screen.dart'; import 'package:eatvio_app/features/chat/presentation/pages/chats_screen.dart';
@@ -24,14 +23,10 @@ class MainNavigationRoutes {
return [ return [
ShellRoute( ShellRoute(
builder: (context, state, child) { builder: (context, state, child) {
// Инициализация ProfileAvatarCubit при первом использовании ShellRoute
final profileAvatarCubit = context.read<ProfileAvatarCubit>(); final profileAvatarCubit = context.read<ProfileAvatarCubit>();
if (profileAvatarCubit.state is ProfileAvatarStateLoading) { WidgetsBinding.instance.addPostFrameCallback((_) {
// Вызываем getMyProfile() только если состояние loading (первый запуск) profileAvatarCubit.ensureFreshForCurrentSession();
WidgetsBinding.instance.addPostFrameCallback((_) { });
profileAvatarCubit.getMyProfile();
});
}
return _MainShellWithNavScope(currentLocation: state.matchedLocation, child: child); return _MainShellWithNavScope(currentLocation: state.matchedLocation, child: child);
}, },

View File

@@ -0,0 +1,122 @@
import 'dart:async';
import 'package:appmetrica_plugin/appmetrica_plugin.dart';
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
import 'package:eatvio_app/core/firebase_api.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/prefs.dart';
import 'package:eatvio_app/core/prefs_keys.dart';
import 'package:eatvio_app/core/routing/route_constants.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/new_messages_cubit/new_messages_cubit.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
import 'package:go_router/go_router.dart';
enum SessionEndReason { logout, unauthorized, accountDeleted }
class SessionLifecycleService {
SessionLifecycleService({
required WsManager wsManager,
required ErrorReporter errorReporter,
}) : _wsManager = wsManager,
_errorReporter = errorReporter;
final WsManager _wsManager;
final ErrorReporter _errorReporter;
int? _authenticatedFingerprint;
bool _endingSession = false;
Future<void> endSession({required SessionEndReason reason}) async {
if (_endingSession) return;
_endingSession = true;
try {
await _run(() => _wsManager.clear());
await _run(TokenStorage.removeTokenInStorage);
await _run(SocketIdStorage.removeSocketIdInStorage);
await _run(() => sl<ProfileAvatarCubit>().reset());
await _run(() => sl<ChatsCubit>().reset());
await _run(() => sl<NotificationsCubit>().reset());
await _run(() => sl<NewMessagesCubit>().reset());
await _run(() => sl<FeedCubit>().reset());
await _run(() async => ProfileCubit.clearMyProfileReference());
await _run(() => sl<VideoCallSessionCubit>().endCall());
await _run(() async => AppMetrica.setUserProfileID(null));
await _run(
() => sl<Prefs>().remove(PrefsKeys.isEmailVerificationSkipped),
);
await _run(() => sl<Prefs>().remove(PrefsKeys.registrationDate));
_authenticatedFingerprint = null;
await _run(() async {
final context = AppRouter.navigatorKey.currentContext;
if (context == null) return;
final path =
AppRouter.appRouter.routeInformationProvider.value.uri.path;
if (path != RouteConstants.authScreen) {
context.go(RouteConstants.authScreen);
}
});
} finally {
_endingSession = false;
}
}
Future<void> onAuthenticated() async {
String? token;
try {
token = await TokenStorage.getTokenFromStorage();
} catch (e, st) {
await _report(e, st);
return;
}
if (token == null || token.isEmpty) return;
final fingerprint = token.hashCode;
if (_authenticatedFingerprint == fingerprint) return;
_authenticatedFingerprint = fingerprint;
await _run(() async {
await _wsManager.clear();
await _wsManager.initialize();
});
await _run(() => sl<ProfileAvatarCubit>().getMyProfile());
await _run(() => sl<ChatsCubit>().getChatRoomsWithLoading());
await _run(() => sl<NotificationsCubit>().getNotifications());
await _run(() => sl<FirebaseApi>().registerPushToken());
}
Future<void> logout() async {
try {
await _run(() => sl<FirebaseApi>().unregisterPushTokens());
await _run(
() => sl<Dio>().post('${sl<AppConfig>().baseUrl}/api/profile/logout'),
);
} finally {
await endSession(reason: SessionEndReason.logout);
}
}
Future<void> _run(FutureOr<void> Function() action) async {
try {
await action();
} catch (e, st) {
await _report(e, st);
}
}
Future<void> _report(Object error, StackTrace stackTrace) {
return _errorReporter.report(
error,
stackTrace,
origin: 'SessionLifecycleService',
);
}
}

View File

@@ -3,6 +3,7 @@ import 'dart:developer';
import 'package:eatvio_app/core/firebase_api.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/prefs_keys.dart'; import 'package:eatvio_app/core/prefs_keys.dart';
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
import 'package:eatvio_app/core/token_storage.dart'; import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/features/auth/presentation/bloc/auth_cubit/auth_state.dart'; import 'package:eatvio_app/features/auth/presentation/bloc/auth_cubit/auth_state.dart';
@@ -17,15 +18,26 @@ class AuthCubit extends Cubit<AuthState> {
final ProfileRepository profileRepository; final ProfileRepository profileRepository;
final FirebaseApi firebaseApi; final FirebaseApi firebaseApi;
final Prefs prefs; final Prefs prefs;
final SessionLifecycleService sessionLifecycleService;
AuthCubit({required this.authRepository, required this.prefs, required this.profileRepository, required this.firebaseApi}) : super(const AuthState()); AuthCubit({
required this.authRepository,
required this.prefs,
required this.profileRepository,
required this.firebaseApi,
required this.sessionLifecycleService,
}) : super(const AuthState());
Future<bool> getTokenByCredentials(String email, String password) async { Future<bool> getTokenByCredentials(String email, String password) async {
emit(const AuthState.loading()); emit(const AuthState.loading());
try { try {
final token = (await authRepository.getTokenByCredentials(email, password)).token; final token = (await authRepository.getTokenByCredentials(
email,
password,
)).token;
emit(AuthState.loaded(token: token)); emit(AuthState.loaded(token: token));
await TokenStorage.saveTokenInStorage(token); await TokenStorage.saveTokenInStorage(token);
await sessionLifecycleService.onAuthenticated();
return true; return true;
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
@@ -48,7 +60,8 @@ class AuthCubit extends Cubit<AuthState> {
Future<void> registerAnonymousPushToken() async { Future<void> registerAnonymousPushToken() async {
try { try {
final hasAnonymousTokenBeenSent = prefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent) ?? false; final hasAnonymousTokenBeenSent =
prefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent) ?? false;
if (!hasAnonymousTokenBeenSent) { if (!hasAnonymousTokenBeenSent) {
await firebaseApi.registerAnonymousPushToken(); await firebaseApi.registerAnonymousPushToken();
prefs.setBool(PrefsKeys.hasAnonymousTokenBeenSent, true); prefs.setBool(PrefsKeys.hasAnonymousTokenBeenSent, true);

View File

@@ -1,7 +1,9 @@
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/session/session_lifecycle_service.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_request_response.dart'; import 'package:eatvio_app/features/auth/models/oauth_verification_request_response.dart';
import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_state.dart'; import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_state.dart';
@@ -11,12 +13,24 @@ 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;
final SessionLifecycleService sessionLifecycleService;
OAuthCubit({required this.authRepository, required Prefs prefs}) : super(const OAuthState()) { OAuthCubit({
required this.authRepository,
required this.firebaseApi,
required this.sessionLifecycleService,
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 +42,8 @@ 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);
await sessionLifecycleService.onAuthenticated();
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 +60,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 +69,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 +125,8 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
height: height, height: height,
clientId: clientId, clientId: clientId,
); );
TokenStorage.saveTokenInStorage(token); await TokenStorage.saveTokenInStorage(token);
await sessionLifecycleService.onAuthenticated();
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 +143,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 +152,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 +173,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 +221,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 +241,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 sessionLifecycleService.onAuthenticated();
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 +254,3 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
} }
} }
} }

View File

@@ -4,12 +4,10 @@ import 'package:eatvio_app/app_keys.dart';
import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/const.dart'; import 'package:eatvio_app/core/const.dart';
import 'package:eatvio_app/core/firebase_api.dart';
import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/utils/image_constant.dart'; import 'package:eatvio_app/core/utils/image_constant.dart';
import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/core/widgets/custom_divider.dart'; import 'package:eatvio_app/core/widgets/custom_divider.dart';
import 'package:eatvio_app/core/widgets/custom_text_form_field.dart'; import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
import 'package:eatvio_app/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart'; import 'package:eatvio_app/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart';
@@ -18,8 +16,6 @@ import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_cub
import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_state.dart'; import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_state.dart';
import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart'; import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart';
import 'package:eatvio_app/core/di/cubit_registrations.dart'; import 'package:eatvio_app/core/di/cubit_registrations.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart'; import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart';
import 'package:eatvio_app/generated/l10n.dart'; import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
@@ -68,23 +64,8 @@ class _AuthScreenState extends State<AuthScreen> {
super.dispose(); super.dispose();
} }
Future<void> _reconnectWebSocket(BuildContext context) async { Future<void> _reconnectWebSocket() async {
try { showSuccessToast('Успешный вход');
await sl<WsManager>().clear();
await sl<WsManager>().initialize();
} catch (e) {
rethrow;
}
if (context.mounted) {
try {
await sl<ProfileAvatarCubit>().getMyProfile();
await sl<ChatsCubit>().getChatRoomsWithLoading();
await sl<FirebaseApi>().registerPushToken();
showSuccessToast('Успешный вход');
} catch (e) {
rethrow;
}
}
AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false}); AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
} }
@@ -202,18 +183,7 @@ class _AuthScreenState extends State<AuthScreen> {
} }
Future<void> _reconnectAfterAuth() async { Future<void> _reconnectAfterAuth() async {
final context = AppRouter.navigatorKey.currentContext; showSuccessToast('Успешный вход');
if (context == null) return;
try {
await sl<WsManager>().clear();
await sl<WsManager>().initialize();
await sl<ProfileAvatarCubit>().getMyProfile();
await sl<ChatsCubit>().getChatRoomsWithLoading();
await sl<FirebaseApi>().registerPushToken();
showSuccessToast('Успешный вход');
} catch (e) {
log(e.toString());
}
AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false}); AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
} }
@@ -395,7 +365,7 @@ class _AuthScreenState extends State<AuthScreen> {
final isAuthenticated = await authCubit.getTokenByCredentials(emailController.text, passwordController.text); final isAuthenticated = await authCubit.getTokenByCredentials(emailController.text, passwordController.text);
if (isAuthenticated) { if (isAuthenticated) {
if (context.mounted) { if (context.mounted) {
await _reconnectWebSocket(context); await _reconnectWebSocket();
} }
} }
setState(() { setState(() {

View File

@@ -9,11 +9,7 @@ import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_sta
import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart'; import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart';
import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart'; import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart';
import 'package:eatvio_app/core/firebase_api.dart';
import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/core/utils/validation_utils.dart'; import 'package:eatvio_app/core/utils/validation_utils.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
@@ -62,23 +58,8 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
_initializeFieldsFromAuthResult(); _initializeFieldsFromAuthResult();
} }
Future<void> _reconnectWebSocket(BuildContext context) async { Future<void> _reconnectWebSocket() async {
try { showSuccessToast('Успешная регистрация');
await sl<WsManager>().clear();
await sl<WsManager>().initialize();
} catch (e) {
rethrow;
}
if (context.mounted) {
try {
await sl<ProfileAvatarCubit>().getMyProfile();
await sl<ChatsCubit>().getChatRoomsWithLoading();
await sl<FirebaseApi>().registerPushToken();
showSuccessToast('Успешная регистрация');
} catch (e) {
rethrow;
}
}
AppRouter.appRouter.go('/feed-screen', extra: {'needToInitializeWebSocket': false}); AppRouter.appRouter.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
} }
@@ -451,7 +432,7 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
Builder( Builder(
builder: (context) { builder: (context) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_reconnectWebSocket(context); _reconnectWebSocket();
}); });
return const SizedBox.shrink(); return const SizedBox.shrink();
}, },
@@ -795,7 +776,7 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
// Перезагружаем веб-сокет и переходим на главный экран // Перезагружаем веб-сокет и переходим на главный экран
if (widgetContext.mounted) { if (widgetContext.mounted) {
_reconnectWebSocket(widgetContext); _reconnectWebSocket();
} }
} catch (e, stackTrace) { } catch (e, stackTrace) {
log('OAuth Verification Confirm Error: $e', error: e, stackTrace: stackTrace); log('OAuth Verification Confirm Error: $e', error: e, stackTrace: stackTrace);

View File

@@ -3,13 +3,10 @@ import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/firebase_api.dart';
import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
import 'package:eatvio_app/core/token_storage.dart'; import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:qr_code_scanner_plus/qr_code_scanner_plus.dart'; import 'package:qr_code_scanner_plus/qr_code_scanner_plus.dart';
@@ -39,7 +36,10 @@ class _ScanQRCodeScreenState extends State<ScanQRCodeScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Сканирование QR-кода'), centerTitle: true), appBar: AppBar(
title: const Text('Сканирование QR-кода'),
centerTitle: true,
),
body: Column( body: Column(
children: <Widget>[ children: <Widget>[
Expanded( Expanded(
@@ -60,30 +60,33 @@ class _ScanQRCodeScreenState extends State<ScanQRCodeScreen> {
try { try {
final scannedUrl = scanData.code; final scannedUrl = scanData.code;
final uri = Uri.parse(scannedUrl ?? ''); final uri = Uri.parse(scannedUrl ?? '');
final scannedBaseUrl = (uri.hasAuthority) ? '${uri.scheme}://${uri.host}' : ''; final scannedBaseUrl = (uri.hasAuthority)
? '${uri.scheme}://${uri.host}'
: '';
sl<AppConfig>().setBaseUrl(scannedBaseUrl); sl<AppConfig>().setBaseUrl(scannedBaseUrl);
sl<Dio>().options.baseUrl = scannedBaseUrl; sl<Dio>().options.baseUrl = scannedBaseUrl;
final response = await Dio().getUri(uri, options: Options(headers: {'accept': 'application/json', 'content-type': 'application/json'})); final response = await Dio().getUri(
uri,
options: Options(
headers: {
'accept': 'application/json',
'content-type': 'application/json',
},
),
);
final token = response.data['token']; final token = response.data['token'];
await TokenStorage.saveTokenInStorage(token); await TokenStorage.saveTokenInStorage(token);
try { await sl<SessionLifecycleService>().onAuthenticated();
await sl<WsManager>().clear(); AppRouter.navigatorKey.currentContext?.go(
await sl<WsManager>().initialize(); '/feed-screen',
} catch (e) { extra: {'needToInitializeWebSocket': false},
rethrow; );
}
try {
await sl<ProfileAvatarCubit>().getMyProfile();
await sl<ChatsCubit>().getChatRoomsWithLoading();
await sl<FirebaseApi>().registerPushToken();
} catch (e) {
rethrow;
}
AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
showSuccessToast('Добро пожаловать!'); showSuccessToast('Добро пожаловать!');
} catch (e) { } catch (e) {
await controller.resumeCamera(); await controller.resumeCamera();
showErrorToast('Что-то пошло не так. Попробуйте обновить страницу с QR-кодом или войдите с помощью логина и пароля.'); showErrorToast(
'Что-то пошло не так. Попробуйте обновить страницу с QR-кодом или войдите с помощью логина и пароля.',
);
} }
}); });
} }

View File

@@ -6,6 +6,13 @@ class NewMessagesCubit extends Cubit<NewMessagesState> {
NewMessagesCubit() : super(const NewMessagesState.empty()); NewMessagesCubit() : super(const NewMessagesState.empty());
void reset() {
_messagesCount = 0;
if (!isClosed) {
emit(const NewMessagesState.empty());
}
}
void incrementCounter() { void incrementCounter() {
_messagesCount++; _messagesCount++;
emit(NewMessagesState.loaded(messagesCount: _messagesCount)); emit(NewMessagesState.loaded(messagesCount: _messagesCount));

View File

@@ -1,18 +1,22 @@
import 'dart:async'; import 'dart:async';
import 'package:appmetrica_plugin/appmetrica_plugin.dart'; import 'package:appmetrica_plugin/appmetrica_plugin.dart';
import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart';
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart'; import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:eatvio_app/core/error_reporting/report_from.dart'; import 'package:eatvio_app/core/error_reporting/report_from.dart';
import 'package:go_router/go_router.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
class ProfileAvatarCubit extends Cubit<ProfileAvatarState> { class ProfileAvatarCubit extends Cubit<ProfileAvatarState> {
final ProfileRepository profileRepository; final ProfileRepository profileRepository;
ProfileAvatarCubit({required this.profileRepository}) : super(const ProfileAvatarState.loading()); ProfileAvatarCubit({required this.profileRepository})
: super(const ProfileAvatarState.loading());
int _requestId = 0;
int? _sessionFingerprint;
bool _ensuring = false;
int _calculateAge(String birthdate) { int _calculateAge(String birthdate) {
try { try {
@@ -25,7 +29,8 @@ class ProfileAvatarCubit extends Cubit<ProfileAvatarState> {
final now = DateTime.now(); final now = DateTime.now();
int age = now.year - birthYear; int age = now.year - birthYear;
if (now.month < birthMonth || (now.month == birthMonth && now.day < birthDay)) { if (now.month < birthMonth ||
(now.month == birthMonth && now.day < birthDay)) {
age--; age--;
} }
@@ -38,30 +43,82 @@ class ProfileAvatarCubit extends Cubit<ProfileAvatarState> {
return 0; return 0;
} }
void reset() {
_requestId++;
_sessionFingerprint = null;
unawaited(AppMetrica.setUserProfileID(null).catchError((Object _) {}));
if (!isClosed) emit(const ProfileAvatarState.loading());
}
Future<void> ensureFreshForCurrentSession() async {
final token = await TokenStorage.getTokenFromStorage();
if (token == null || token.isEmpty) {
reset();
return;
}
final fingerprint = token.hashCode;
if (_sessionFingerprint == fingerprint &&
state is ProfileAvatarStateLoaded) {
return;
}
if (_ensuring) {
return;
}
_ensuring = true;
try {
await getMyProfile();
} finally {
_ensuring = false;
}
}
Future<void> getMyProfile() async { Future<void> getMyProfile() async {
final requestId = ++_requestId;
final token = await TokenStorage.getTokenFromStorage();
final fingerprint = token?.hashCode;
if (isClosed || requestId != _requestId) return;
emit(const ProfileAvatarState.loading()); emit(const ProfileAvatarState.loading());
try { try {
final profile = await profileRepository.getMyProfile(); final profile = await profileRepository.getMyProfile();
if (isClosed || requestId != _requestId) return;
final profileDetails = await profileRepository.getMyProfileDetails(); final profileDetails = await profileRepository.getMyProfileDetails();
if (isClosed || requestId != _requestId) return;
_sessionFingerprint = fingerprint;
emit(ProfileAvatarState.loaded(profile: profile)); emit(ProfileAvatarState.loaded(profile: profile));
try { try {
final notificationStatus = await Permission.notification.status; final notificationStatus = await Permission.notification.status;
if (isClosed || requestId != _requestId) return;
final isNotificationEnabled = notificationStatus.isGranted; final isNotificationEnabled = notificationStatus.isGranted;
AppMetricaUserProfile userProfile = AppMetricaUserProfile([ AppMetricaUserProfile userProfile = AppMetricaUserProfile([
AppMetricaNameAttribute.withValue(profile.name), AppMetricaNameAttribute.withValue(profile.name),
AppMetricaGenderAttribute.withValue(profileDetails.gender == 'male' ? AppMetricaGender.male : AppMetricaGender.female), AppMetricaGenderAttribute.withValue(
AppMetricaBirthDateAttribute.withAge(_calculateAge(profileDetails.birthdate)), profileDetails.gender == 'male'
AppMetricaNotificationEnabledAttribute.withValue(isNotificationEnabled), ? AppMetricaGender.male
: AppMetricaGender.female,
),
AppMetricaBirthDateAttribute.withAge(
_calculateAge(profileDetails.birthdate),
),
AppMetricaNotificationEnabledAttribute.withValue(
isNotificationEnabled,
),
]); ]);
AppMetrica.setUserProfileID(profile.slug); AppMetrica.setUserProfileID(profile.slug);
AppMetrica.reportUserProfile(userProfile); AppMetrica.reportUserProfile(userProfile);
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
// Игнорируем ошибки плагинов, профиль уже загружен
} }
} catch (e, st) { } catch (e, st) {
if (isClosed || requestId != _requestId) return;
await reportFrom(e, st, this); await reportFrom(e, st, this);
AppRouter.navigatorKey.currentContext?.go('/auth-screen'); if (isClosed || requestId != _requestId) return;
emit(ProfileAvatarState.error(errorMessage: e.toString())); emit(ProfileAvatarState.error(errorMessage: e.toString()));
} }
} }

View File

@@ -39,6 +39,8 @@ class BottomNavigationBarWidget extends StatefulWidget {
class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> { class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
int currentBottomNavBarIndex = 1; int currentBottomNavBarIndex = 1;
List<String>? pages; List<String>? pages;
int? _pagesProfileId;
bool? _pagesIsConsultant;
int tapsCount = 0; int tapsCount = 0;
List<String> _getPages(bool isConsultant) { List<String> _getPages(bool isConsultant) {
@@ -179,7 +181,16 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
final isConsultant = profileModel.roles.contains('consultant'); final isConsultant = profileModel.roles.contains('consultant');
// Инициализируем pages на основе profileModel // Инициализируем pages на основе profileModel
pages ??= _getPages(isConsultant); if (pages == null ||
_pagesProfileId != profileModel.id ||
_pagesIsConsultant != isConsultant) {
_pagesProfileId = profileModel.id;
_pagesIsConsultant = isConsultant;
pages = _getPages(isConsultant);
if (currentBottomNavBarIndex >= pages!.length) {
currentBottomNavBarIndex = 0;
}
}
// Expose tab switching to descendants (e.g. notification widget) // Expose tab switching to descendants (e.g. notification widget)
final controller = BottomNavScope.maybeOf(context); final controller = BottomNavScope.maybeOf(context);

View File

@@ -57,6 +57,17 @@ class ChatsCubit extends Cubit<ChatsState> {
int page = 1; int page = 1;
late int lastPage; late int lastPage;
/// Clears all user-scoped state. WS subscriptions stay intact so that
/// `getChatRoomsWithLoading` can re-load the new user's chats.
void reset() {
_query = null;
page = 1;
lastPage = 1;
if (!isClosed) {
emit(ChatsState());
}
}
Future<void> connectToWebSocket() async { Future<void> connectToWebSocket() async {
try { try {
await newIncomingMessagesSubscription?.cancel(); await newIncomingMessagesSubscription?.cancel();

View File

@@ -12,9 +12,19 @@ class FeedCubit extends Cubit<FeedState> {
bool _hasMore = true; bool _hasMore = true;
bool isLoading = false; bool isLoading = false;
String? _query; String? _query;
int _requestId = 0;
FeedCubit({required this.feedRepository}) : super(const FeedState.loading()); FeedCubit({required this.feedRepository}) : super(const FeedState.loading());
void reset() {
_requestId++;
_nextCursor = null;
_hasMore = true;
isLoading = false;
_query = null;
if (!isClosed) emit(const FeedState.loading());
}
void updateFeedBlogPostList(FeedBlogPostModel? feedBlogPostModel, bool wasLikedByMe) { void updateFeedBlogPostList(FeedBlogPostModel? feedBlogPostModel, bool wasLikedByMe) {
if (feedBlogPostModel == null || isClosed || state is! FeedStateLoaded) return; if (feedBlogPostModel == null || isClosed || state is! FeedStateLoaded) return;
final List<FeedBlogPostModel> feedBlogPostList = [...(state as FeedStateLoaded).feedBlogPostList]; final List<FeedBlogPostModel> feedBlogPostList = [...(state as FeedStateLoaded).feedBlogPostList];
@@ -39,17 +49,18 @@ class FeedCubit extends Cubit<FeedState> {
} }
Future<void> loadFeedBlogPostList({String? query, int? currentUserId}) async { Future<void> loadFeedBlogPostList({String? query, int? currentUserId}) async {
final requestId = ++_requestId;
try { try {
_query = query; _query = query;
_nextCursor = null; _nextCursor = null;
_hasMore = true; _hasMore = true;
final feed = await feedRepository.getFeed(search: query, currentUserId: currentUserId); final feed = await feedRepository.getFeed(search: query, currentUserId: currentUserId);
if (isClosed || requestId != _requestId) return;
_nextCursor = feed.nextCursor; _nextCursor = feed.nextCursor;
_hasMore = _nextCursor != null; _hasMore = _nextCursor != null;
if (!isClosed) { emit(FeedState.loaded(feedBlogPostList: feed.posts, isLoading: false));
emit(FeedState.loaded(feedBlogPostList: feed.posts, isLoading: false));
}
} catch (e, st) { } catch (e, st) {
if (isClosed || requestId != _requestId) return;
await reportFrom(e, st, this); await reportFrom(e, st, this);
if (!isClosed) { if (!isClosed) {
emit(FeedState.error(errorMessage: e.toString())); emit(FeedState.error(errorMessage: e.toString()));
@@ -62,14 +73,16 @@ class FeedCubit extends Cubit<FeedState> {
return; return;
} }
final requestId = _requestId;
isLoading = true; isLoading = true;
emit((state as FeedStateLoaded).copyWith(isLoading: true)); emit((state as FeedStateLoaded).copyWith(isLoading: true));
try { try {
final feed = await feedRepository.getFeed(search: _query, cursor: _nextCursor, currentUserId: currentUserId); final feed = await feedRepository.getFeed(search: _query, cursor: _nextCursor, currentUserId: currentUserId);
if (isClosed || requestId != _requestId) return;
_nextCursor = feed.nextCursor; _nextCursor = feed.nextCursor;
_hasMore = _nextCursor != null; _hasMore = _nextCursor != null;
if (!isClosed && state is FeedStateLoaded) { if (state is FeedStateLoaded) {
emit( emit(
FeedState.loaded( FeedState.loaded(
feedBlogPostList: [...(state as FeedStateLoaded).feedBlogPostList, ...feed.posts], feedBlogPostList: [...(state as FeedStateLoaded).feedBlogPostList, ...feed.posts],
@@ -78,14 +91,15 @@ class FeedCubit extends Cubit<FeedState> {
); );
} }
} catch (e, st) { } catch (e, st) {
if (isClosed || requestId != _requestId) return;
await reportFrom(e, st, this); await reportFrom(e, st, this);
if (!isClosed && state is FeedStateLoaded) { if (state is FeedStateLoaded) {
emit((state as FeedStateLoaded).copyWith(isLoading: false)); emit((state as FeedStateLoaded).copyWith(isLoading: false));
} else if (!isClosed) { } else {
emit(FeedState.error(errorMessage: e.toString())); emit(FeedState.error(errorMessage: e.toString()));
} }
} finally { } finally {
isLoading = false; if (requestId == _requestId) isLoading = false;
} }
} }

View File

@@ -1,5 +1,6 @@
import 'dart:developer'; import 'dart:developer';
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
import 'package:eatvio_app/features/menu/presentation/bloc/menu_cubit/menu_state.dart'; import 'package:eatvio_app/features/menu/presentation/bloc/menu_cubit/menu_state.dart';
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart'; import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@@ -7,7 +8,11 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart';
class MenuCubit extends Cubit<MenuState> { class MenuCubit extends Cubit<MenuState> {
final ProfileRepository profileRepository; final ProfileRepository profileRepository;
MenuCubit(this.profileRepository) : super(const MenuState.initial()); final SessionLifecycleService sessionLifecycleService;
MenuCubit(this.profileRepository, this.sessionLifecycleService)
: super(const MenuState.initial());
Future<void> logout() => sessionLifecycleService.logout();
Future<void> getMyProfile() async { Future<void> getMyProfile() async {
emit(const MenuState.loading()); emit(const MenuState.loading());
@@ -23,7 +28,10 @@ class MenuCubit extends Cubit<MenuState> {
Future<String?> makePassKey() async { Future<String?> makePassKey() async {
try { try {
final passKey = await profileRepository.getPassKey(routeName: 'product_nutritional_value.index', routeParams: {}); final passKey = await profileRepository.getPassKey(
routeName: 'product_nutritional_value.index',
routeParams: {},
);
return passKey; return passKey;
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);

View File

@@ -1,22 +1,15 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
import 'package:eatvio_app/core/const.dart'; import 'package:eatvio_app/core/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/theme/custom_text_style.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/core/utils/image_constant.dart'; import 'package:eatvio_app/core/utils/image_constant.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/widgets/data_error_widget.dart'; import 'package:eatvio_app/core/widgets/data_error_widget.dart';
import 'package:eatvio_app/core/widgets/loader.dart'; import 'package:eatvio_app/core/widgets/loader.dart';
import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/features/consultant_rating/presentation/consultant_rating_screen.dart'; import 'package:eatvio_app/features/consultant_rating/presentation/consultant_rating_screen.dart';
import 'package:eatvio_app/features/finances_and_consultations_archive/presentation/consultant_finances_screen.dart'; import 'package:eatvio_app/features/finances_and_consultations_archive/presentation/consultant_finances_screen.dart';
import 'package:eatvio_app/features/finances_and_consultations_archive/presentation/user_purchases_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/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/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_diets/presentation/my_diets_screen.dart';
import 'package:eatvio_app/features/my_plans/presentation/my_plans_screen.dart'; import 'package:eatvio_app/features/my_plans/presentation/my_plans_screen.dart';
@@ -155,6 +148,7 @@ class MenuScreen extends StatelessWidget {
Future<Null> Function() _onTapLogout(BuildContext context) { Future<Null> Function() _onTapLogout(BuildContext context) {
return () async { return () async {
final menuCubit = context.read<MenuCubit>();
showDialog( showDialog(
context: context, context: context,
builder: (context) => CupertinoAlertDialog( builder: (context) => CupertinoAlertDialog(
@@ -162,25 +156,8 @@ class MenuScreen extends StatelessWidget {
actions: [ actions: [
CupertinoDialogAction( CupertinoDialogAction(
onPressed: () async { onPressed: () async {
try { Navigator.of(context).pop();
// Logout revokes the Sanctum token. Unregister first, or await menuCubit.logout();
// FCM / VoIP tokens stay on the server and calls still ring.
await sl<FirebaseApi>().unregisterPushTokens();
} catch (e, s) {
await sl<ErrorReporter>().report(e, s, origin: 'MenuScreen');
}
try {
await sl<Dio>().post('${sl<AppConfig>().baseUrl}/api/profile/logout');
} catch (e, s) {
await sl<ErrorReporter>().report(e, s, origin: 'MenuScreen');
} finally {
await sl<VideoCallSessionCubit>().endCall();
await sl<WsManager>().clear();
await TokenStorage.removeTokenInStorage();
if (context.mounted) {
GoRouter.of(context).go('/auth-screen', extra: true);
}
}
}, },
isDestructiveAction: true, isDestructiveAction: true,
child: const Text("Выйти"), child: const Text("Выйти"),

View File

@@ -16,6 +16,11 @@ class NotificationsCubit extends Cubit<NotificationsState> {
StreamSubscription? _newNotificationsSubscription; StreamSubscription? _newNotificationsSubscription;
NotificationsCubit({required this.wsManager, required this.notificationsRepository}) : super(const NotificationsState.empty()); NotificationsCubit({required this.wsManager, required this.notificationsRepository}) : super(const NotificationsState.empty());
void reset() {
_notifications.clear();
if (!isClosed) emit(const NotificationsState.empty());
}
Future<void> connectToWebSocket() async { Future<void> connectToWebSocket() async {
try { try {
await _newNotificationsSubscription?.cancel(); await _newNotificationsSubscription?.cancel();

View File

@@ -22,6 +22,10 @@ class ProfileCubit extends Cubit<ProfileState> {
/// Bottom-nav / own profile instance, used to refresh posts after create/update/delete. /// Bottom-nav / own profile instance, used to refresh posts after create/update/delete.
static ProfileCubit? myProfileCubit; static ProfileCubit? myProfileCubit;
static void clearMyProfileReference() {
myProfileCubit = null;
}
ProfileCubit({required this.profileRepository, required this.chatRepository, required this.consultantRepository}) : super(const ProfileState.loading()); ProfileCubit({required this.profileRepository, required this.chatRepository, required this.consultantRepository}) : super(const ProfileState.loading());
Future<void> getProfileBySlug(String slug) async { Future<void> getProfileBySlug(String slug) async {

View File

@@ -1,11 +1,9 @@
import 'dart:developer'; import 'dart:developer';
import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/firebase_api.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/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/session/session_lifecycle_service.dart';
import 'package:eatvio_app/core/token_storage.dart'; import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/features/registration/models/user_registration_model.dart'; import 'package:eatvio_app/features/registration/models/user_registration_model.dart';
import 'package:eatvio_app/features/registration/presentation/bloc/registration_cubit/registration_state.dart'; import 'package:eatvio_app/features/registration/presentation/bloc/registration_cubit/registration_state.dart';
@@ -18,7 +16,8 @@ import 'package:go_router/go_router.dart';
class RegistrationCubit extends Cubit<RegistrationState> with RegistrationDateCacheMixin { class RegistrationCubit extends Cubit<RegistrationState> with RegistrationDateCacheMixin {
final RegistrationRepository registrationRepository; final RegistrationRepository registrationRepository;
RegistrationCubit({required this.registrationRepository, required Prefs prefs}) final SessionLifecycleService sessionLifecycleService;
RegistrationCubit({required this.registrationRepository, required this.sessionLifecycleService, required Prefs prefs})
: super( : super(
const RegistrationState( const RegistrationState(
sheet: UserRegistrationSheet( sheet: UserRegistrationSheet(
@@ -62,7 +61,7 @@ class RegistrationCubit extends Cubit<RegistrationState> with RegistrationDateCa
state.sheet.copyWith(phone: state.sheet.phone.replaceAll(' ', '').replaceAll('(', '').replaceAll(')', ''), responseBody: '{}'), state.sheet.copyWith(phone: state.sheet.phone.replaceAll(' ', '').replaceAll('(', '').replaceAll(')', ''), responseBody: '{}'),
); );
await TokenStorage.saveTokenInStorage(token); await TokenStorage.saveTokenInStorage(token);
await sl<FirebaseApi>().registerPushToken(); await sessionLifecycleService.onAuthenticated();
RegistrationAnalyticsService.reportRegistrationComplete(); RegistrationAnalyticsService.reportRegistrationComplete();
cacheRegistrationDate(DateTime.now()); cacheRegistrationDate(DateTime.now());
AppRouter.navigatorKey.currentContext?.replaceNamed('verifyEmailScreen'); AppRouter.navigatorKey.currentContext?.replaceNamed('verifyEmailScreen');

View File

@@ -1,6 +1,8 @@
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/session/session_lifecycle_service.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 +15,13 @@ 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;
final SessionLifecycleService sessionLifecycleService;
RegistrationCubitV2({
required this.registrationRepository,
required this.firebaseApi,
required this.sessionLifecycleService,
}) : super(const RegistrationStateV2());
void setFirstName(String firstName) { void setFirstName(String firstName) {
emit(state.copyWith(firstName: firstName)); emit(state.copyWith(firstName: firstName));
@@ -194,9 +202,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 sessionLifecycleService.onAuthenticated();
emit(state.copyWith(registrationError: null)); emit(state.copyWith(registrationError: null));
return (true, false); return (true, false);
} }
@@ -214,11 +225,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 +248,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 +262,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

@@ -1,3 +1,5 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
import 'package:eatvio_app/features/consultations/repositories/consultations_repository.dart'; import 'package:eatvio_app/features/consultations/repositories/consultations_repository.dart';
@@ -17,12 +19,14 @@ class SettingsCubit extends Cubit<SettingsState> {
final SettingsRepository settingsRepository; final SettingsRepository settingsRepository;
final ConsultantRepository consultantRepository; final ConsultantRepository consultantRepository;
final ConsultationsRepository consultationsRepository; final ConsultationsRepository consultationsRepository;
final SessionLifecycleService sessionLifecycleService;
SettingsCubit({ SettingsCubit({
required this.profileRepository, required this.profileRepository,
required this.settingsRepository, required this.settingsRepository,
required this.consultantRepository, required this.consultantRepository,
required this.consultationsRepository, required this.consultationsRepository,
required this.sessionLifecycleService,
}) : super(const SettingsState.loading()); }) : super(const SettingsState.loading());
Future<void> init() async { Future<void> init() async {
@@ -30,10 +34,12 @@ class SettingsCubit extends Cubit<SettingsState> {
late final ConsultationRequestModel? consultantInfo; late final ConsultationRequestModel? consultantInfo;
late final bool hasActiveConsultation; late final bool hasActiveConsultation;
late final OAuthAccountsDataModel? oauthAccounts; late final OAuthAccountsDataModel? oauthAccounts;
try { try {
if (profile.phoneConfirmationState.contains('confirmed')) { if (profile.phoneConfirmationState.contains('confirmed')) {
consultantInfo = await consultantRepository.getMyRequestStatus(profile.slug); consultantInfo = await consultantRepository.getMyRequestStatus(
profile.slug,
);
} else { } else {
consultantInfo = null; consultantInfo = null;
} }
@@ -41,14 +47,14 @@ class SettingsCubit extends Cubit<SettingsState> {
await reportFrom(e, st, this); await reportFrom(e, st, this);
consultantInfo = null; consultantInfo = null;
} }
try { try {
hasActiveConsultation = await _checkActiveConsultation(); hasActiveConsultation = await _checkActiveConsultation();
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
hasActiveConsultation = false; hasActiveConsultation = false;
} }
try { try {
final oauthResponse = await settingsRepository.getOAuthAccounts(); final oauthResponse = await settingsRepository.getOAuthAccounts();
oauthAccounts = oauthResponse.data; oauthAccounts = oauthResponse.data;
@@ -56,7 +62,7 @@ class SettingsCubit extends Cubit<SettingsState> {
await reportFrom(e, st, this); await reportFrom(e, st, this);
oauthAccounts = null; oauthAccounts = null;
} }
try { try {
emit( emit(
SettingsState.loaded( SettingsState.loaded(
@@ -74,9 +80,13 @@ class SettingsCubit extends Cubit<SettingsState> {
} }
} }
Future<void> updateProfileDetails({required ProfileDetailsModel profileDetailsModel}) async { Future<void> updateProfileDetails({
required ProfileDetailsModel profileDetailsModel,
}) async {
try { try {
await settingsRepository.updateProfileDetails(profileDetailsModel: profileDetailsModel); await settingsRepository.updateProfileDetails(
profileDetailsModel: profileDetailsModel,
);
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
} finally { } finally {
@@ -84,9 +94,13 @@ class SettingsCubit extends Cubit<SettingsState> {
} }
} }
Future<void> updateProfileSettings({required ProfileSettingsModel profileSettingsModel}) async { Future<void> updateProfileSettings({
required ProfileSettingsModel profileSettingsModel,
}) async {
try { try {
await settingsRepository.updateProfileSettings(profileSettingsModel: profileSettingsModel); await settingsRepository.updateProfileSettings(
profileSettingsModel: profileSettingsModel,
);
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
} finally { } finally {
@@ -97,9 +111,15 @@ class SettingsCubit extends Cubit<SettingsState> {
Future<bool> deleteAccount() async { Future<bool> deleteAccount() async {
try { try {
await profileRepository.deleteAccount(); await profileRepository.deleteAccount();
await sessionLifecycleService.endSession(
reason: SessionEndReason.accountDeleted,
);
return true; return true;
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
if (e is DioException && e.response?.statusCode == 401) {
return true;
}
return false; return false;
} }
} }
@@ -135,11 +155,14 @@ class SettingsCubit extends Cubit<SettingsState> {
Future<bool> _checkActiveConsultation() async { Future<bool> _checkActiveConsultation() async {
try { try {
final userPurchases = await consultationsRepository.getUserPurchases(null, null); final userPurchases = await consultationsRepository.getUserPurchases(
null,
null,
);
final activeStates = ChosenPlanStateEnum.getActiveStates(); final activeStates = ChosenPlanStateEnum.getActiveStates();
return userPurchases.any((purchase) => return userPurchases.any(
activeStates.contains(purchase.chosenPlanState) (purchase) => activeStates.contains(purchase.chosenPlanState),
); );
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);

View File

@@ -698,7 +698,6 @@ class _DeleteAccountBottomSheet extends State<DeleteAccountBottomSheet> {
onPressed: () async { onPressed: () async {
final result = await widget.cubit.deleteAccount(); final result = await widget.cubit.deleteAccount();
if (result == true) { if (result == true) {
AppRouter.navigatorKey.currentContext?.go('/auth-screen');
return; return;
} }
AppRouter.navigatorKey.currentContext?.pop(); AppRouter.navigatorKey.currentContext?.pop();

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

@@ -0,0 +1,118 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/interceptors/token_interceptor.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../fixtures/doubles/session_doubles.dart';
class _RecordingErrorInterceptorHandler extends ErrorInterceptorHandler {
int nextCalls = 0;
DioException? nextError;
@override
void next(DioException error) {
nextCalls++;
nextError = error;
}
@override
void resolve(Response<dynamic> response) {
throw UnimplementedError('resolve should not be called');
}
@override
void reject(
DioException error, [
bool callFollowingErrorInterceptor = false,
]) {
throw UnimplementedError('reject should not be called');
}
}
DioException _unauthorized(RequestOptions options) {
return DioException(
requestOptions: options,
response: Response<dynamic>(requestOptions: options, statusCode: 401),
type: DioExceptionType.badResponse,
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late FakeSessionLifecycleService sessionLifecycleService;
late TokenInterceptor interceptor;
setUp(() async {
SharedPreferences.setMockInitialValues({});
TokenStorage.clearCache();
await sl.reset();
sessionLifecycleService = FakeSessionLifecycleService();
sl.registerSingleton<SessionLifecycleService>(sessionLifecycleService);
interceptor = TokenInterceptor();
});
tearDown(() async {
await sl.reset();
TokenStorage.clearCache();
});
group('TokenInterceptor.onError 401', () {
test('request without Authorization does not end the session', () async {
await TokenStorage.saveTokenInStorage('current-token');
final options = RequestOptions(path: '/api/profile/index');
final error = _unauthorized(options);
final handler = _RecordingErrorInterceptorHandler();
await interceptor.onError(error, handler);
expect(handler.nextCalls, 1);
expect(handler.nextError, same(error));
expect(sessionLifecycleService.endSessionCalls, 0);
});
test('stale request token does not end the new session', () async {
await TokenStorage.saveTokenInStorage('new-token');
final options = RequestOptions(path: '/api/profile/index');
options.headers['Authorization'] = 'Bearer old-token';
options.extra['authToken'] = 'old-token';
final error = _unauthorized(options);
final handler = _RecordingErrorInterceptorHandler();
await interceptor.onError(error, handler);
expect(handler.nextCalls, 1);
expect(sessionLifecycleService.endSessionCalls, 0);
});
test(
'current request token ends the session once as unauthorized',
() async {
await TokenStorage.saveTokenInStorage('current-token');
final options = RequestOptions(path: '/api/profile/index');
options.headers['Authorization'] = 'Bearer current-token';
options.extra['authToken'] = 'current-token';
final error = _unauthorized(options);
final handler = _RecordingErrorInterceptorHandler();
await interceptor.onError(error, handler);
expect(handler.nextCalls, 1);
expect(sessionLifecycleService.endSessionCalls, 1);
expect(
sessionLifecycleService.lastEndReason,
SessionEndReason.unauthorized,
);
},
);
});
}

View File

@@ -1,22 +1,24 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:eatvio_app/core/interceptors/token_interceptor.dart'; import 'package:eatvio_app/core/interceptors/token_interceptor.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../../fixtures/doubles/session_doubles.dart';
class MockRequestOptions extends Mock implements RequestOptions {} class MockRequestOptions extends Mock implements RequestOptions {}
class MockRequestInterceptorHandler extends Mock implements RequestInterceptorHandler {} class MockRequestInterceptorHandler extends Mock
implements RequestInterceptorHandler {}
class MockErrorInterceptorHandler extends Mock implements ErrorInterceptorHandler {} class MockErrorInterceptorHandler extends Mock
implements ErrorInterceptorHandler {}
class MockResponse extends Mock implements Response {} class MockResponse extends Mock implements Response {}
class MockWsManager extends Mock implements WsManager {}
class MockDioException extends Mock implements DioException {} class MockDioException extends Mock implements DioException {}
class FakeRequestOptions extends Fake implements RequestOptions {} class FakeRequestOptions extends Fake implements RequestOptions {}
@@ -32,23 +34,18 @@ void main() {
}); });
group('TokenInterceptor', () { group('TokenInterceptor', () {
late TokenInterceptor interceptor; late TokenInterceptor interceptor;
late FakeSessionLifecycleService fakeSession;
setUp(() async { setUp(() async {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
TokenStorage.clearCache();
final mockWsManager = MockWsManager();
when(() => mockWsManager.clear()).thenAnswer((_) async {}); await sl.reset();
fakeSession = FakeSessionLifecycleService();
await sl.reset(); // Сбрасываем sl перед каждым тестом sl.registerSingleton<SessionLifecycleService>(fakeSession);
sl.registerSingleton<WsManager>(mockWsManager);
interceptor = TokenInterceptor(); interceptor = TokenInterceptor();
// Сбрасываем токен перед каждым тестом await TokenStorage.removeTokenInStorage();
try {
await TokenStorage.removeTokenInStorage();
} catch (e) {
// Игнорируем ошибки при очистке
}
}); });
group('onRequest', () { group('onRequest', () {
@@ -59,6 +56,7 @@ void main() {
final headers = <String, dynamic>{}; final headers = <String, dynamic>{};
final options = MockRequestOptions(); final options = MockRequestOptions();
when(() => options.headers).thenReturn(headers); when(() => options.headers).thenReturn(headers);
when(() => options.extra).thenReturn(<String, dynamic>{});
final handler = MockRequestInterceptorHandler(); final handler = MockRequestInterceptorHandler();
when(() => handler.next(any())).thenReturn(null); when(() => handler.next(any())).thenReturn(null);
@@ -73,6 +71,7 @@ void main() {
final headers = <String, dynamic>{}; final headers = <String, dynamic>{};
final options = MockRequestOptions(); final options = MockRequestOptions();
when(() => options.headers).thenReturn(headers); when(() => options.headers).thenReturn(headers);
when(() => options.extra).thenReturn(<String, dynamic>{});
final handler = MockRequestInterceptorHandler(); final handler = MockRequestInterceptorHandler();
when(() => handler.next(any())).thenReturn(null); when(() => handler.next(any())).thenReturn(null);
@@ -89,6 +88,7 @@ void main() {
final headers = <String, dynamic>{}; final headers = <String, dynamic>{};
final options = MockRequestOptions(); final options = MockRequestOptions();
when(() => options.headers).thenReturn(headers); when(() => options.headers).thenReturn(headers);
when(() => options.extra).thenReturn(<String, dynamic>{});
final handler = MockRequestInterceptorHandler(); final handler = MockRequestInterceptorHandler();
when(() => handler.next(any())).thenReturn(null); when(() => handler.next(any())).thenReturn(null);
@@ -101,11 +101,16 @@ void main() {
}); });
group('onError', () { group('onError', () {
test('удаляет токен и перенаправляет на auth при 401', () async { test('завершает сессию при 401 для текущего токена', () async {
const token = 'test-token'; const token = 'test-token';
await TokenStorage.saveTokenInStorage(token); await TokenStorage.saveTokenInStorage(token);
final options = MockRequestOptions(); final options = MockRequestOptions();
when(
() => options.headers,
).thenReturn({'Authorization': 'Bearer $token'});
when(() => options.extra).thenReturn({'authToken': token});
final response = MockResponse(); final response = MockResponse();
when(() => response.statusCode).thenReturn(401); when(() => response.statusCode).thenReturn(401);
@@ -116,14 +121,10 @@ void main() {
final handler = MockErrorInterceptorHandler(); final handler = MockErrorInterceptorHandler();
when(() => handler.next(any())).thenReturn(null); when(() => handler.next(any())).thenReturn(null);
// Проверяем, что токен был удален
final tokenBefore = await TokenStorage.getTokenFromStorage();
expect(tokenBefore, token);
await interceptor.onError(error, handler); await interceptor.onError(error, handler);
final tokenAfter = await TokenStorage.getTokenFromStorage(); expect(fakeSession.endSessionCalls, 1);
expect(tokenAfter, null); expect(fakeSession.lastEndReason, SessionEndReason.unauthorized);
verify(() => handler.next(error)).called(1); verify(() => handler.next(error)).called(1);
}); });

View File

@@ -0,0 +1,223 @@
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/prefs.dart';
import 'package:eatvio_app/core/prefs_keys.dart';
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/new_messages_cubit/new_messages_cubit.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
class _FakeWsManager implements WsManager {
int clearCalls = 0;
@override
Future<void> clear() async {
clearCalls++;
}
@override
dynamic noSuchMethod(Invocation invocation) {
throw UnimplementedError('WsManager.${invocation.memberName}');
}
}
class _FakeErrorReporter implements ErrorReporter {
int reportCalls = 0;
@override
Future<void> report(
Object error,
StackTrace? stackTrace, {
String? origin,
Map<String, Object?>? extras,
}) async {
reportCalls++;
}
}
class _FakeProfileAvatarCubit implements ProfileAvatarCubit {
int resetCalls = 0;
@override
void reset() {
resetCalls++;
}
@override
dynamic noSuchMethod(Invocation invocation) {
throw UnimplementedError('ProfileAvatarCubit.${invocation.memberName}');
}
}
class _FakeChatsCubit implements ChatsCubit {
int resetCalls = 0;
@override
void reset() {
resetCalls++;
}
@override
dynamic noSuchMethod(Invocation invocation) {
throw UnimplementedError('ChatsCubit.${invocation.memberName}');
}
}
class _FakeNotificationsCubit implements NotificationsCubit {
int resetCalls = 0;
@override
void reset() {
resetCalls++;
}
@override
dynamic noSuchMethod(Invocation invocation) {
throw UnimplementedError('NotificationsCubit.${invocation.memberName}');
}
}
class _FakeNewMessagesCubit implements NewMessagesCubit {
int resetCalls = 0;
@override
void reset() {
resetCalls++;
}
@override
dynamic noSuchMethod(Invocation invocation) {
throw UnimplementedError('NewMessagesCubit.${invocation.memberName}');
}
}
class _FakeFeedCubit implements FeedCubit {
int resetCalls = 0;
@override
void reset() {
resetCalls++;
}
@override
dynamic noSuchMethod(Invocation invocation) {
throw UnimplementedError('FeedCubit.${invocation.memberName}');
}
}
class _FakeVideoCallSessionCubit implements VideoCallSessionCubit {
int endCallCalls = 0;
@override
Future<void> endCall() async {
endCallCalls++;
}
@override
dynamic noSuchMethod(Invocation invocation) {
throw UnimplementedError('VideoCallSessionCubit.${invocation.memberName}');
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late _FakeWsManager wsManager;
late _FakeProfileAvatarCubit profileAvatarCubit;
late _FakeChatsCubit chatsCubit;
late _FakeNotificationsCubit notificationsCubit;
late _FakeNewMessagesCubit newMessagesCubit;
late _FakeFeedCubit feedCubit;
late _FakeVideoCallSessionCubit videoCallSessionCubit;
late SessionLifecycleService service;
setUp(() async {
SharedPreferences.setMockInitialValues({
'token': 'token-a',
'socketId': 'socket-a',
PrefsKeys.customBaseUrl.name: 'https://old.example',
PrefsKeys.isEmailVerificationSkipped.name: true,
PrefsKeys.registrationDate.name: '2024-01-01',
PrefsKeys.welcomeScreenWasShowed.name: true,
});
TokenStorage.clearCache();
await sl.reset();
wsManager = _FakeWsManager();
profileAvatarCubit = _FakeProfileAvatarCubit();
chatsCubit = _FakeChatsCubit();
notificationsCubit = _FakeNotificationsCubit();
newMessagesCubit = _FakeNewMessagesCubit();
feedCubit = _FakeFeedCubit();
videoCallSessionCubit = _FakeVideoCallSessionCubit();
sl.registerSingleton<ProfileAvatarCubit>(profileAvatarCubit);
sl.registerSingleton<ChatsCubit>(chatsCubit);
sl.registerSingleton<NotificationsCubit>(notificationsCubit);
sl.registerSingleton<NewMessagesCubit>(newMessagesCubit);
sl.registerSingleton<FeedCubit>(feedCubit);
sl.registerSingleton<VideoCallSessionCubit>(videoCallSessionCubit);
sl.registerSingleton<Prefs>(Prefs(await SharedPreferences.getInstance()));
service = SessionLifecycleService(
wsManager: wsManager,
errorReporter: _FakeErrorReporter(),
);
});
tearDown(() async {
await sl.reset();
TokenStorage.clearCache();
});
group('SessionLifecycleService.endSession', () {
test(
'resets user-scoped state and clears only session-scoped keys',
() async {
await service.endSession(reason: SessionEndReason.logout);
expect(wsManager.clearCalls, 1);
expect(profileAvatarCubit.resetCalls, 1);
expect(chatsCubit.resetCalls, 1);
expect(notificationsCubit.resetCalls, 1);
expect(newMessagesCubit.resetCalls, 1);
expect(feedCubit.resetCalls, 1);
expect(videoCallSessionCubit.endCallCalls, 1);
expect(await TokenStorage.getTokenFromStorage(), isNull);
expect(await SocketIdStorage.getSocketIdFromStorage(), isNull);
final prefs = sl<Prefs>();
expect(prefs.getString(PrefsKeys.customBaseUrl), 'https://old.example');
expect(prefs.getBool(PrefsKeys.isEmailVerificationSkipped), isNull);
expect(prefs.getString(PrefsKeys.registrationDate), isNull);
expect(prefs.getBool(PrefsKeys.welcomeScreenWasShowed), isTrue);
},
);
test('is idempotent for concurrent calls', () async {
final first = service.endSession(reason: SessionEndReason.logout);
final second = service.endSession(reason: SessionEndReason.unauthorized);
await Future.wait([first, second]);
expect(wsManager.clearCalls, 1);
expect(profileAvatarCubit.resetCalls, 1);
expect(chatsCubit.resetCalls, 1);
expect(notificationsCubit.resetCalls, 1);
expect(newMessagesCubit.resetCalls, 1);
expect(feedCubit.resetCalls, 1);
expect(videoCallSessionCubit.endCallCalls, 1);
expect(await TokenStorage.getTokenFromStorage(), isNull);
expect(await SocketIdStorage.getSocketIdFromStorage(), isNull);
});
});
}

View File

@@ -12,6 +12,7 @@ import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart'; import 'package:mockito/mockito.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../../../../../fixtures/doubles/session_doubles.dart';
import 'auth_cubit_test.mocks.dart'; import 'auth_cubit_test.mocks.dart';
@GenerateNiceMocks([ @GenerateNiceMocks([
@@ -75,6 +76,7 @@ void main() {
profileRepository: mockProfileRepository, profileRepository: mockProfileRepository,
firebaseApi: mockFirebaseApi, firebaseApi: mockFirebaseApi,
prefs: mockPrefs, prefs: mockPrefs,
sessionLifecycleService: FakeSessionLifecycleService(),
); );
expect(cubit!.state, isA<AuthState>()); expect(cubit!.state, isA<AuthState>());
@@ -86,6 +88,7 @@ void main() {
profileRepository: mockProfileRepository, profileRepository: mockProfileRepository,
firebaseApi: mockFirebaseApi, firebaseApi: mockFirebaseApi,
prefs: mockPrefs, prefs: mockPrefs,
sessionLifecycleService: FakeSessionLifecycleService(),
); );
const email = 'test@example.com'; const email = 'test@example.com';
@@ -116,6 +119,7 @@ void main() {
profileRepository: mockProfileRepository, profileRepository: mockProfileRepository,
firebaseApi: mockFirebaseApi, firebaseApi: mockFirebaseApi,
prefs: mockPrefs, prefs: mockPrefs,
sessionLifecycleService: FakeSessionLifecycleService(),
); );
const email = 'test@example.com'; const email = 'test@example.com';
@@ -140,6 +144,7 @@ void main() {
profileRepository: mockProfileRepository, profileRepository: mockProfileRepository,
firebaseApi: mockFirebaseApi, firebaseApi: mockFirebaseApi,
prefs: mockPrefs, prefs: mockPrefs,
sessionLifecycleService: FakeSessionLifecycleService(),
); );
when(mockPrefs.getBool(any)).thenReturn(false); when(mockPrefs.getBool(any)).thenReturn(false);
@@ -159,6 +164,7 @@ void main() {
profileRepository: mockProfileRepository, profileRepository: mockProfileRepository,
firebaseApi: mockFirebaseApi, firebaseApi: mockFirebaseApi,
prefs: mockPrefs, prefs: mockPrefs,
sessionLifecycleService: FakeSessionLifecycleService(),
); );
when(mockPrefs.getBool(any)).thenReturn(true); when(mockPrefs.getBool(any)).thenReturn(true);
@@ -174,6 +180,7 @@ void main() {
profileRepository: mockProfileRepository, profileRepository: mockProfileRepository,
firebaseApi: mockFirebaseApi, firebaseApi: mockFirebaseApi,
prefs: mockPrefs, prefs: mockPrefs,
sessionLifecycleService: FakeSessionLifecycleService(),
); );
when(mockFirebaseApi.registerPushToken()) when(mockFirebaseApi.registerPushToken())
@@ -190,6 +197,7 @@ void main() {
profileRepository: mockProfileRepository, profileRepository: mockProfileRepository,
firebaseApi: mockFirebaseApi, firebaseApi: mockFirebaseApi,
prefs: mockPrefs, prefs: mockPrefs,
sessionLifecycleService: FakeSessionLifecycleService(),
); );
// TokenStorage.haveTokenInStorage() вернет false // TokenStorage.haveTokenInStorage() вернет false

View File

@@ -13,6 +13,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../../../../fixtures/doubles/auth_doubles.dart'; import '../../../../fixtures/doubles/auth_doubles.dart';
import '../../../../fixtures/doubles/profile_doubles.dart'; import '../../../../fixtures/doubles/profile_doubles.dart';
import '../../../../fixtures/doubles/session_doubles.dart';
import '../../../../fixtures/fixture_reader.dart'; import '../../../../fixtures/fixture_reader.dart';
import 'auth_cubit_test.mocks.dart'; import 'auth_cubit_test.mocks.dart';
@@ -26,6 +27,7 @@ void main() {
late FixtureProfileRepository profileRepository; late FixtureProfileRepository profileRepository;
late MockPrefs mockPrefs; late MockPrefs mockPrefs;
late MockFirebaseApi mockFirebaseApi; late MockFirebaseApi mockFirebaseApi;
late FakeSessionLifecycleService fakeSession;
late Map<String, dynamic> credentialsFixture; late Map<String, dynamic> credentialsFixture;
late TokenModel tokenFixture; late TokenModel tokenFixture;
@@ -37,6 +39,7 @@ void main() {
profileRepository = FixtureProfileRepository(); profileRepository = FixtureProfileRepository();
mockPrefs = MockPrefs(); mockPrefs = MockPrefs();
mockFirebaseApi = MockFirebaseApi(); mockFirebaseApi = MockFirebaseApi();
fakeSession = FakeSessionLifecycleService();
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
// Mock Fluttertoast plugin // Mock Fluttertoast plugin
@@ -59,12 +62,12 @@ void main() {
}); });
test('начальное состояние - AuthState', () { test('начальное состояние - AuthState', () {
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession);
expect(authCubit!.state, const AuthState()); expect(authCubit!.state, const AuthState());
}); });
test('getTokenByCredentials - успешная авторизация', () async { test('getTokenByCredentials - успешная авторизация', () async {
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession);
final result = await authCubit!.getTokenByCredentials(credentialsFixture['email'] as String, credentialsFixture['password'] as String); final result = await authCubit!.getTokenByCredentials(credentialsFixture['email'] as String, credentialsFixture['password'] as String);
@@ -77,7 +80,7 @@ void main() {
}); });
test('getTokenByCredentials - переход в состояние loading', () async { test('getTokenByCredentials - переход в состояние loading', () async {
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession);
authRepository.responseDelay = const Duration(milliseconds: 50); authRepository.responseDelay = const Duration(milliseconds: 50);
final future = authCubit!.getTokenByCredentials(credentialsFixture['email'] as String, credentialsFixture['password'] as String); final future = authCubit!.getTokenByCredentials(credentialsFixture['email'] as String, credentialsFixture['password'] as String);
@@ -88,7 +91,7 @@ void main() {
}); });
test('getTokenByCredentials - обработка ошибки авторизации', () async { test('getTokenByCredentials - обработка ошибки авторизации', () async {
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession);
authRepository.error = Exception('Неверный адрес электронной почты или пароль'); authRepository.error = Exception('Неверный адрес электронной почты или пароль');
final result = await authCubit!.getTokenByCredentials('wrong@example.com', 'wrongpassword'); final result = await authCubit!.getTokenByCredentials('wrong@example.com', 'wrongpassword');
@@ -102,7 +105,7 @@ void main() {
}); });
test('getTokenByCredentials - обработка различных типов ошибок', () async { test('getTokenByCredentials - обработка различных типов ошибок', () async {
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession);
authRepository.error = const FormatException('Invalid credentials format'); authRepository.error = const FormatException('Invalid credentials format');
final result = await authCubit!.getTokenByCredentials('invalid', 'pass'); final result = await authCubit!.getTokenByCredentials('invalid', 'pass');
@@ -115,7 +118,7 @@ void main() {
}); });
test('getTokenByCredentials - проверка вызова с правильными параметрами', () async { test('getTokenByCredentials - проверка вызова с правильными параметрами', () async {
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession);
await authCubit!.getTokenByCredentials('user@test.com', 'securePassword123'); await authCubit!.getTokenByCredentials('user@test.com', 'securePassword123');
@@ -124,7 +127,7 @@ void main() {
}); });
test('getTokenByCredentials - множественные вызовы', () async { test('getTokenByCredentials - множественные вызовы', () async {
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession);
await authCubit!.getTokenByCredentials('first@test.com', 'password1'); await authCubit!.getTokenByCredentials('first@test.com', 'password1');
await Future.delayed(const Duration(milliseconds: 100)); await Future.delayed(const Duration(milliseconds: 100));
@@ -138,7 +141,7 @@ void main() {
}); });
test('registerAnonymousPushToken - регистрирует токен если не был отправлен', () async { test('registerAnonymousPushToken - регистрирует токен если не был отправлен', () async {
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession);
when(mockPrefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent)).thenReturn(false); when(mockPrefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent)).thenReturn(false);
when(mockFirebaseApi.registerAnonymousPushToken()).thenAnswer((_) async => {}); when(mockFirebaseApi.registerAnonymousPushToken()).thenAnswer((_) async => {});
when(mockPrefs.setBool(PrefsKeys.hasAnonymousTokenBeenSent, true)).thenAnswer((_) async => {}); when(mockPrefs.setBool(PrefsKeys.hasAnonymousTokenBeenSent, true)).thenAnswer((_) async => {});
@@ -150,7 +153,7 @@ void main() {
}); });
test('registerAnonymousPushToken - не регистрирует токен если уже был отправлен', () async { test('registerAnonymousPushToken - не регистрирует токен если уже был отправлен', () async {
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession);
when(mockPrefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent)).thenReturn(true); when(mockPrefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent)).thenReturn(true);
await authCubit!.registerAnonymousPushToken(); await authCubit!.registerAnonymousPushToken();
@@ -159,7 +162,7 @@ void main() {
}); });
test('registerPushToken - регистрирует push токен', () async { test('registerPushToken - регистрирует push токен', () async {
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession);
when(mockFirebaseApi.registerPushToken()).thenAnswer((_) async => {}); when(mockFirebaseApi.registerPushToken()).thenAnswer((_) async => {});
await authCubit!.registerPushToken(); await authCubit!.registerPushToken();
@@ -168,7 +171,7 @@ void main() {
}); });
test('registerAnonymousPushToken - обрабатывает null значение hasAnonymousTokenBeenSent', () async { test('registerAnonymousPushToken - обрабатывает null значение hasAnonymousTokenBeenSent', () async {
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession);
when(mockPrefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent)).thenReturn(null); when(mockPrefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent)).thenReturn(null);
when(mockFirebaseApi.registerAnonymousPushToken()).thenAnswer((_) async => {}); when(mockFirebaseApi.registerAnonymousPushToken()).thenAnswer((_) async => {});
when(mockPrefs.setBool(PrefsKeys.hasAnonymousTokenBeenSent, true)).thenAnswer((_) async => {}); when(mockPrefs.setBool(PrefsKeys.hasAnonymousTokenBeenSent, true)).thenAnswer((_) async => {});

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';
@@ -13,9 +14,21 @@ import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart'; import 'package:mockito/mockito.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../../../../../fixtures/doubles/session_doubles.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 +37,38 @@ void main() {
OAuthCubit? oauthCubit; OAuthCubit? oauthCubit;
late MockAuthRepository mockAuthRepository; late MockAuthRepository mockAuthRepository;
late MockPrefs mockPrefs; late MockPrefs mockPrefs;
late _RecordingFirebaseApi firebaseApi;
late FakeSessionLifecycleService fakeSession;
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();
fakeSession = FakeSessionLifecycleService();
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 +77,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, sessionLifecycleService: fakeSession,
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, sessionLifecycleService: fakeSession,
prefs: mockPrefs,
);
when( when(
mockAuthRepository.signInViaSocialMedia( mockAuthRepository.signInViaSocialMedia(
@@ -92,7 +130,11 @@ void main() {
}); });
test('signInViaSocialMedia - переход в состояние loading', () async { test('signInViaSocialMedia - переход в состояние loading', () async {
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs); oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
prefs: mockPrefs,
);
when( when(
mockAuthRepository.signInViaSocialMedia( mockAuthRepository.signInViaSocialMedia(
@@ -119,7 +161,11 @@ void main() {
}); });
test('signInViaSocialMedia - обработка ошибки', () async { test('signInViaSocialMedia - обработка ошибки', () async {
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs); oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
prefs: mockPrefs,
);
when( when(
mockAuthRepository.signInViaSocialMedia( mockAuthRepository.signInViaSocialMedia(
@@ -144,7 +190,11 @@ void main() {
}); });
test('signUpViaSocialMedia - успешная регистрация через соцсеть', () async { test('signUpViaSocialMedia - успешная регистрация через соцсеть', () async {
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs); oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
prefs: mockPrefs,
);
when( when(
mockAuthRepository.signUpViaSocialMedia( mockAuthRepository.signUpViaSocialMedia(
@@ -164,7 +214,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 +239,61 @@ 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(fakeSession.onAuthenticatedCalls, 1);
}); });
test('signInViaSocialMedia - обработка ошибки валидации email/phone', () async { test(
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs); 'signInViaSocialMedia - обработка ошибки валидации email/phone',
() async {
oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
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, sessionLifecycleService: fakeSession,
prefs: mockPrefs,
);
final dioException = DioException( final dioException = DioException(
requestOptions: RequestOptions(path: '/api/auth/oauth/signup'), requestOptions: RequestOptions(path: '/api/auth/oauth/signup'),
@@ -282,82 +347,129 @@ 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, sessionLifecycleService: fakeSession,
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, sessionLifecycleService: fakeSession,
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, sessionLifecycleService: fakeSession,
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(fakeSession.onAuthenticatedCalls, 1);
},
);
test('confirmOAuthVerification - обработка ошибки', () async { test('confirmOAuthVerification - обработка ошибки', () async {
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs); oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
prefs: mockPrefs,
);
when( when(
mockAuthRepository.confirmOAuthVerification( mockAuthRepository.confirmOAuthVerification(

View File

@@ -15,6 +15,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../../../../fixtures/doubles/auth_doubles.dart'; import '../../../../fixtures/doubles/auth_doubles.dart';
import '../../../../fixtures/doubles/profile_doubles.dart'; import '../../../../fixtures/doubles/profile_doubles.dart';
import '../../../../fixtures/doubles/session_doubles.dart';
import '../../../../fixtures/fixture_reader.dart'; import '../../../../fixtures/fixture_reader.dart';
import 'basic_widget_test.mocks.dart'; import 'basic_widget_test.mocks.dart';
@@ -36,7 +37,7 @@ void main() {
mockFirebaseApi = MockFirebaseApi(); mockFirebaseApi = MockFirebaseApi();
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: FakeSessionLifecycleService());
}); });
tearDown(() { tearDown(() {
@@ -55,7 +56,7 @@ void main() {
testWidgets('should create AuthCubit successfully', (WidgetTester tester) async { testWidgets('should create AuthCubit successfully', (WidgetTester tester) async {
// Arrange & Act // Arrange & Act
final cubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); final cubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: FakeSessionLifecycleService());
// Assert // Assert
expect(cubit, isA<AuthCubit>()); expect(cubit, isA<AuthCubit>());

View File

@@ -0,0 +1,204 @@
import 'dart:async';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart';
import 'package:eatvio_app/features/profile/models/profile_model.dart';
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shared_preferences/shared_preferences.dart';
class MockProfileRepository extends Mock implements ProfileRepository {}
const _profileA = ProfileModel(
id: 1,
slug: 'user-a',
name: 'User A',
email: 'a@example.com',
phoneConfirmationState: 'confirmed',
isPublicProfile: true,
isBlockedByMe: false,
isBlockedByAuthor: false,
isBlockedByUser: false,
isEntitySubscribedByMe: false,
profilePhotoUrl: 'avatars/a.png',
roles: [],
badges: [],
subscriptions: [],
subscribers: [],
);
const _profileB = ProfileModel(
id: 2,
slug: 'user-b',
name: 'User B',
email: 'b@example.com',
phoneConfirmationState: 'confirmed',
isPublicProfile: true,
isBlockedByMe: false,
isBlockedByAuthor: false,
isBlockedByUser: false,
isEntitySubscribedByMe: false,
profilePhotoUrl: 'avatars/b.png',
roles: [],
badges: [],
subscriptions: [],
subscribers: [],
);
const _details = ProfileDetailsModel(
firstName: 'User',
lastName: 'Test',
birthdate: '1990-01-01',
gender: 'male',
country: 'Russia',
city: 'Moscow',
lang: 'ru',
weight: 70.0,
height: 175,
chestCircumference: 100,
waistCircumference: 80,
hipCircumference: 90,
bloodPressureUp: 120,
bloodPressureDown: 80,
hasHypo: false,
hasHyper: false,
aboutAlergy: '-',
aboutTakingPils: '-',
aboutSportHobbies: '-',
favoriteFoods: '-',
unfavoriteFoods: '-',
additionalInfo: '-',
isMoodFillable: true,
isBedtimeFillable: true,
activityCoefficient: 1.5,
);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late MockProfileRepository repository;
ProfileAvatarCubit? cubit;
setUp(() async {
await sl.reset();
SharedPreferences.setMockInitialValues({'token': 'token-a'});
TokenStorage.clearCache();
repository = MockProfileRepository();
});
tearDown(() async {
if (cubit != null && !cubit!.isClosed) {
await cubit!.close();
}
cubit = null;
await sl.reset();
TokenStorage.clearCache();
});
void stubSuccessfulProfile(ProfileModel profile) {
when(() => repository.getMyProfile()).thenAnswer((_) async => profile);
when(
() => repository.getMyProfileDetails(),
).thenAnswer((_) async => _details);
}
test('reset emits loading and clears the cached profile', () async {
stubSuccessfulProfile(_profileA);
cubit = ProfileAvatarCubit(profileRepository: repository);
await cubit!.ensureFreshForCurrentSession();
expect(cubit!.state, isA<ProfileAvatarStateLoaded>());
cubit!.reset();
expect(cubit!.state, isA<ProfileAvatarStateLoading>());
await cubit!.ensureFreshForCurrentSession();
verify(() => repository.getMyProfile()).called(2);
expect(cubit!.state, isA<ProfileAvatarStateLoaded>());
});
test('late getMyProfile completion after reset is ignored', () async {
final profileCompleter = Completer<ProfileModel>();
when(
() => repository.getMyProfile(),
).thenAnswer((_) => profileCompleter.future);
when(
() => repository.getMyProfileDetails(),
).thenAnswer((_) async => _details);
cubit = ProfileAvatarCubit(profileRepository: repository);
final pendingRequest = cubit!.getMyProfile();
await pumpEventQueue();
expect(cubit!.state, isA<ProfileAvatarStateLoading>());
cubit!.reset();
profileCompleter.complete(_profileA);
await pendingRequest;
await pumpEventQueue();
expect(cubit!.state, isA<ProfileAvatarStateLoading>());
verifyNever(() => repository.getMyProfileDetails());
});
test(
'ensureFreshForCurrentSession does not refetch for the same token',
() async {
stubSuccessfulProfile(_profileA);
cubit = ProfileAvatarCubit(profileRepository: repository);
await cubit!.ensureFreshForCurrentSession();
await cubit!.ensureFreshForCurrentSession();
verify(() => repository.getMyProfile()).called(1);
expect(cubit!.state, isA<ProfileAvatarStateLoaded>());
},
);
test(
'ensureFreshForCurrentSession refetches after the token changes',
() async {
final profiles = <ProfileModel>[_profileA, _profileB];
when(
() => repository.getMyProfile(),
).thenAnswer((_) async => profiles.removeAt(0));
when(
() => repository.getMyProfileDetails(),
).thenAnswer((_) async => _details);
cubit = ProfileAvatarCubit(profileRepository: repository);
await cubit!.ensureFreshForCurrentSession();
final firstState = cubit!.state as ProfileAvatarStateLoaded;
expect(firstState.profile.id, _profileA.id);
await TokenStorage.saveTokenInStorage('token-b');
await cubit!.ensureFreshForCurrentSession();
final secondState = cubit!.state as ProfileAvatarStateLoaded;
expect(secondState.profile.id, _profileB.id);
expect(secondState.profile.profilePhotoUrl, _profileB.profilePhotoUrl);
verify(() => repository.getMyProfile()).called(2);
},
);
test('errors emit ProfileAvatarStateError and do not navigate', () async {
when(
() => repository.getMyProfile(),
).thenAnswer((_) async => throw Exception('profile request failed'));
cubit = ProfileAvatarCubit(profileRepository: repository);
await cubit!.getMyProfile();
expect(cubit!.state, isA<ProfileAvatarStateError>());
expect(
(cubit!.state as ProfileAvatarStateError).errorMessage,
contains('profile request failed'),
);
});
}

View File

@@ -1,3 +1,4 @@
import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart';
import 'package:eatvio_app/features/profile/models/profile_model.dart'; import 'package:eatvio_app/features/profile/models/profile_model.dart';
@@ -5,6 +6,7 @@ import 'package:eatvio_app/features/profile/repositories/profile_repository.dart
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart'; import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart'; import 'package:mockito/mockito.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'profile_avatar_cubit_test.mocks.dart'; import 'profile_avatar_cubit_test.mocks.dart';
@@ -16,6 +18,8 @@ void main() {
late MockProfileRepository mockRepository; late MockProfileRepository mockRepository;
setUp(() { setUp(() {
SharedPreferences.setMockInitialValues({'token': 'test-token'});
TokenStorage.clearCache();
mockRepository = MockProfileRepository(); mockRepository = MockProfileRepository();
}); });

View File

@@ -4,6 +4,8 @@ import 'package:eatvio_app/features/profile/repositories/profile_repository.dart
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart'; import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart'; import 'package:mockito/mockito.dart';
import '../../../../fixtures/doubles/session_doubles.dart';
import 'menu_cubit_test.mocks.dart'; import 'menu_cubit_test.mocks.dart';
@GenerateNiceMocks([MockSpec<ProfileRepository>()]) @GenerateNiceMocks([MockSpec<ProfileRepository>()])
@@ -25,12 +27,12 @@ void main() {
}); });
test('начальное состояние - MenuState', () { test('начальное состояние - MenuState', () {
menuCubit = MenuCubit(mockProfileRepository); menuCubit = MenuCubit(mockProfileRepository, FakeSessionLifecycleService());
expect(menuCubit!.state, isA<MenuState>()); expect(menuCubit!.state, isA<MenuState>());
}); });
test('makePassKey - успешно создает passKey', () async { test('makePassKey - успешно создает passKey', () async {
menuCubit = MenuCubit(mockProfileRepository); menuCubit = MenuCubit(mockProfileRepository, FakeSessionLifecycleService());
const expectedPassKey = 'test-pass-key'; const expectedPassKey = 'test-pass-key';
when(mockProfileRepository.getPassKey( when(mockProfileRepository.getPassKey(
@@ -48,7 +50,7 @@ void main() {
}); });
test('makePassKey - возвращает null при ошибке', () async { test('makePassKey - возвращает null при ошибке', () async {
menuCubit = MenuCubit(mockProfileRepository); menuCubit = MenuCubit(mockProfileRepository, FakeSessionLifecycleService());
when(mockProfileRepository.getPassKey( when(mockProfileRepository.getPassKey(
routeName: 'product_nutritional_value.index', routeName: 'product_nutritional_value.index',

View File

@@ -15,6 +15,7 @@ import 'package:go_router/go_router.dart';
import 'package:mockito/annotations.dart'; import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart'; import 'package:mockito/mockito.dart';
import '../../../../fixtures/doubles/session_doubles.dart';
import 'menu_screen_smoke_test.mocks.dart'; import 'menu_screen_smoke_test.mocks.dart';
@GenerateNiceMocks([MockSpec<ProfileRepository>()]) @GenerateNiceMocks([MockSpec<ProfileRepository>()])
@@ -138,7 +139,7 @@ void main() {
testWidgets('renders menu screen for user', (tester) async { testWidgets('renders menu screen for user', (tester) async {
final repository = MockProfileRepository(); final repository = MockProfileRepository();
final cubit = MenuCubit(repository); final cubit = MenuCubit(repository, FakeSessionLifecycleService());
when(repository.getPassKey(routeName: anyNamed('routeName'), routeParams: anyNamed('routeParams'))).thenAnswer((_) async => 'test-passkey'); when(repository.getPassKey(routeName: anyNamed('routeName'), routeParams: anyNamed('routeParams'))).thenAnswer((_) async => 'test-passkey');
when(repository.getMyProfile()).thenAnswer((_) async => mockUserProfile); when(repository.getMyProfile()).thenAnswer((_) async => mockUserProfile);
@@ -155,7 +156,7 @@ void main() {
testWidgets('renders menu screen for consultant', (tester) async { testWidgets('renders menu screen for consultant', (tester) async {
final repository = MockProfileRepository(); final repository = MockProfileRepository();
final cubit = MenuCubit(repository); final cubit = MenuCubit(repository, FakeSessionLifecycleService());
when(repository.getPassKey(routeName: anyNamed('routeName'), routeParams: anyNamed('routeParams'))).thenAnswer((_) async => 'test-passkey'); when(repository.getPassKey(routeName: anyNamed('routeName'), routeParams: anyNamed('routeParams'))).thenAnswer((_) async => 'test-passkey');
when(repository.getMyProfile()).thenAnswer((_) async => mockConsultantProfile); when(repository.getMyProfile()).thenAnswer((_) async => mockConsultantProfile);

View File

@@ -9,6 +9,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart'; import 'package:mockito/annotations.dart';
import '../../fixtures/doubles/session_doubles.dart';
import 'basic_widget_test.mocks.dart'; import 'basic_widget_test.mocks.dart';
@GenerateMocks([RegistrationRepository, Prefs]) @GenerateMocks([RegistrationRepository, Prefs])
@@ -19,7 +20,7 @@ void main() {
setUp(() { setUp(() {
mockRegistrationRepository = MockRegistrationRepository(); mockRegistrationRepository = MockRegistrationRepository();
registrationCubit = RegistrationCubit(registrationRepository: mockRegistrationRepository, prefs: MockPrefs()); registrationCubit = RegistrationCubit(registrationRepository: mockRegistrationRepository, prefs: MockPrefs(), sessionLifecycleService: FakeSessionLifecycleService());
}); });
tearDown(() { tearDown(() {
@@ -38,7 +39,7 @@ void main() {
testWidgets('should create RegistrationCubit successfully', (WidgetTester tester) async { testWidgets('should create RegistrationCubit successfully', (WidgetTester tester) async {
// Arrange & Act // Arrange & Act
final cubit = RegistrationCubit(registrationRepository: mockRegistrationRepository, prefs: MockPrefs()); final cubit = RegistrationCubit(registrationRepository: mockRegistrationRepository, prefs: MockPrefs(), sessionLifecycleService: FakeSessionLifecycleService());
// Assert // Assert
expect(cubit, isA<RegistrationCubit>()); expect(cubit, isA<RegistrationCubit>());

View File

@@ -7,6 +7,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart'; import 'package:mockito/annotations.dart';
import '../../fixtures/doubles/session_doubles.dart';
import 'integration_widget_test.mocks.dart'; import 'integration_widget_test.mocks.dart';
@GenerateMocks([RegistrationRepository, Prefs]) @GenerateMocks([RegistrationRepository, Prefs])
@@ -17,7 +18,7 @@ void main() {
setUp(() { setUp(() {
mockRegistrationRepository = MockRegistrationRepository(); mockRegistrationRepository = MockRegistrationRepository();
registrationCubit = RegistrationCubit(registrationRepository: mockRegistrationRepository, prefs: MockPrefs()); registrationCubit = RegistrationCubit(registrationRepository: mockRegistrationRepository, prefs: MockPrefs(), sessionLifecycleService: FakeSessionLifecycleService());
}); });
tearDown(() { tearDown(() {

View File

@@ -7,6 +7,8 @@ import 'package:eatvio_app/features/registration/repositories/registration_repos
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
import '../../../../../fixtures/doubles/session_doubles.dart';
class MockRegistrationRepository extends Mock implements RegistrationRepository {} class MockRegistrationRepository extends Mock implements RegistrationRepository {}
class MockPrefs extends Mock implements Prefs {} class MockPrefs extends Mock implements Prefs {}
@@ -26,7 +28,7 @@ void main() {
setUp(() { setUp(() {
mockRepository = MockRegistrationRepository(); mockRepository = MockRegistrationRepository();
mockPrefs = MockPrefs(); mockPrefs = MockPrefs();
cubit = RegistrationCubit(registrationRepository: mockRepository, prefs: mockPrefs); cubit = RegistrationCubit(registrationRepository: mockRepository, prefs: mockPrefs, sessionLifecycleService: FakeSessionLifecycleService());
}); });
tearDown(() { tearDown(() {

View File

@@ -13,6 +13,7 @@ import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart'; import 'package:mockito/annotations.dart';
import '../../../../fixtures/doubles/session_doubles.dart';
import 'registration_screen_smoke_test.mocks.dart'; import 'registration_screen_smoke_test.mocks.dart';
@GenerateNiceMocks([MockSpec<RegistrationRepository>(), MockSpec<Prefs>()]) @GenerateNiceMocks([MockSpec<RegistrationRepository>(), MockSpec<Prefs>()])
@@ -44,7 +45,7 @@ void main() {
testWidgets('renders registration screen with initial step', (tester) async { testWidgets('renders registration screen with initial step', (tester) async {
final repository = MockRegistrationRepository(); final repository = MockRegistrationRepository();
final prefs = MockPrefs(); final prefs = MockPrefs();
final cubit = RegistrationCubit(registrationRepository: repository, prefs: prefs); final cubit = RegistrationCubit(registrationRepository: repository, prefs: prefs, sessionLifecycleService: FakeSessionLifecycleService());
sl.registerLazySingleton<RegistrationRepository>(() => repository); sl.registerLazySingleton<RegistrationRepository>(() => repository);
sl.registerLazySingleton<Prefs>(() => prefs); sl.registerLazySingleton<Prefs>(() => prefs);
@@ -59,7 +60,7 @@ void main() {
testWidgets('renders phone screen as first step', (tester) async { testWidgets('renders phone screen as first step', (tester) async {
final repository = MockRegistrationRepository(); final repository = MockRegistrationRepository();
final prefs = MockPrefs(); final prefs = MockPrefs();
final cubit = RegistrationCubit(registrationRepository: repository, prefs: prefs); final cubit = RegistrationCubit(registrationRepository: repository, prefs: prefs, sessionLifecycleService: FakeSessionLifecycleService());
sl.registerLazySingleton<RegistrationRepository>(() => repository); sl.registerLazySingleton<RegistrationRepository>(() => repository);
sl.registerLazySingleton<Prefs>(() => prefs); sl.registerLazySingleton<Prefs>(() => prefs);

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';
@@ -7,45 +9,66 @@ import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart'; import 'package:mockito/mockito.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../../../fixtures/doubles/session_doubles.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;
late FakeSessionLifecycleService fakeSession;
const MethodChannel passwordGeneratorChannel = MethodChannel(
'flutter_password_generator',
);
setUp(() { setUp(() {
mockRepository = MockRegistrationRepositoryV2(); mockRepository = MockRegistrationRepositoryV2();
cubit = RegistrationCubitV2(registrationRepository: mockRepository); firebaseApi = _RecordingFirebaseApi();
fakeSession = FakeSessionLifecycleService();
cubit = RegistrationCubitV2(
registrationRepository: mockRepository,
firebaseApi: firebaseApi,
sessionLifecycleService: fakeSession,
);
// Инициализируем 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 +205,23 @@ 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(fakeSession.onAuthenticatedCalls, 1);
verify(
mockRepository.register(
registrationModel: anyNamed('registrationModel'),
),
).called(1);
}); });
test('register - возвращает false при невалидных полях', () async { test('register - возвращает false при невалидных полях', () async {
@@ -199,7 +231,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 +262,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'),
);
}); });
}); });
} }

View File

@@ -1,3 +1,4 @@
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
import 'package:eatvio_app/features/consultations/repositories/consultations_repository.dart'; import 'package:eatvio_app/features/consultations/repositories/consultations_repository.dart';
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart'; import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
@@ -7,6 +8,7 @@ import 'package:eatvio_app/features/settings/repositories/service_repository.dar
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart'; import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart'; import 'package:mockito/mockito.dart';
import '../../../../fixtures/doubles/session_doubles.dart';
import '../../../../fixtures/settings_fixtures.dart'; import '../../../../fixtures/settings_fixtures.dart';
import 'settings_cubit_test.mocks.dart'; import 'settings_cubit_test.mocks.dart';
@@ -23,6 +25,7 @@ void main() {
late MockSettingsRepository mockSettingsRepository; late MockSettingsRepository mockSettingsRepository;
late MockConsultantRepository mockConsultantRepository; late MockConsultantRepository mockConsultantRepository;
late MockConsultationsRepository mockConsultationsRepository; late MockConsultationsRepository mockConsultationsRepository;
late FakeSessionLifecycleService fakeSession;
setUp(() { setUp(() {
@@ -30,6 +33,7 @@ void main() {
mockSettingsRepository = MockSettingsRepository(); mockSettingsRepository = MockSettingsRepository();
mockConsultantRepository = MockConsultantRepository(); mockConsultantRepository = MockConsultantRepository();
mockConsultationsRepository = MockConsultationsRepository(); mockConsultationsRepository = MockConsultationsRepository();
fakeSession = FakeSessionLifecycleService();
}); });
tearDown(() async { tearDown(() async {
@@ -46,6 +50,7 @@ void main() {
settingsRepository: mockSettingsRepository, settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository, consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository, consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: fakeSession,
); );
expect(settingsCubit!.state, const SettingsState.loading()); expect(settingsCubit!.state, const SettingsState.loading());
}); });
@@ -56,6 +61,7 @@ void main() {
settingsRepository: mockSettingsRepository, settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository, consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository, consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: fakeSession,
); );
when(mockProfileRepository.getMyProfile()) when(mockProfileRepository.getMyProfile())
@@ -86,6 +92,7 @@ void main() {
settingsRepository: mockSettingsRepository, settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository, consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository, consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: fakeSession,
); );
// Ошибка происходит при загрузке настроек профиля (внутри try-catch блока) // Ошибка происходит при загрузке настроек профиля (внутри try-catch блока)
@@ -114,6 +121,7 @@ void main() {
settingsRepository: mockSettingsRepository, settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository, consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository, consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: fakeSession,
); );
when(mockProfileRepository.getMyProfile()) when(mockProfileRepository.getMyProfile())
@@ -145,6 +153,7 @@ void main() {
settingsRepository: mockSettingsRepository, settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository, consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository, consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: fakeSession,
); );
when(mockProfileRepository.getMyProfile()) when(mockProfileRepository.getMyProfile())
@@ -176,6 +185,7 @@ void main() {
settingsRepository: mockSettingsRepository, settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository, consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository, consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: fakeSession,
); );
when(mockProfileRepository.deleteAccount()) when(mockProfileRepository.deleteAccount())
@@ -184,6 +194,8 @@ void main() {
final result = await settingsCubit!.deleteAccount(); final result = await settingsCubit!.deleteAccount();
expect(result, true); expect(result, true);
expect(fakeSession.endSessionCalls, 1);
expect(fakeSession.lastEndReason, SessionEndReason.accountDeleted);
verify(mockProfileRepository.deleteAccount()).called(1); verify(mockProfileRepository.deleteAccount()).called(1);
}); });
@@ -193,6 +205,7 @@ void main() {
settingsRepository: mockSettingsRepository, settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository, consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository, consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: fakeSession,
); );
when(mockProfileRepository.deleteAccount()) when(mockProfileRepository.deleteAccount())
@@ -209,6 +222,7 @@ void main() {
settingsRepository: mockSettingsRepository, settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository, consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository, consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: fakeSession,
); );
when(mockProfileRepository.getMyProfile()) when(mockProfileRepository.getMyProfile())
@@ -244,6 +258,7 @@ void main() {
settingsRepository: mockSettingsRepository, settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository, consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository, consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: fakeSession,
); );
when(mockProfileRepository.getMyProfile()) when(mockProfileRepository.getMyProfile())

View File

@@ -6,6 +6,7 @@ import 'package:eatvio_app/features/settings/presentation/bloc/settings_cubit/se
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import '../../../../fixtures/doubles/session_doubles.dart';
import '../../../../fixtures/settings_fixtures.dart'; import '../../../../fixtures/settings_fixtures.dart';
import '../bloc/settings_cubit_test.mocks.dart'; import '../bloc/settings_cubit_test.mocks.dart';
@@ -27,6 +28,7 @@ void main() {
settingsRepository: mockSettingsRepository, settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository, consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository, consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: FakeSessionLifecycleService(),
); );
}); });

View File

@@ -18,6 +18,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart'; import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart'; import 'package:mockito/mockito.dart';
import '../../../../fixtures/doubles/session_doubles.dart';
import 'settings_screen_smoke_test.mocks.dart'; import 'settings_screen_smoke_test.mocks.dart';
@GenerateNiceMocks([ @GenerateNiceMocks([
@@ -57,6 +58,7 @@ void main() {
settingsRepository: sl(), settingsRepository: sl(),
consultantRepository: sl(), consultantRepository: sl(),
consultationsRepository: sl(), consultationsRepository: sl(),
sessionLifecycleService: FakeSessionLifecycleService(),
), ),
); );
}); });

View File

@@ -0,0 +1,24 @@
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
class FakeSessionLifecycleService implements SessionLifecycleService {
int onAuthenticatedCalls = 0;
int endSessionCalls = 0;
int logoutCalls = 0;
SessionEndReason? lastEndReason;
@override
Future<void> onAuthenticated() async {
onAuthenticatedCalls++;
}
@override
Future<void> endSession({required SessionEndReason reason}) async {
endSessionCalls++;
lastEndReason = reason;
}
@override
Future<void> logout() async {
logoutCalls++;
}
}