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

View File

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

View File

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

View File

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

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/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/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/sub_cubit/sub_cubit.dart';
import 'package:eatvio_app/features/recipe/presentation/cubit/about_recipe_cubit.dart';
@@ -70,69 +71,174 @@ class CubitRegistrations {
/// Регистрация Cubit'ов как синглтонов
static void _registerSingletonCubits() {
sl.registerLazySingleton<NewMessagesCubit>(() => NewMessagesCubit());
sl.registerLazySingleton<NotificationsCubit>(() => NotificationsCubit(wsManager: sl(), notificationsRepository: sl()));
sl.registerLazySingleton<ProfileAvatarCubit>(() => ProfileAvatarCubit(profileRepository: sl()));
sl.registerLazySingleton<ChatsCubit>(() => ChatsCubit(chatRepository: sl(), wsManager: sl(), profileRepository: sl()));
sl.registerLazySingleton<InternetConnectionCubit>(() => InternetConnectionCubit());
sl.registerLazySingleton<VideoCallManager>(
() => VideoCallManager(
videoCallRepository: sl(),
sl.registerLazySingleton<NotificationsCubit>(
() => NotificationsCubit(wsManager: sl(), notificationsRepository: sl()),
);
sl.registerLazySingleton<ProfileAvatarCubit>(
() => ProfileAvatarCubit(profileRepository: sl()),
);
sl.registerLazySingleton<ChatsCubit>(
() => ChatsCubit(
chatRepository: sl(),
wsManager: sl(),
profileRepository: sl(),
),
);
sl.registerLazySingleton<InternetConnectionCubit>(
() => InternetConnectionCubit(),
);
sl.registerLazySingleton<VideoCallManager>(
() =>
VideoCallManager(videoCallRepository: sl(), profileRepository: sl()),
);
sl.registerLazySingleton<VideoCallSessionCubit>(
() => VideoCallSessionCubit(
videoCallManager: sl(),
systemPipCoordinator: sl(),
),
);
sl.registerLazySingleton<FeedCubit>(() => FeedCubit(feedRepository: sl()));
}
/// Регистрация Cubit'ов как фабрик
static void _registerFactoryCubits() {
// Auth related cubits
sl.registerFactory<AuthCubit>(() => AuthCubit(authRepository: sl(), firebaseApi: sl(), prefs: sl(), profileRepository: sl()));
sl.registerFactory<RegistrationCubit>(() => RegistrationCubit(registrationRepository: sl(), prefs: sl()));
sl.registerFactory<ResetPasswordCubit>(() => ResetPasswordCubit(resetPasswordRepository: sl()));
sl.registerFactory<VerifyEmailCubit>(() => VerifyEmailCubit(profileRepository: sl(), prefs: sl()));
sl.registerFactory<OAuthCubit>(() => OAuthCubit(authRepository: sl(), prefs: sl()));
sl.registerFactory<RegistrationCubitV2>(() => RegistrationCubitV2(registrationRepository: sl()));
sl.registerFactory<AuthCubit>(
() => AuthCubit(
authRepository: sl(),
firebaseApi: sl(),
prefs: 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
sl.registerFactory<FeedCubit>(() => FeedCubit(feedRepository: sl()));
sl.registerFactory<RecipesCubit>(() => RecipesCubit(feedRepository: sl(), recipeRepository: sl(), aboutRecipeRepository: 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()));
sl.registerFactory<RecipesCubit>(
() => RecipesCubit(
feedRepository: sl(),
recipeRepository: sl(),
aboutRecipeRepository: 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
sl.registerFactory<ChatRoomCubit>(() => ChatRoomCubit(cardsRepository: sl(), chatRepository: sl(), wsManager: sl()));
sl.registerFactory<ChatRoomCubit>(
() => ChatRoomCubit(
cardsRepository: sl(),
chatRepository: sl(),
wsManager: sl(),
),
);
// Profile related cubits
sl.registerFactory<ProfileCubit>(() => ProfileCubit(profileRepository: sl(), chatRepository: sl(), consultantRepository: sl()));
sl.registerFactory<profile_plans.PlansCubit>(() => profile_plans.PlansCubit(plansRepository: sl()));
sl.registerFactory<ProfileCubit>(
() => ProfileCubit(
profileRepository: sl(),
chatRepository: sl(),
consultantRepository: sl(),
),
);
sl.registerFactory<profile_plans.PlansCubit>(
() => profile_plans.PlansCubit(plansRepository: sl()),
);
sl.registerFactory<SubCubit>(() => SubCubit(profileRepository: sl()));
// Content creation cubits
sl.registerFactory<CreateDietCubit>(() => CreateDietCubit(profileRepository: 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()));
sl.registerFactory<CreateDietCubit>(
() => CreateDietCubit(
profileRepository: 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
sl.registerFactory<AboutDietCubit>(() => AboutDietCubit(dietsRepository: sl(), profileRepository: sl()));
sl.registerFactory<AboutPlanCubit>(() => AboutPlanCubit(planRepository: sl(), profileRepository: sl(), dietRepository: sl()));
sl.registerFactory<AboutRecipeCubit>(() => AboutRecipeCubit(recipeRepository: sl(), profileRepository: sl()));
sl.registerFactory<AboutDietCubit>(
() => AboutDietCubit(dietsRepository: 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()));
// User content cubits
sl.registerFactory<MyDietsCubit>(() => MyDietsCubit(myDietsRepository: sl(), createDietRepository: sl()));
sl.registerFactory<MyPlansCubit>(() => MyPlansCubit(myPlansRepository: sl()));
sl.registerFactory<MyRecipesCubit>(() => MyRecipesCubit(myRecipesRepository: sl(), aboutRecipeRepository: sl(), recipeRepository: sl()));
sl.registerFactory<MyDietsCubit>(
() => MyDietsCubit(myDietsRepository: sl(), createDietRepository: sl()),
);
sl.registerFactory<MyPlansCubit>(
() => MyPlansCubit(myPlansRepository: sl()),
);
sl.registerFactory<MyRecipesCubit>(
() => MyRecipesCubit(
myRecipesRepository: sl(),
aboutRecipeRepository: sl(),
recipeRepository: sl(),
),
);
// Other feature cubits
sl.registerFactory<CalendarCubit>(() => CalendarCubit(calendarRepository: sl()));
sl.registerFactory<CalendarCubit>(
() => CalendarCubit(calendarRepository: sl()),
);
sl.registerFactory<DiaryCubit>(
() => DiaryCubit(
chatRepository: sl(),
@@ -144,28 +250,74 @@ class CubitRegistrations {
),
);
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>(
() => 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<UserDetailsCubit>(() => UserDetailsCubit(profileRepository: sl()));
sl.registerFactory<MenuCubit>(() => MenuCubit(sl()));
sl.registerFactory<UserDetailsCubit>(
() => UserDetailsCubit(profileRepository: sl()),
);
sl.registerFactory<MenuCubit>(() => MenuCubit(sl(), 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:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:go_router/go_router.dart';
class TokenInterceptor extends Interceptor {
@override
@@ -14,6 +12,7 @@ class TokenInterceptor extends Interceptor {
final token = await TokenStorage.getTokenFromStorage();
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
options.extra['authToken'] = token;
}
super.onRequest(options, handler);
@@ -25,9 +24,18 @@ class TokenInterceptor extends Interceptor {
ErrorInterceptorHandler handler,
) async {
if (err.response?.statusCode == 401) {
await sl<WsManager>().clear();
await TokenStorage.removeTokenInStorage();
AppRouter.navigatorKey.currentContext?.go('/auth-screen');
final requestCarriedAuth = err.requestOptions.headers.containsKey(
'Authorization',
);
final requestToken = err.requestOptions.extra['authToken'];
final currentToken = await TokenStorage.getTokenFromStorage();
if (requestCarriedAuth &&
requestToken != null &&
requestToken == currentToken) {
await sl<SessionLifecycleService>().endSession(
reason: SessionEndReason.unauthorized,
);
}
}
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/repository_registrations.dart';
import 'package:eatvio_app/core/di/service_registrations.dart';
import 'package:eatvio_app/core/di/session_registrations.dart';
import 'package:eatvio_app/core/prefs.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:get_it/get_it.dart';
@@ -38,4 +39,6 @@ Future<void> init({required AppBuildType buildType}) async {
sl.registerLazySingleton<WsManager>(
() => 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/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/widgets/bottom_nav_scope.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart';
import 'package:eatvio_app/features/chat/presentation/pages/chats_screen.dart';
@@ -24,14 +23,10 @@ class MainNavigationRoutes {
return [
ShellRoute(
builder: (context, state, child) {
// Инициализация ProfileAvatarCubit при первом использовании ShellRoute
final profileAvatarCubit = context.read<ProfileAvatarCubit>();
if (profileAvatarCubit.state is ProfileAvatarStateLoading) {
// Вызываем getMyProfile() только если состояние loading (первый запуск)
WidgetsBinding.instance.addPostFrameCallback((_) {
profileAvatarCubit.getMyProfile();
});
}
WidgetsBinding.instance.addPostFrameCallback((_) {
profileAvatarCubit.ensureFreshForCurrentSession();
});
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/prefs.dart';
import 'package:eatvio_app/core/prefs_keys.dart';
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/features/auth/presentation/bloc/auth_cubit/auth_state.dart';
@@ -17,15 +18,26 @@ class AuthCubit extends Cubit<AuthState> {
final ProfileRepository profileRepository;
final FirebaseApi firebaseApi;
final Prefs prefs;
final SessionLifecycleService sessionLifecycleService;
AuthCubit({required this.authRepository, required this.prefs, required this.profileRepository, required this.firebaseApi}) : super(const AuthState());
AuthCubit({
required this.authRepository,
required this.prefs,
required this.profileRepository,
required this.firebaseApi,
required this.sessionLifecycleService,
}) : super(const AuthState());
Future<bool> getTokenByCredentials(String email, String password) async {
emit(const AuthState.loading());
try {
final token = (await authRepository.getTokenByCredentials(email, password)).token;
final token = (await authRepository.getTokenByCredentials(
email,
password,
)).token;
emit(AuthState.loaded(token: token));
await TokenStorage.saveTokenInStorage(token);
await sessionLifecycleService.onAuthenticated();
return true;
} catch (e, st) {
await reportFrom(e, st, this);
@@ -48,7 +60,8 @@ class AuthCubit extends Cubit<AuthState> {
Future<void> registerAnonymousPushToken() async {
try {
final hasAnonymousTokenBeenSent = prefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent) ?? false;
final hasAnonymousTokenBeenSent =
prefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent) ?? false;
if (!hasAnonymousTokenBeenSent) {
await firebaseApi.registerAnonymousPushToken();
prefs.setBool(PrefsKeys.hasAnonymousTokenBeenSent, true);

View File

@@ -1,7 +1,9 @@
import 'dart:developer';
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/firebase_api.dart';
import 'package:eatvio_app/core/mixins/registration_date_cache_mixin.dart';
import 'package:eatvio_app/core/prefs.dart';
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/features/auth/models/oauth_verification_request_response.dart';
import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_state.dart';
@@ -11,12 +13,24 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart';
class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
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;
}
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 {
emit(const OAuthState.loading());
@@ -28,7 +42,8 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
);
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));
} on DioException catch (e, st) {
await reportFrom(e, st, this);
@@ -45,7 +60,8 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
if (errorStr.contains('Некорректный формат email')) {
errorsList.add(RegistrationValidationErrors.incorrectEmailFormat);
}
if (errorStr.contains('почта уже существует') || errorStr.contains('Эл. почта уже существует')) {
if (errorStr.contains('почта уже существует') ||
errorStr.contains('Эл. почта уже существует')) {
errorsList.add(RegistrationValidationErrors.emailExist);
}
}
@@ -53,14 +69,20 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
if (phoneErrors != null && phoneErrors.isNotEmpty) {
for (final error in phoneErrors) {
final errorStr = error.toString();
if (errorStr.contains('phone уже существует') || errorStr.contains('телефон уже существует')) {
if (errorStr.contains('phone уже существует') ||
errorStr.contains('телефон уже существует')) {
errorsList.add(RegistrationValidationErrors.phoneExist);
}
}
}
if (errorsList.isNotEmpty) {
emit(OAuthState.error(errorMessage: 'validation_error', registrationValidationErrors: errorsList));
emit(
OAuthState.error(
errorMessage: 'validation_error',
registrationValidationErrors: errorsList,
),
);
} else {
emit(OAuthState.error(errorMessage: e.toString()));
}
@@ -103,7 +125,8 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
height: height,
clientId: clientId,
);
TokenStorage.saveTokenInStorage(token);
await TokenStorage.saveTokenInStorage(token);
await sessionLifecycleService.onAuthenticated();
cacheRegistrationDate(DateTime.now());
emit(OAuthState.loaded(token: token));
} on DioException catch (e, st) {
@@ -120,7 +143,8 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
if (errorStr.contains('Некорректный формат email')) {
errorsList.add(RegistrationValidationErrors.incorrectEmailFormat);
}
if (errorStr.contains('почта уже существует') || errorStr.contains('Эл. почта уже существует')) {
if (errorStr.contains('почта уже существует') ||
errorStr.contains('Эл. почта уже существует')) {
errorsList.add(RegistrationValidationErrors.emailExist);
}
}
@@ -128,14 +152,17 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
if (phoneErrors != null && phoneErrors.isNotEmpty) {
for (final error in phoneErrors) {
final errorStr = error.toString();
if (errorStr.contains('phone уже существует') || errorStr.contains('телефон уже существует')) {
if (errorStr.contains('phone уже существует') ||
errorStr.contains('телефон уже существует')) {
errorsList.add(RegistrationValidationErrors.phoneExist);
}
}
}
if (dateOfBirth != null && dateOfBirth.isNotEmpty) {
if (dateOfBirth.contains('Некорректный формат даты рождения')) {
errorsList.add(RegistrationValidationErrors.incorrectDateOfBirthFormat);
errorsList.add(
RegistrationValidationErrors.incorrectDateOfBirthFormat,
);
}
}
if (errorsList.isNotEmpty) {
@@ -146,13 +173,23 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
return null;
}
Future<OAuthVerificationRequestResponse?> requestOAuthVerification({String? email, String? phone}) async {
Future<OAuthVerificationRequestResponse?> requestOAuthVerification({
String? email,
String? phone,
}) async {
try {
emit(const OAuthState.loading());
log('OAuthCubit: Requesting verification code for email: $email, phone: $phone');
final response = await authRepository.requestOAuthVerification(email: email, phone: phone);
log(
'OAuthCubit: Requesting verification code for email: $email, phone: $phone',
);
final response = await authRepository.requestOAuthVerification(
email: email,
phone: phone,
);
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());
@@ -184,7 +221,9 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
}) async {
try {
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(
provider: provider,
accessToken: accessToken,
@@ -202,8 +241,11 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
weight: weight,
height: height,
);
log('OAuthCubit: OAuth verification confirmed successfully, token received');
TokenStorage.saveTokenInStorage(response.token);
log(
'OAuthCubit: OAuth verification confirmed successfully, token received',
);
await TokenStorage.saveTokenInStorage(response.token);
await sessionLifecycleService.onAuthenticated();
emit(OAuthState.loaded(token: response.token));
} catch (e, st) {
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_router.dart';
import 'package:eatvio_app/core/const.dart';
import 'package:eatvio_app/core/firebase_api.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/utils/image_constant.dart';
import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/core/widgets/custom_divider.dart';
import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
import 'package:eatvio_app/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart';
@@ -18,8 +16,6 @@ import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_cub
import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_state.dart';
import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart';
import 'package:eatvio_app/core/di/cubit_registrations.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/cupertino.dart';
@@ -68,23 +64,8 @@ class _AuthScreenState extends State<AuthScreen> {
super.dispose();
}
Future<void> _reconnectWebSocket(BuildContext context) async {
try {
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;
}
}
Future<void> _reconnectWebSocket() async {
showSuccessToast('Успешный вход');
AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
}
@@ -202,18 +183,7 @@ class _AuthScreenState extends State<AuthScreen> {
}
Future<void> _reconnectAfterAuth() async {
final context = AppRouter.navigatorKey.currentContext;
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());
}
showSuccessToast('Успешный вход');
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);
if (isAuthenticated) {
if (context.mounted) {
await _reconnectWebSocket(context);
await _reconnectWebSocket();
}
}
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/core/app_router.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart';
import 'package:eatvio_app/core/firebase_api.dart';
import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/core/utils/validation_utils.dart';
import 'package:flutter/gestures.dart';
@@ -62,23 +58,8 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
_initializeFieldsFromAuthResult();
}
Future<void> _reconnectWebSocket(BuildContext context) async {
try {
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;
}
}
Future<void> _reconnectWebSocket() async {
showSuccessToast('Успешная регистрация');
AppRouter.appRouter.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
}
@@ -451,7 +432,7 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
Builder(
builder: (context) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_reconnectWebSocket(context);
_reconnectWebSocket();
});
return const SizedBox.shrink();
},
@@ -795,7 +776,7 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
// Перезагружаем веб-сокет и переходим на главный экран
if (widgetContext.mounted) {
_reconnectWebSocket(widgetContext);
_reconnectWebSocket();
}
} catch (e, 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:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/firebase_api.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:qr_code_scanner_plus/qr_code_scanner_plus.dart';
@@ -39,7 +36,10 @@ class _ScanQRCodeScreenState extends State<ScanQRCodeScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Сканирование QR-кода'), centerTitle: true),
appBar: AppBar(
title: const Text('Сканирование QR-кода'),
centerTitle: true,
),
body: Column(
children: <Widget>[
Expanded(
@@ -60,30 +60,33 @@ class _ScanQRCodeScreenState extends State<ScanQRCodeScreen> {
try {
final scannedUrl = scanData.code;
final uri = Uri.parse(scannedUrl ?? '');
final scannedBaseUrl = (uri.hasAuthority) ? '${uri.scheme}://${uri.host}' : '';
final scannedBaseUrl = (uri.hasAuthority)
? '${uri.scheme}://${uri.host}'
: '';
sl<AppConfig>().setBaseUrl(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'];
await TokenStorage.saveTokenInStorage(token);
try {
await sl<WsManager>().clear();
await sl<WsManager>().initialize();
} catch (e) {
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});
await sl<SessionLifecycleService>().onAuthenticated();
AppRouter.navigatorKey.currentContext?.go(
'/feed-screen',
extra: {'needToInitializeWebSocket': false},
);
showSuccessToast('Добро пожаловать!');
} catch (e) {
await controller.resumeCamera();
showErrorToast('Что-то пошло не так. Попробуйте обновить страницу с QR-кодом или войдите с помощью логина и пароля.');
showErrorToast(
'Что-то пошло не так. Попробуйте обновить страницу с QR-кодом или войдите с помощью логина и пароля.',
);
}
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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/firebase_api.dart';
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/core/utils/image_constant.dart';
import 'package:eatvio_app/core/utils/date_time_utils.dart';
import 'package:eatvio_app/core/widgets/data_error_widget.dart';
import 'package:eatvio_app/core/widgets/loader.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/features/consultant_rating/presentation/consultant_rating_screen.dart';
import 'package:eatvio_app/features/finances_and_consultations_archive/presentation/consultant_finances_screen.dart';
import 'package:eatvio_app/features/finances_and_consultations_archive/presentation/user_purchases_screen.dart';
import 'package:eatvio_app/features/menu/presentation/bloc/menu_cubit/menu_cubit.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
import 'package:eatvio_app/features/menu/presentation/bloc/menu_cubit/menu_state.dart';
import 'package:eatvio_app/features/my_diets/presentation/my_diets_screen.dart';
import 'package:eatvio_app/features/my_plans/presentation/my_plans_screen.dart';
@@ -155,6 +148,7 @@ class MenuScreen extends StatelessWidget {
Future<Null> Function() _onTapLogout(BuildContext context) {
return () async {
final menuCubit = context.read<MenuCubit>();
showDialog(
context: context,
builder: (context) => CupertinoAlertDialog(
@@ -162,25 +156,8 @@ class MenuScreen extends StatelessWidget {
actions: [
CupertinoDialogAction(
onPressed: () async {
try {
// Logout revokes the Sanctum token. Unregister first, or
// FCM / VoIP tokens stay on the server and calls still ring.
await sl<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);
}
}
Navigator.of(context).pop();
await menuCubit.logout();
},
isDestructiveAction: true,
child: const Text("Выйти"),

View File

@@ -16,6 +16,11 @@ class NotificationsCubit extends Cubit<NotificationsState> {
StreamSubscription? _newNotificationsSubscription;
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 {
try {
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.
static ProfileCubit? myProfileCubit;
static void clearMyProfileReference() {
myProfileCubit = null;
}
ProfileCubit({required this.profileRepository, required this.chatRepository, required this.consultantRepository}) : super(const ProfileState.loading());
Future<void> getProfileBySlug(String slug) async {

View File

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

View File

@@ -1,6 +1,8 @@
import 'dart:developer';
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/firebase_api.dart';
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/core/utils/date_time_utils.dart';
import 'package:eatvio_app/core/utils/validation_utils.dart';
@@ -13,7 +15,13 @@ import 'package:gpassword/gpassword.dart';
class RegistrationCubitV2 extends Cubit<RegistrationStateV2> {
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) {
emit(state.copyWith(firstName: firstName));
@@ -194,9 +202,12 @@ class RegistrationCubitV2 extends Cubit<RegistrationStateV2> {
bool didApiComplainOnValidation = false;
String errorMessage = '';
try {
final token = await registrationRepository.register(registrationModel: registrationModel);
final token = await registrationRepository.register(
registrationModel: registrationModel,
);
if (token != null) {
TokenStorage.saveTokenInStorage(token);
await TokenStorage.saveTokenInStorage(token);
await sessionLifecycleService.onAuthenticated();
emit(state.copyWith(registrationError: null));
return (true, false);
}
@@ -214,11 +225,21 @@ class RegistrationCubitV2 extends Cubit<RegistrationStateV2> {
for (var error in emailErrors) {
allErrors.add(error.toString());
if (error.toString() == 'Некорректный формат email') {
emit(state.copyWith(isEmailValid: false, emailDisplayError: 'Некорректный формат email'));
emit(
state.copyWith(
isEmailValid: false,
emailDisplayError: 'Некорректный формат email',
),
);
didApiComplainOnValidation = true;
}
if (error.toString() == 'Такой Эл. почта уже существует.') {
emit(state.copyWith(isEmailValid: false, emailDisplayError: 'Такая Эл. почта уже существует.'));
emit(
state.copyWith(
isEmailValid: false,
emailDisplayError: 'Такая Эл. почта уже существует.',
),
);
didApiComplainOnValidation = true;
}
}
@@ -227,7 +248,12 @@ class RegistrationCubitV2 extends Cubit<RegistrationStateV2> {
for (var error in phoneErrors) {
allErrors.add(error.toString());
if (error.toString() == 'Такой phone уже существует.') {
emit(state.copyWith(isPhoneValid: false, phoneDisplayError: 'Такой номер телефона уже существует.'));
emit(
state.copyWith(
isPhoneValid: false,
phoneDisplayError: 'Такой номер телефона уже существует.',
),
);
didApiComplainOnValidation = true;
}
}
@@ -236,7 +262,12 @@ class RegistrationCubitV2 extends Cubit<RegistrationStateV2> {
for (var error in dateOfBirth) {
allErrors.add(error.toString());
if (error.toString() == 'Некорректный формат даты рождения') {
emit(state.copyWith(isBirthdayValid: false, dateOfBirthDisplayError: 'Некорректный формат даты рождения'));
emit(
state.copyWith(
isBirthdayValid: false,
dateOfBirthDisplayError: 'Некорректный формат даты рождения',
),
);
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/repositories/consultant_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 ConsultantRepository consultantRepository;
final ConsultationsRepository consultationsRepository;
final SessionLifecycleService sessionLifecycleService;
SettingsCubit({
required this.profileRepository,
required this.settingsRepository,
required this.profileRepository,
required this.settingsRepository,
required this.consultantRepository,
required this.consultationsRepository,
required this.sessionLifecycleService,
}) : super(const SettingsState.loading());
Future<void> init() async {
@@ -30,10 +34,12 @@ class SettingsCubit extends Cubit<SettingsState> {
late final ConsultationRequestModel? consultantInfo;
late final bool hasActiveConsultation;
late final OAuthAccountsDataModel? oauthAccounts;
try {
if (profile.phoneConfirmationState.contains('confirmed')) {
consultantInfo = await consultantRepository.getMyRequestStatus(profile.slug);
consultantInfo = await consultantRepository.getMyRequestStatus(
profile.slug,
);
} else {
consultantInfo = null;
}
@@ -41,14 +47,14 @@ class SettingsCubit extends Cubit<SettingsState> {
await reportFrom(e, st, this);
consultantInfo = null;
}
try {
hasActiveConsultation = await _checkActiveConsultation();
} catch (e, st) {
await reportFrom(e, st, this);
hasActiveConsultation = false;
}
try {
final oauthResponse = await settingsRepository.getOAuthAccounts();
oauthAccounts = oauthResponse.data;
@@ -56,7 +62,7 @@ class SettingsCubit extends Cubit<SettingsState> {
await reportFrom(e, st, this);
oauthAccounts = null;
}
try {
emit(
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 {
await settingsRepository.updateProfileDetails(profileDetailsModel: profileDetailsModel);
await settingsRepository.updateProfileDetails(
profileDetailsModel: profileDetailsModel,
);
} catch (e, st) {
await reportFrom(e, st, this);
} finally {
@@ -84,9 +94,13 @@ class SettingsCubit extends Cubit<SettingsState> {
}
}
Future<void> updateProfileSettings({required ProfileSettingsModel profileSettingsModel}) async {
Future<void> updateProfileSettings({
required ProfileSettingsModel profileSettingsModel,
}) async {
try {
await settingsRepository.updateProfileSettings(profileSettingsModel: profileSettingsModel);
await settingsRepository.updateProfileSettings(
profileSettingsModel: profileSettingsModel,
);
} catch (e, st) {
await reportFrom(e, st, this);
} finally {
@@ -97,9 +111,15 @@ class SettingsCubit extends Cubit<SettingsState> {
Future<bool> deleteAccount() async {
try {
await profileRepository.deleteAccount();
await sessionLifecycleService.endSession(
reason: SessionEndReason.accountDeleted,
);
return true;
} catch (e, st) {
await reportFrom(e, st, this);
if (e is DioException && e.response?.statusCode == 401) {
return true;
}
return false;
}
}
@@ -135,11 +155,14 @@ class SettingsCubit extends Cubit<SettingsState> {
Future<bool> _checkActiveConsultation() async {
try {
final userPurchases = await consultationsRepository.getUserPurchases(null, null);
final userPurchases = await consultationsRepository.getUserPurchases(
null,
null,
);
final activeStates = ChosenPlanStateEnum.getActiveStates();
return userPurchases.any((purchase) =>
activeStates.contains(purchase.chosenPlanState)
return userPurchases.any(
(purchase) => activeStates.contains(purchase.chosenPlanState),
);
} catch (e, st) {
await reportFrom(e, st, this);

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -1,4 +1,5 @@
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/token_storage.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:shared_preferences/shared_preferences.dart';
import '../../../../../fixtures/doubles/session_doubles.dart';
import '../../../../../fixtures/fixture_reader.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>()])
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -24,26 +37,38 @@ void main() {
OAuthCubit? oauthCubit;
late MockAuthRepository mockAuthRepository;
late MockPrefs mockPrefs;
late _RecordingFirebaseApi firebaseApi;
late FakeSessionLifecycleService fakeSession;
late TokenModel tokenFixture;
late Map<String, dynamic> signInRequestFixture;
late Map<String, dynamic> signUpRequestFixture;
setUp(() async {
tokenFixture = TokenModel.fromJson(FixtureReader.readAsMap('auth/token_response.json'));
signInRequestFixture = FixtureReader.readAsMap('auth/oauth_signin_request.json');
signUpRequestFixture = FixtureReader.readAsMap('auth/oauth_signup_request.json');
tokenFixture = TokenModel.fromJson(
FixtureReader.readAsMap('auth/token_response.json'),
);
signInRequestFixture = FixtureReader.readAsMap(
'auth/oauth_signin_request.json',
);
signUpRequestFixture = FixtureReader.readAsMap(
'auth/oauth_signup_request.json',
);
mockAuthRepository = MockAuthRepository();
mockPrefs = MockPrefs();
firebaseApi = _RecordingFirebaseApi();
fakeSession = FakeSessionLifecycleService();
SharedPreferences.setMockInitialValues({});
// Mock Fluttertoast plugin
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(const MethodChannel('PonnamKarthik/fluttertoast'), (
MethodCall methodCall,
) async {
return null;
});
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('PonnamKarthik/fluttertoast'),
(MethodCall methodCall) async {
return null;
},
);
});
tearDown(() async {
@@ -52,18 +77,31 @@ void main() {
oauthCubit!.close();
oauthCubit = null;
}
TokenStorage.clearCache();
// Clear mock handlers
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(const MethodChannel('PonnamKarthik/fluttertoast'), null);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('PonnamKarthik/fluttertoast'),
null,
);
});
test('начальное состояние - OAuthState', () {
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs);
oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
prefs: mockPrefs,
);
expect(oauthCubit!.state, const OAuthState());
});
test('signInViaSocialMedia - успешный вход через соцсеть', () async {
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs);
oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
prefs: mockPrefs,
);
when(
mockAuthRepository.signInViaSocialMedia(
@@ -92,7 +130,11 @@ void main() {
});
test('signInViaSocialMedia - переход в состояние loading', () async {
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs);
oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
prefs: mockPrefs,
);
when(
mockAuthRepository.signInViaSocialMedia(
@@ -119,7 +161,11 @@ void main() {
});
test('signInViaSocialMedia - обработка ошибки', () async {
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs);
oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
prefs: mockPrefs,
);
when(
mockAuthRepository.signInViaSocialMedia(
@@ -144,7 +190,11 @@ void main() {
});
test('signUpViaSocialMedia - успешная регистрация через соцсеть', () async {
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs);
oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
prefs: mockPrefs,
);
when(
mockAuthRepository.signUpViaSocialMedia(
@@ -164,7 +214,9 @@ void main() {
),
).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(
provider: signUpRequestFixture['provider'] as String,
@@ -187,48 +239,61 @@ void main() {
expect(oauthCubit!.state, isA<OAuthStateLoaded>());
final loadedState = oauthCubit!.state as OAuthStateLoaded;
expect(loadedState.token, tokenFixture.token);
expect(await TokenStorage.getTokenFromStorage(), tokenFixture.token);
expect(fakeSession.onAuthenticatedCalls, 1);
});
test('signInViaSocialMedia - обработка ошибки валидации email/phone', () async {
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs);
test(
'signInViaSocialMedia - обработка ошибки валидации email/phone',
() async {
oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
prefs: mockPrefs,
);
final dioException = DioException(
requestOptions: RequestOptions(path: '/api/auth/oauth/signin'),
response: Response(
statusCode: 422,
data: {
'errors': {
'email': ['Некорректный формат email'],
},
},
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'),
),
);
when(
mockAuthRepository.signInViaSocialMedia(
provider: anyNamed('provider'),
accessToken: anyNamed('accessToken'),
refreshToken: anyNamed('refreshToken'),
clientId: anyNamed('clientId'),
),
).thenThrow(dioException);
when(
mockAuthRepository.signInViaSocialMedia(
provider: anyNamed('provider'),
accessToken: anyNamed('accessToken'),
refreshToken: anyNamed('refreshToken'),
clientId: anyNamed('clientId'),
),
).thenThrow(dioException);
await oauthCubit!.signInViaSocialMedia(
provider: signInRequestFixture['provider'] as String,
accessToken: signInRequestFixture['access_token'] as String,
refreshToken: signInRequestFixture['refresh_token'] as String,
clientId: signInRequestFixture['client_id'] as String,
);
await oauthCubit!.signInViaSocialMedia(
provider: signInRequestFixture['provider'] as String,
accessToken: signInRequestFixture['access_token'] as String,
refreshToken: signInRequestFixture['refresh_token'] as String,
clientId: signInRequestFixture['client_id'] as String,
);
await Future.delayed(const Duration(milliseconds: 100));
expect(oauthCubit!.state, isA<OAuthStateError>());
final errorState = oauthCubit!.state as OAuthStateError;
expect(errorState.errorMessage, 'validation_error');
});
await Future.delayed(const Duration(milliseconds: 100));
expect(oauthCubit!.state, isA<OAuthStateError>());
final errorState = oauthCubit!.state as OAuthStateError;
expect(errorState.errorMessage, 'validation_error');
},
);
test('signUpViaSocialMedia - обработка ошибки валидации email', () async {
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs);
oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
prefs: mockPrefs,
);
final dioException = DioException(
requestOptions: RequestOptions(path: '/api/auth/oauth/signup'),
@@ -282,82 +347,129 @@ void main() {
expect(result[0], RegistrationValidationErrors.incorrectEmailFormat);
});
test('requestOAuthVerification - успешный запрос кода верификации', () async {
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs);
test(
'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!.email, 't***@example.com');
expect(oauthCubit!.state, isA<OAuthState>());
});
expect(result, isNotNull);
expect(result!.email, 't***@example.com');
expect(oauthCubit!.state, isA<OAuthState>());
},
);
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(oauthCubit!.state, isA<OAuthStateError>());
});
test('confirmOAuthVerification - успешное подтверждение верификации', () async {
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs);
test(
'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(
mockAuthRepository.confirmOAuthVerification(
provider: anyNamed('provider'),
accessToken: anyNamed('accessToken'),
refreshToken: anyNamed('refreshToken'),
clientId: anyNamed('clientId'),
id: anyNamed('id'),
email: anyNamed('email'),
phone: anyNamed('phone'),
code: anyNamed('code'),
verificationType: anyNamed('verificationType'),
firstName: anyNamed('firstName'),
lastName: anyNamed('lastName'),
dateOfBirth: anyNamed('dateOfBirth'),
gender: anyNamed('gender'),
weight: anyNamed('weight'),
height: anyNamed('height'),
),
).thenAnswer((_) async => verificationResponse);
when(
mockAuthRepository.confirmOAuthVerification(
provider: anyNamed('provider'),
accessToken: anyNamed('accessToken'),
refreshToken: anyNamed('refreshToken'),
clientId: anyNamed('clientId'),
id: anyNamed('id'),
email: anyNamed('email'),
phone: anyNamed('phone'),
code: anyNamed('code'),
verificationType: anyNamed('verificationType'),
firstName: anyNamed('firstName'),
lastName: anyNamed('lastName'),
dateOfBirth: anyNamed('dateOfBirth'),
gender: anyNamed('gender'),
weight: anyNamed('weight'),
height: anyNamed('height'),
),
).thenAnswer((_) async => verificationResponse);
await oauthCubit!.confirmOAuthVerification(
provider: 'google',
accessToken: 'access_token',
refreshToken: 'refresh_token',
clientId: 'client_id',
id: 'id',
email: 'test@example.com',
phone: null,
code: '123456',
verificationType: 'email',
firstName: 'Test',
lastName: 'User',
dateOfBirth: '2000-01-01',
gender: 'male',
weight: 70,
height: 175,
);
await oauthCubit!.confirmOAuthVerification(
provider: 'google',
accessToken: 'access_token',
refreshToken: 'refresh_token',
clientId: 'client_id',
id: 'id',
email: 'test@example.com',
phone: null,
code: '123456',
verificationType: 'email',
firstName: 'Test',
lastName: 'User',
dateOfBirth: '2000-01-01',
gender: 'male',
weight: 70,
height: 175,
);
await Future.delayed(const Duration(milliseconds: 100));
expect(oauthCubit!.state, isA<OAuthStateLoaded>());
final loadedState = oauthCubit!.state as OAuthStateLoaded;
expect(loadedState.token, tokenFixture.token);
});
await Future.delayed(const Duration(milliseconds: 100));
expect(oauthCubit!.state, isA<OAuthStateLoaded>());
final loadedState = oauthCubit!.state as OAuthStateLoaded;
expect(loadedState.token, tokenFixture.token);
expect(await TokenStorage.getTokenFromStorage(), tokenFixture.token);
expect(fakeSession.onAuthenticatedCalls, 1);
},
);
test('confirmOAuthVerification - обработка ошибки', () async {
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs);
oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
prefs: mockPrefs,
);
when(
mockAuthRepository.confirmOAuthVerification(

View File

@@ -15,6 +15,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../../../../fixtures/doubles/auth_doubles.dart';
import '../../../../fixtures/doubles/profile_doubles.dart';
import '../../../../fixtures/doubles/session_doubles.dart';
import '../../../../fixtures/fixture_reader.dart';
import 'basic_widget_test.mocks.dart';
@@ -36,7 +37,7 @@ void main() {
mockFirebaseApi = MockFirebaseApi();
SharedPreferences.setMockInitialValues({});
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi);
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: FakeSessionLifecycleService());
});
tearDown(() {
@@ -55,7 +56,7 @@ void main() {
testWidgets('should create AuthCubit successfully', (WidgetTester tester) async {
// Arrange & Act
final cubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi);
final cubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: FakeSessionLifecycleService());
// Assert
expect(cubit, isA<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_state.dart';
import 'package:eatvio_app/features/profile/models/profile_model.dart';
@@ -5,6 +6,7 @@ import 'package:eatvio_app/features/profile/repositories/profile_repository.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'profile_avatar_cubit_test.mocks.dart';
@@ -16,6 +18,8 @@ void main() {
late MockProfileRepository mockRepository;
setUp(() {
SharedPreferences.setMockInitialValues({'token': 'test-token'});
TokenStorage.clearCache();
mockRepository = MockProfileRepository();
});

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,6 @@
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/repositories/registration_repository_v2.dart';
import 'package:flutter/services.dart';
@@ -7,45 +9,66 @@ import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../fixtures/doubles/session_doubles.dart';
import 'registration_cubit_v2_test.mocks.dart';
class _RecordingFirebaseApi extends Fake implements FirebaseApi {
int registerPushTokenCalls = 0;
bool hadAuthToken = false;
@override
Future<void> registerPushToken() async {
registerPushTokenCalls++;
hadAuthToken = await TokenStorage.haveTokenInStorage();
}
}
@GenerateMocks([RegistrationRepositoryV2])
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('RegistrationCubitV2', () {
late RegistrationCubitV2 cubit;
late MockRegistrationRepositoryV2 mockRepository;
const MethodChannel passwordGeneratorChannel = MethodChannel('flutter_password_generator');
late _RecordingFirebaseApi firebaseApi;
late FakeSessionLifecycleService fakeSession;
const MethodChannel passwordGeneratorChannel = MethodChannel(
'flutter_password_generator',
);
setUp(() {
mockRepository = MockRegistrationRepositoryV2();
cubit = RegistrationCubitV2(registrationRepository: mockRepository);
firebaseApi = _RecordingFirebaseApi();
fakeSession = FakeSessionLifecycleService();
cubit = RegistrationCubitV2(
registrationRepository: mockRepository,
firebaseApi: firebaseApi,
sessionLifecycleService: fakeSession,
);
// Инициализируем SharedPreferences для тестов
SharedPreferences.setMockInitialValues({});
// Мокируем MethodChannel для flutter_password_generator
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
passwordGeneratorChannel,
(MethodCall methodCall) async {
if (methodCall.method == 'generateSecureRandomData') {
// Возвращаем Uint8List для генерации пароля
// MethodChannel автоматически преобразует Uint8List в List<int>
return Uint8List.fromList(List<int>.generate(32, (i) => i % 256));
}
return null;
},
);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(passwordGeneratorChannel, (
MethodCall methodCall,
) async {
if (methodCall.method == 'generateSecureRandomData') {
// Возвращаем Uint8List для генерации пароля
// MethodChannel автоматически преобразует Uint8List в List<int>
return Uint8List.fromList(List<int>.generate(32, (i) => i % 256));
}
return null;
});
});
tearDown(() {
TokenStorage.clearCache();
cubit.close();
// Очищаем моки
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
passwordGeneratorChannel,
null,
);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(passwordGeneratorChannel, null);
});
test('начальное состояние', () {
@@ -182,14 +205,23 @@ void main() {
cubit.setBirthday(DateTime(1990, 1, 1));
const token = 'test-token';
when(mockRepository.register(registrationModel: anyNamed('registrationModel')))
.thenAnswer((_) async => token);
when(
mockRepository.register(
registrationModel: anyNamed('registrationModel'),
),
).thenAnswer((_) async => token);
final result = await cubit.register();
expect(result.$1, isTrue);
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 {
@@ -199,7 +231,11 @@ void main() {
expect(result.$1, isFalse);
expect(result.$2, isTrue);
verifyNever(mockRepository.register(registrationModel: anyNamed('registrationModel')));
verifyNever(
mockRepository.register(
registrationModel: anyNamed('registrationModel'),
),
);
});
test('register - обработка ошибки валидации email', () async {
@@ -226,16 +262,21 @@ void main() {
),
);
when(mockRepository.register(registrationModel: anyNamed('registrationModel')))
.thenThrow(dioException);
when(
mockRepository.register(
registrationModel: anyNamed('registrationModel'),
),
).thenThrow(dioException);
final result = await cubit.register();
expect(result.$1, isFalse);
expect(result.$2, isTrue);
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/consultations/repositories/consultations_repository.dart';
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
@@ -7,6 +8,7 @@ import 'package:eatvio_app/features/settings/repositories/service_repository.dar
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import '../../../../fixtures/doubles/session_doubles.dart';
import '../../../../fixtures/settings_fixtures.dart';
import 'settings_cubit_test.mocks.dart';
@@ -23,6 +25,7 @@ void main() {
late MockSettingsRepository mockSettingsRepository;
late MockConsultantRepository mockConsultantRepository;
late MockConsultationsRepository mockConsultationsRepository;
late FakeSessionLifecycleService fakeSession;
setUp(() {
@@ -30,6 +33,7 @@ void main() {
mockSettingsRepository = MockSettingsRepository();
mockConsultantRepository = MockConsultantRepository();
mockConsultationsRepository = MockConsultationsRepository();
fakeSession = FakeSessionLifecycleService();
});
tearDown(() async {
@@ -46,6 +50,7 @@ void main() {
settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: fakeSession,
);
expect(settingsCubit!.state, const SettingsState.loading());
});
@@ -56,6 +61,7 @@ void main() {
settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: fakeSession,
);
when(mockProfileRepository.getMyProfile())
@@ -86,6 +92,7 @@ void main() {
settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: fakeSession,
);
// Ошибка происходит при загрузке настроек профиля (внутри try-catch блока)
@@ -114,6 +121,7 @@ void main() {
settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: fakeSession,
);
when(mockProfileRepository.getMyProfile())
@@ -145,6 +153,7 @@ void main() {
settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: fakeSession,
);
when(mockProfileRepository.getMyProfile())
@@ -176,6 +185,7 @@ void main() {
settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: fakeSession,
);
when(mockProfileRepository.deleteAccount())
@@ -184,6 +194,8 @@ void main() {
final result = await settingsCubit!.deleteAccount();
expect(result, true);
expect(fakeSession.endSessionCalls, 1);
expect(fakeSession.lastEndReason, SessionEndReason.accountDeleted);
verify(mockProfileRepository.deleteAccount()).called(1);
});
@@ -193,6 +205,7 @@ void main() {
settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: fakeSession,
);
when(mockProfileRepository.deleteAccount())
@@ -209,6 +222,7 @@ void main() {
settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: fakeSession,
);
when(mockProfileRepository.getMyProfile())
@@ -244,6 +258,7 @@ void main() {
settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: fakeSession,
);
when(mockProfileRepository.getMyProfile())

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_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../../../fixtures/doubles/session_doubles.dart';
import '../../../../fixtures/settings_fixtures.dart';
import '../bloc/settings_cubit_test.mocks.dart';
@@ -27,6 +28,7 @@ void main() {
settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: FakeSessionLifecycleService(),
);
});

View File

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

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++;
}
}